August 14, 2026
Hacker Holidays Day 10: The Hollow Shell
The setup
By Devyaniitware
3 min read
The setup
Day 10's theme is a beach souvenir with a double meaning — guests can upload a "shell" (a .zip file containing images/stylesheets) to personalize their in-room display's ambiance. The room's own hint gives away the trick outright: "slip past what the portal forgets to check, and the shell answers with a shell of your own."
What's expected: find the single flag by getting arbitrary code execution through the upload feature.
Quick definitions before we start
- Zip Slip — a real, well-known vulnerability where a
.zipfile's internal filenames contain path traversal sequences (../../). If the server extracting the zip doesn't sanitize those filenames, it can be tricked into writing files outside the folder it intended to extract into. - SSTI (Server-Side Template Injection) — covered in an earlier post, but relevant again here: if an app renders a template file that an attacker can overwrite, planting real template syntax in that file can get it executed as code the next time the page loads.
- Template caching — many web frameworks compile a template into memory the first time it's rendered, then reuse that compiled version on later requests instead of re-reading the file from disk. This means overwriting a template file doesn't guarantee the change takes effect immediately — not until the cache is invalidated or the process restarts.
How I started
The upload page (reached after logging in with a leftover demo login — concierge/StayNoticed2024!, found sitting in an HTML comment, same pattern as Day 5's Beach Bar) accepted a .zip containing a shell.json manifest listing "assets." The page also mentioned optional "automation hooks" that a background "theme worker" applies shortly after upload — a strong hint that something processes uploaded content beyond just storing it.
A normal upload confirmed the storage pattern: each shell landed at a predictable path, shells/<hex-id>/.
My thinking process — a long chain of dead ends before the real bug
Automation hooks theory (failed). I tried dozens of guesses for a JSON field name that might reference an executable hook script — hook, script, postprocess, webhook, and many more, both as top-level fields and nested inside assets as objects ({"type": "...", "path": "..."}). Every object-format entry got explicitly rejected — "asset type not allowed" — even when the type and extension exactly matched the app's own stated whitelist. This ruled out the hooks theory entirely; assets turned out to just want plain filename strings, nothing more.
ImageTragick theory (failed). Since the allowed asset types (png jpg gif svg css json) matched formats a real image library might process, I tried a classic ImageTragick-style payload — a file with a .png extension that's secretly a different format internally, designed to trigger command execution if processed by a vulnerable image library. No evidence it was ever actually processed this way.
XXE via SVG (failed). SVG is technically XML, so a malicious SVG with an external entity reference could, in a vulnerable app, read arbitrary local files. The uploaded SVG was just stored and served back raw — never parsed as XML at all.
Zip Slip write primitive (confirmed, but hard to weaponize at first). Crafting a zip with Python's zipfile module (the command-line zip tool sanitizes traversal paths, so this needed to be built manually) let me embed real ../../ sequences as literal filenames. Testing several depths against the static/ folder (a location I could verify by fetching the file back over plain HTTP) confirmed: exactly two levels up (../../) landed precisely in the app's own root directory.
With a confirmed write primitive, I tried several targets that all failed:
- Planting an SSH public key into various guessed home directories'
.ssh/authorized_keys— every attempt returned a server error, since none of those directories/subfolders actually existed to write into. - Testing which home directories existed at all, one by one — every guess also failed cleanly, ruling out that entire branch.
The actual working exploit. I overwrote templates/login.html directly with a Jinja2 SSTI payload designed to run os.popen('id') when the template rendered. The very first attempt appeared to do nothing — the login page looked completely unchanged. The reason: the app had already rendered that template once before (from normal recon traffic), so Flask/Jinja2 was serving the cached, compiled version instead of re-reading the file from disk.
The fix: restarting the target machine gave a genuinely fresh process that had never rendered /login yet. Uploading the same malicious login.html immediately, before anything else could trigger a normal render, meant the very next visit to /login compiled the template fresh — from our poisoned file. That attempt worked instantly, showing real command output (uid=996(roomservice)) in place of the login form.
Getting the flag
Sending a reverse shell through the same technique (swapping the id command for a bash reverse shell one-liner) dropped a shell as roomservice. The flag was sitting directly in that account's home directory.
find / -iname "*flag*" 2>/dev/nullfind / -iname "*flag*" 2>/dev/nullturned up /home/roomservice/flag.txt.
The big takeaway
Never trust filenames inside an uploaded archive — a
.zip's internal paths can contain traversal sequences just like any other user input, and extraction code needs to explicitly sanitize them. Separately: template caching can hide a successful exploit from you — if an attack "does nothing" against a running app, it's worth considering whether the target has already cached something that would only refresh on a fresh process, not assuming the technique itself failed.
Next up: Day 11, Infinity Pool, a chain that started with a "ping this host" tool and ended with a voicemail message hiding a root-level API key in its caller ID.
No template cache to fight through this time — LinkedIn and GitHub, right where you'd expect them.