Fancy Food Notifications
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.
$ ls tags/ techniques/
$ cat /etc/rate-limit
Rate limit reached (20 reads/hour per IP). Showing preview only — full content returns at the next hour roll-over.
Fancy Food Notifications — GPNCTF 2025 (KITCTF)
Description
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.- An
Authorizationheader carrying a base64-wrapped HS256 JWT whose claimvipisTrue, signed with the server's secretkey.
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.
Analysis
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.
Bug 1 — Predictable JWT secret: only 258 possible keys
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.
...
$ grep --similar
Similar writeups
- [web][free]Simple food notifications— gpn24
- [infra][free]Old food— gpnctf24
- [web][free]SecretPickle— gpnctf
- [web][Pro]QuickBite — SSTI via Registration Name Field— hackadvisor
- [pwn][free]Recipe for Disaster— gpnctf