$ cat writeup.md…
$ cat writeup.md…
gpn24
Task: Flask food-ordering app with an SSRF sink that validates the resolved IP with ipaddress.is_global before urllib3 fetches the URL; flag served at /vip-meal only to remote_addr 127.0.0.1. Solution: DNS rebinding TOCTOU via 1u.ms — host resolves to public 8.8.8.8 during the is_global check (passes), then urllib3's connect-timeout-and-retry triggers a fresh resolution after the dnsmasq 2s cache expires, rebinding to 127.0.0.1 and hitting the loopback-only VIP endpoint.
We are a new high tech startup in the food industry. In other words we are a new restaurant. Our last system was too complex, we made it simpler for you.
A Flask app handout (tar.gz) with full source was provided. The title/theme
("simpler") foreshadows the flag: why make it complex when you can make it
simple. The goal is to coerce the server into requesting an internal,
loopback-only endpoint that returns the flag.
The app is a small restaurant ordering system. Key routes and logic from
app/app.py:
FLAG is read from /flag./vip-meal returns the flag only if request.remote_addr == "127.0.0.1",
otherwise 401 "You are not dressed appropriate to see even vip meals."
So the server itself must request http://127.0.0.1/vip-meal (classic SSRF to
loopback)./order (POST, form param url) is the SSRF sink. Globally rate-limited
to one request per 60s. It spawns a background thread create_meal(id, url)./notification/<id> (GET) returns JSON {id, message, status} — this is
how the attacker reads back the SSRF response body (and thus the flag).create_meal(id, url) flow:
time.sleep(secrets.randbelow(15-5)+5) — random 5–15s "let him cook".addresses = socket.getaddrinfo(urllib3.util.parse_url(url).host, 80).if not ipaddress.ip_address(addr).is_global: -> REJECTED.
This blocks every private/loopback/link-local IP. Confirmed: 127.0.0.1,
0.0.0.0, 169.254.169.254, 10/192.168, ::1, ::ffff:127.0.0.1 all have
is_global == False; 8.8.8.8 / 1.2.3.4 are is_global == True.r = urllib3.request('GET', url, redirect=False, timeout=urllib3.Timeout(30)).
urllib3 re-resolves the host independently when it opens the connection.r.data in notifications[id]["message"], status DONE.Environment (entrypoint.sh, dnsmasq.conf):
entrypoint.sh runs dnsmasq --user=root &, sets /etc/resolv.conf to
nameserver 127.0.0.1, then runs Flask on port 80.dnsmasq.conf: min-cache-ttl=2, server=8.8.8.8, listen-address=127.0.0.1,
no-resolv. The min-cache-ttl=2 is the deliberate challenge knob — it forces
every DNS answer (even TTL=0) to be cached for 2 seconds.requirements.txt: flask==3.1.3, requests==2.34.2, urllib3==2.7.0.SSRF via DNS rebinding (TOCTOU — Time-Of-Check / Time-Of-Use). The
is_global blocklist is checked against the result of DNS resolution #1
(getaddrinfo), but urllib3 performs its own independent DNS resolution #2
when it opens the connection. If the hostname resolves to a public IP during the
check and to 127.0.0.1 during the use, the loopback filter is bypassed and the
request hits 127.0.0.1/vip-meal with remote_addr == 127.0.0.1.
127.0.0.1, 0.0.0.0, ::1,
::ffff:127.0.0.1, 169.254.169.254, decimal/octal/hex variants) all fail the
is_global check (all non-global).@, backslash \@, #@) do
not desync here: BOTH the check (urllib3.util.parse_url(url).host) and the
actual request use the same urllib3 parser, so they always agree on the
host. Verified empirically — parser confusion is a dead end here. DNS-level
rebinding is the real path.The check (getaddrinfo) and the use (urllib3) run back-to-back with a
sub-millisecond gap. With min-cache-ttl=2, the second resolution normally hits
the dnsmasq cache and returns the same IP as the first → both see the public
IP → no rebind. Verified: back-to-back getaddrinfo inside the container both
returned 8.8.8.8.
Winning insight: the real gap between check and use is created by urllib3's
connect-timeout + retry, not by the code. When urllib3 first resolves to the
public decoy IP (from cache) and tries to connect to PUBLIC_IP:80, the
connection hangs (port 80 closed/filtered on the decoy, ~connect-timeout).
When the connect fails, urllib3 retries, performing a NEW getaddrinfo. By
then (>2s later) the dnsmasq 2s cache has expired and the rebind window is
active, so the retry resolves to 127.0.0.1 and connects to loopback.
Instrumented timeline (getaddrinfo hook inside the container):
[res t=0.10] -> ['8.8.8.8', '8.8.8.8', '8.8.8.8'] # CHECK passes is_global
[res t=0.10] -> ['8.8.8.8'] # urllib3 try1 (cache) -> connects 8.8.8.8:80, hangs
[res t=30.23] -> ['127.0.0.1'] # urllib3 RETRY after timeout -> rebound to loopback
USE status=200 FLAG retrieved
1u.ms (free, zero-config, by @neexemil / Emil Lerner) provides controllable
rebinding via hostname syntax:
make-<IP1>-rebind-<IP2>-rr.1u.ms resolves to IP1 on the first query, then to
IP2 within a timeout window (default 5s).rebindfor<interval> (e.g. rebindfor5m).<prefix>-make-...-rr.1u.ms.<interval> → IP1, else IP2." Confirmed with
dig.Final payload host:
<unique>-make-8.8.8.8-rebindfor5m-127.0.0.1-rr.1u.ms
8.8.8.8 (is_global == True → passes filter; its :80 hangs from the
container, giving the timeout/retry gap).127.0.0.1 (loopback → hits /vip-meal as 127.0.0.1).rebindfor5m = 5-minute window, chosen to FAR exceed the total attack time
(~30s, one urllib3 connect-timeout cycle). This turns a probabilistic race into
a deterministic, first-try exploit.Important lesson: a too-short window like rebindfor30s FAILED in testing
because the urllib3 connect-timeout consumed the whole window before the retry.
Always size the rebind window to comfortably exceed the full attack duration.
Full payload URL:
http://<unique>-make-8.8.8.8-rebindfor5m-127.0.0.1-rr.1u.ms/vip-meal
/order with
url=http://<unique>-make-8.8.8.8-rebindfor5m-127.0.0.1-rr.1u.ms/vip-meal.
Response gives a notification id (10 lowercase-alnum chars)./notification/<id>. Status: RECEIVED → COOKING (during the
5–15s sleep + urllib3 timeout/retry, ~30–40s total) → DONE.DONE, the message field holds the rendered /vip-meal page:
"Our chef cooked the beast meal for our vip customers, here is the flag
GPNCTF{...} with some caviar on top."/order between attempts.run.sh)#!/bin/bash # Usage: ./run.sh http://REMOTE_HOST:PORT set -e BASE="${1:?usage: ./run.sh http://host:port}" PREFIX="sfn$RANDOM$RANDOM" REBIND="${PREFIX}-make-8.8.8.8-rebindfor5m-127.0.0.1-rr.1u.ms" URL="http://${REBIND}/vip-meal" RESP=$(curl -s -X POST "$BASE/order" --data-urlencode "url=$URL") NID=$(echo "$RESP" | grep -o 'notification/[a-z0-9]*' | head -1 | cut -d/ -f2) for i in $(seq 1 60); do J=$(curl -s "$BASE/notification/$NID") ST=$(echo "$J" | sed -n 's/.*"status": *"\([^"]*\)".*/\1/p') case "$ST" in DONE) echo "$J" | grep -o 'GPNCTF{[^}]*}'; exit 0;; FAILED|REJECTED) echo "$J"; exit 1;; esac sleep 3 done
Target: https://wood-fired-pizza-alongside-julienned-gremolata-sm1n.gpn24.ctf.kitctf.de
/vip-meal external = 401 (as expected).pyzjcs00x0, polled COOKING ... at poll 13 → DONE.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar