webfreemedium

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/
python_xor_vs_pow_seed_pitfallmersenne_twister_state_leak_via_returned_idjwt_hs256_secret_recovery_by_seed_bruteforcejwt_forgery_vip_truessrf_dns_rebinding_1u_msrequests_userinfo_authorization_basic_overridebase64_then_charfilter_colon_drop_normalizationipaddress_is_global_allowlist_bypassssrf_response_exfiltration

$ 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:

  1. request.remote_addr == "127.0.0.1" — the request must originate from localhost.
  2. An 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.

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