GhostLabs

GhostAuth

ejercicios de burp suite :: servicio de identidad y notas privadas
ghostauth-lab — write-up
Laboratorio
GhostAuth
Dificultad
★★★★☆ media-alta
Categoría
JWT / Autenticación rota
Objetivo
http://127.0.0.1:5003
Herramientas
Burp Repeater · extensión JWT Editor · script Python
Retos
3
⚠ Este documento contiene la solución completa de los 3 retos — resuélvelos primero por tu cuenta

Descripción del laboratorio

GhostAuth es un servicio de identidad y notas privadas de juguete: te registras, guardas notas propias, puedes generar un token de API personal para consultar tus notas desde fuera de la aplicación, y puedes conectar una integración de partner para que un servicio externo de confianza acceda a tus datos sin conocer tu contraseña. Corre en local (python app/app.py), usa SQLite para usuarios y notas, y no depende de ningún servicio externo una vez arrancada.

A diferencia de GhostFlix, GhostCart, GhostGram y GhostRace —que usan cookies de sesión clásicas de Flask—, GhostAuth cambia deliberadamente de convención: la sesión viaja como JWT (JSON Web Token) en la cabecera Authorization: Bearer <token> en cada petición a la API. Es el cambio de skill que hace falta para este laboratorio: en vez de mirar cookies en Burp, vas a capturar, decodificar y reforjar tokens con la extensión gratuita JWT Editor del BApp Store.

Este laboratorio no está basado en un vídeo concreto de Gorka El Bochi Morillo (no existe ninguno suyo sobre JWT). Los 3 fallos son, en cambio, las tres vulnerabilidades de JWT más documentadas del sector — equivalentes conceptualmente a los laboratorios "JWT authentication bypass" de PortSwigger Web Security Academy y a las entradas correspondientes de HackTricks — elegidas por ser las que más se repiten en informes reales de bug bounty y pentest.

Lo único que hace falta saber de un JWT

Un JWT son tres trozos separados por puntos, cada uno codificado en Base64url:

header.payload.firma eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOjUsInVzZXJuYW1lIjoiYWx1bW5vX2dob3N0Iiwicm9sZSI6InVzZXIifQ . LfhGDUyWU4PoOFfpTdWyw4Ji0L961b6QuTktYBDqJ6Y └──────────────── header ────────────────┘ └──────────────────── payload ─────────────────────┘ └──────────── firma ────────────┘ {"alg":"HS256","typ":"JWT"} {"sub":5,"username":"alumno_ghost","role":"user"} Base64 NO es cifrado. Es sólo una codificación reversible: cualquiera puede decodificar el header y el payload de SU PROPIO token con un decodificador Base64 normal (o con Burp Decoder) y leer exactamente lo que dicen -- y puede volver a codificar una versión editada exactamente igual de fácil. Lo ÚNICO que debería impedir que un servidor acepte un token modificado es la firma -- y sólo si el servidor la comprueba correctamente.

Los 3 retos de este laboratorio son 3 formas distintas en las que un verificador de firma puede estar mal escrito, cada una en un tipo de token diferente de los que emite GhostAuth:

▶ Reto 1 → Token de sesión — el verificador confía en "alg":"none"
▶ Reto 2 → Token de API personal — firmado con un secreto HS256 corto y adivinable
▶ Reto 3 → Token de partner — confusión de algoritmo RS256→HS256 usando la clave pública como secreto

Los 3 caminos llevan al mismo objetivo final: GET /api/admin/notes, un endpoint que acepta cualquiera de los 3 tipos de token siempre que sus claims digan "role":"admin" — igual que el patrón "misma puerta, tres llaves" que ya vimos en GhostGram. La cuenta admin tiene una contraseña aleatoria que nunca hace falta conocer ni adivinar: el objetivo es leer sus notas falsificando un token, no iniciar sesión como ella.

Antes de empezar: crea tu propia cuenta en /register. No hace falta ninguna cuenta de "víctima" — en los 3 retos partes de un token legítimo de tu propia cuenta (con "role":"user") y lo modificas para que el servidor crea que tiene "role":"admin". Usa /reset en cualquier momento para reiniciar la base de datos (usuarios y notas); los secretos de firma y las claves RSA del partner no cambian con el reset, así que un token que ya hayas forjado sigue funcionando después.


1Token de sesión sin firma verificada

GET /api/admin/notes · extensión JWT Editor · alg:none

El token de sesión que recibes al iniciar sesión o registrarte se firma con HS256 usando un secreto largo y aleatorio que vive en el servidor. Ese secreto no es el problema — es lo bastante largo como para no ser adivinable ni crackeable. El problema es que el código que verifica la sesión tiene esta forma:

def verify_session_token(token):
    header, payload = ghost_jwt.unverified_header_and_payload(token)
    alg = header.get("alg")

    if alg == "none":
        # Un desarrollador anadio esto para probar la API sin firmar
        # tokens durante el desarrollo, y el flag nunca se quito.
        return payload
    elif alg == "HS256":
        # ... aqui si se comprueba la firma correctamente ...
        ...

El verificador confía en lo que dice el propio alg de la cabecera del token — y esa cabecera la controla quien envía la petición. Si mandamos un token cuya cabecera diga {"alg":"none"}, el servidor se salta la comprobación de firma por completo, sin importar qué diga el payload.

0
Crear una cuenta y capturar tráfico con un JWT

Con Burp como proxy (o su navegador embebido) y Proxy > Intercept desactivado, nos registramos desde /register y visitamos "Mis notas". En cuanto la app manda su primera petición autenticada (GET /api/notes, con la cabecera Authorization: Bearer <token>), la extensión JWT Editor la detecta automáticamente y lo anota en la columna "Notes" del historial:

Historial HTTP de Burp mostrando peticiones con la nota 1 JWTs, 0 JWEs
Proxy > HTTP history — JWT Editor marca "1 JWTs, 0 JWEs" en las peticiones que llevan un token
1
Abrir la pestaña "JSON Web Token"

Al abrir cualquiera de esas peticiones, aparece una pestaña nueva junto a Pretty/Raw/Hex: "JSON Web Token". Ahí JWT Editor te muestra el header y el payload ya decodificados y editables, y la firma en hexadecimal — sin tener que copiar el token a mano a un decodificador externo:

Pestana JSON Web Token mostrando header alg HS256 y payload con role user
Pestaña "JSON Web Token" — header {"alg":"HS256","typ":"JWT"}, payload con "role":"user"
2
Mandar la petición a Repeater

Clic derecho sobre la petición > "Send to Repeater" (o Ctrl+R). En Repeater es donde vamos a editar el token y a cambiar el endpoint de destino:

Menu contextual con la opcion Send to Repeater sobre la peticion GET /api/notes
Clic derecho sobre GET /api/notes > Send to Repeater
3
Forjar el token: alg "none" + role "admin"

Ya en Repeater, hacemos dos cambios:

  • Cambiamos la ruta de la primera línea a GET /api/admin/notes.
  • En la pestaña "JSON Web Token", editamos el payload para que diga "role":"admin" en vez de "role":"user", y cambiamos la cabecera a {"alg":"none","typ":"JWT"}. JWT Editor tiene un botón "Sign" con una opción explícita "None" que hace esto en un clic (pone el algoritmo en none y vacía la firma); también se puede editar el JSON a mano y dejar la tercera parte del token vacía.

El resultado es una petición con este aspecto exacto:

Peticion Raw GET /api/admin/notes con Authorization Bearer y token alg none
GET /api/admin/notes con Authorization: Bearer eyJhbGciOiAibm9uZSIsICJ0eXAiOiAiSldUIn0.eyJzdWIiOiA1LCAidXNlcm5hbWUiOiAiYWx1bW5vX2dob3N0IiwgInJvbGUiOiAiYWRtaW4ifQ. — nótese el punto final sin nada después: la firma está vacía porque alg es none
Puedes decodificar tú mismo la cabecera y el payload de ese token: eyJhbGciOiAibm9uZSIsICJ0eXAiOiAiSldUIn0 en Base64url es {"alg": "none", "typ": "JWT"}, y el segundo trozo es {"sub": 5, "username": "alumno_ghost", "role": "admin"}. Todo a la vista, nada cifrado.
4
Enviar y comprobar la respuesta

Pulsamos "Send". La respuesta es un 200 OK con las notas de la cuenta admin:

Resultado
GHOSTAUTH{jwt_es_solo_base64_no_cifrado}
Respuesta 200 OK con las notas de admin y la flag del laboratorio
200 OK — "username":"admin", nota con la flag del laboratorio
Causa raíz: el verificador de sesión trata "alg":"none" como un caso válido más, en vez de rechazarlo siempre. Un servidor correcto debe fijar de antemano qué algoritmo espera (por ejemplo, sólo HS256) e ignorar por completo lo que diga el alg del token que llega — nunca dejar que el token decida cómo se va a verificar a sí mismo.

2Secreto HS256 débil en el token de API

GET /api/admin/notes · crack_secret.py · JWT Editor

En "Mis notas", el botón "Generar token de API" emite un token de larga duración pensado para consultar tus notas desde fuera de la app. Este token no acepta "alg":"none" —a propósito, para que este reto sea inequívocamente sobre el secreto, no una repetición del reto 1— pero su verificador comprueba la firma HS256 contra un secreto que resulta ser corto y de aspecto típico de entorno de desarrollo:

def verify_api_token(token):
    header, payload = ghost_jwt.unverified_header_and_payload(token)
    if header.get("alg") != "HS256":
        raise InvalidToken()
    signing_input, sig_b64 = token.rsplit(".", 1)
    expected = ghost_jwt.hmac_sha256(API_TOKEN_SECRET, signing_input)
    if not hmac.compare_digest(expected, ghost_jwt._b64url_decode(sig_b64)):
        raise InvalidToken()
    return payload

HS256 es sólo HMAC-SHA256: si el secreto es corto, se puede recalcular la firma contra una lista corta de candidatos hasta que una coincida — exactamente igual que crackear un hash de contraseña, pero contra la firma del token en vez de contra un hash almacenado. No hace falta Hashcat ni Burp Intruder: un script de 30 líneas con hmac y hashlib puros basta.

1
Generar tu token de API

En "Mis notas", pulsamos "Generar token de API":

Pagina Mis notas con el token de API generado visible en un recuadro
/notas — token de API generado y mostrado en pantalla

En el historial de Burp, la petición POST /api/tokens/api confirma el token exacto que se emitió:

Historial de Burp con la peticion POST api/tokens/api resaltada
Proxy > HTTP history — POST /api/tokens/api, marcada como "1 JWTs, 0 JWEs"
Respuesta JSON con el token de API y su header/payload decodificado en JWT Editor
Respuesta con el token en claro, y su header/payload ya decodificados por JWT Editor: {"alg":"HS256","typ":"JWT"} / {"sub":5,"username":"alumno_ghost","role":"user","typ":"api"}
2
Crackear el secreto offline

Con el token copiado, ejecutamos crack_secret.py (viene en el ZIP del alumno) pasando el token como argumento. El script recalcula la firma HS256 del header.payload real del token contra cada palabra de una wordlist corta de ~30 secretos de desarrollo típicos, y compara byte a byte con la firma real:

python crack_secret.py "eyJhbGciOiJIUzI1NiIs...<tu token completo>"
Terminal mostrando crack_secret.py encontrando el secreto ghostauth-dev2024
python crack_secret.py <token> — [+] Secreto encontrado: 'ghostauth-dev2024'
En un pentest real, esta misma idea se automatiza con Hashcat en modo -m 16500 (JWT), pasándole el token y una wordlist de verdad (por ejemplo rockyou.txt filtrada). Aquí una wordlist de 30 palabras es suficiente porque el objetivo pedagógico es entender qué se está crackeando, no la fuerza bruta en sí.
3
Firmar un token nuevo con el secreto encontrado

Con el secreto ghostauth-dev2024 en la mano, hay dos formas de firmar un token nuevo con "role":"admin":

  • En JWT Editor, pestaña "Keys" > "New Symmetric Key", pegando ghostauth-dev2024 como valor; luego, en la pestaña "JSON Web Token" de la petición, editar el payload a "role":"admin" y pulsar "Sign", eligiendo esa clave simétrica.
  • Con el mismo script Python, calculando hmac.new(b"ghostauth-dev2024", signing_input, sha256) a mano.

El resultado es una petición como esta:

Peticion GET /api/admin/notes con un nuevo token HS256 firmado con el secreto crackeado
GET /api/admin/notes con un token nuevo, mismo alg:HS256, payload con "role":"admin", firmado correctamente con el secreto ya conocido
4
Enviar y comprobar
Resultado
GHOSTAUTH{jwt_es_solo_base64_no_cifrado}
Respuesta 200 OK con las notas de admin usando el token firmado con el secreto crackeado
200 OK — misma flag, esta vez con una firma HS256 totalmente válida, sólo que firmada con un secreto que nunca debería haber sido adivinable
Causa raíz: aquí la verificación de la firma es correcta — el fallo está en la fuerza del secreto, no en la lógica. Un secreto HS256 debe ser tan largo y aleatorio como una clave criptográfica de verdad (256 bits, generado con secrets.token_hex(32) o similar), nunca una cadena memorable "de aspecto seguro".

3Confusión de algoritmo RS256→HS256

GET /api/admin/notes · clave pública como secreto HMAC

La "Integración de partner" firma tokens con RS256 (RSA + SHA-256): GhostAuth los firma con su clave privada, y cualquier integración externa puede comprobar su autenticidad con la clave pública correspondiente — publicada a propósito en /.well-known/jwks.json y en /api/partner/public-key.pem, porque una clave pública, por definición, no es secreta. El fallo está en que el mismo verificador también acepta HS256, reutilizando esa misma clave pública como si fuera un secreto simétrico:

def verify_partner_token(token):
    header, payload = ghost_jwt.unverified_header_and_payload(token)
    alg = header.get("alg")

    if alg == "RS256":
        partner_public_key.verify(signature, signing_input, ...)
        return payload
    elif alg == "HS256":
        # partner_public_key_pem es la clave PUBLICA en PEM -- no es secreta,
        # pero aqui se usa como si lo fuera
        expected = ghost_jwt.hmac_sha256(partner_public_key_pem, signing_input)
        if not hmac.compare_digest(expected, signature):
            raise InvalidToken()
        return payload

Como la clave pública es, precisamente, pública, cualquiera puede descargarla, usar su contenido tal cual (los bytes del PEM, con cabeceras -----BEGIN PUBLIC KEY----- incluidas) como clave HMAC, y firmar un token HS256 que el servidor va a validar con éxito contra esa misma clave.

1
Conectar la integración de partner

Desde "Integración partner", pulsamos "Conectar integración de partner":

Pagina Integracion de partner antes de conectar, con enlaces a JWKS y a la clave publica PEM
/partner — enlaces a /.well-known/jwks.json y /api/partner/public-key.pem
Token de partner RS256 generado y mostrado en pantalla
Token de partner generado: cabecera {"alg":"RS256","typ":"JWT"}
2
Descargar la clave pública

Visitamos GET /api/partner/public-key.pem (con el navegador, con curl, o desde el propio Burp) y guardamos el contenido tal cual, incluidas las líneas -----BEGIN PUBLIC KEY----- / -----END PUBLIC KEY-----:

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1sQJIYW25DsLTWUByejV
tKkV+jKd3fGR0MEhIOlI1pZjJrFry4zU/ssrC0uImf3z9opagqcP/wKTkLlZtZDG
UcpFDJysyyMr+zJKpKmYjVgPkkLzc/ECBycmslRFXU3DlzqzoKYhj1OZ2jKxDSM7
VD/kenQJllLA800vLjwT/0obC6cd/jGZYDJs5rsAi0SQlFF/oKDmLF3wREhaKW10
39rptVGhJTqDNinhVfcYZV0T1AZgmbzOkxXi86sxGNaGDECdQtku0ZoxaphtnDrO
N3Q7q5pn2+gsl2/0sLPGbX63G58Up4Pc3CuHhxuIoJS8wwaEqtoZ8bGx8XKbzOOD
JwIDAQAB
-----END PUBLIC KEY-----
Esta misma clave, en formato JWK (JSON), está en /.well-known/jwks.json — el formato estándar que usan la mayoría de librerías JWT para publicar claves públicas de verificación. Para este ataque nos interesa el PEM porque es el que se usa tal cual como clave simétrica.
3
Firmar un token HS256 con la clave pública

En JWT Editor, pestaña "Keys" > "New Symmetric Key" > pegamos el contenido completo del PEM (tal cual, como texto) como valor de la clave. Editamos el payload del token de partner a "role":"admin", cambiamos la cabecera a {"alg":"HS256","typ":"JWT"}, y firmamos con esa clave simétrica — que en realidad es la clave pública RSA del servidor.

Peticion GET /api/admin/notes con un token HS256 firmado con la clave publica del partner
GET /api/admin/notes con un token alg:HS256 nuevo, payload con "role":"admin", firmado usando el PEM de la clave pública como secreto HMAC
4
Enviar y comprobar
Resultado
GHOSTAUTH{jwt_es_solo_base64_no_cifrado}
Respuesta 200 OK con las notas de admin usando el token de confusion de algoritmo
200 OK — la misma flag, con un token que nunca fue firmado por la clave privada del servidor
Causa raíz: el código asumió que este verificador sólo vería nunca tokens RS256, y reutilizó la misma variable de "clave" para el caso HS256 sin darse cuenta de que en ese contexto esa clave es la pública. Un verificador correcto no debe aceptar más de un algoritmo por token: si un endpoint espera RS256, debe rechazar cualquier token que llegue con "alg":"HS256", sin excepciones.

Cómo se protege cada fallo

Los 3 retos comparten la misma lección de fondo — nunca dejar que el token decida cómo se va a verificar a sí mismo — pero cada uno se arregla de forma distinta:

RetoFalloMitigación concreta
1 Verificador acepta "alg":"none" Fijar de antemano el algoritmo esperado (por ejemplo, sólo HS256) y rechazar cualquier token cuyo alg no coincida, sin excepción para "none" ni siquiera en desarrollo.
2 Secreto HS256 corto y adivinable Generar el secreto con un generador criptográfico (secrets.token_hex(32) o equivalente), nunca una cadena memorable, y rotarlo si se sospecha que se ha filtrado.
3 Confusión de algoritmo RS256→HS256 El verificador debe exigir un único algoritmo por endpoint/tipo de token (nunca "cualquiera de estos dos"), y usar librerías JWT modernas que obliguen a declarar algorithms=["RS256"] explícitamente en vez de leer el alg del propio token.
Las 3 mitigaciones tienen algo en común: en ningún caso el servidor debería fiarse de lo que el token dice sobre sí mismo. El algoritmo, la clave y el tipo de verificación deben decidirse en el código del servidor, de antemano, para cada endpoint — el token sólo aporta los datos a verificar contra esa decisión ya tomada.

Herramientas utilizadas

HerramientaUso
Proxy > HTTP historyDetectar automáticamente los JWT en el tráfico capturado (columna "Notes", vía la extensión JWT Editor)
Extensión JWT Editor (BApp Store)Decodificar y editar header/payload de un JWT visualmente, gestionar claves (RSA y simétricas) y firmar tokens nuevos con un clic
RepeaterModificar la ruta y la cabecera Authorization de la petición forjada y reenviarla contra /api/admin/notes
crack_secret.pyScript propio (incluido en este paquete) para crackear offline un secreto HS256 corto probando una wordlist contra la firma real del token
Decodificador Base64urlConfirmar a mano que header y payload de un JWT son texto plano legible, no datos cifrados