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:
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:
"alg":"none"
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
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.
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:
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:
{"alg":"HS256","typ":"JWT"}, payload con "role":"user"
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:
GET /api/notes > Send to RepeaterYa 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 ennoney 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:
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
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.
Pulsamos "Send". La respuesta es un 200 OK con las notas de la cuenta admin:
200 OK — "username":"admin", nota con la flag del laboratorio"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
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.
En "Mis notas", pulsamos "Generar token de API":
En el historial de Burp, la petición POST /api/tokens/api confirma el token exacto que se emitió:
POST /api/tokens/api, marcada como "1 JWTs, 0 JWEs"
{"alg":"HS256","typ":"JWT"} / {"sub":5,"username":"alumno_ghost","role":"user","typ":"api"}
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>"
python crack_secret.py <token> — [+] Secreto encontrado: 'ghostauth-dev2024'
-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í.
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-dev2024como 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:
GET /api/admin/notes con un token nuevo, mismo alg:HS256, payload con "role":"admin", firmado correctamente con el secreto ya conocido
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 adivinablesecrets.token_hex(32) o similar), nunca una cadena
memorable "de aspecto seguro".
3Confusión de algoritmo RS256→HS256
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.
Desde "Integración partner", pulsamos "Conectar integración de partner":
/.well-known/jwks.json y /api/partner/public-key.pem
{"alg":"RS256","typ":"JWT"}
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-----
/.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.
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.
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
200 OK — la misma flag, con un token que nunca fue firmado por la clave privada del servidor"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:
| Reto | Fallo | Mitigació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. |
Herramientas utilizadas
| Herramienta | Uso |
|---|---|
| Proxy > HTTP history | Detectar 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 |
| Repeater | Modificar la ruta y la cabecera Authorization de la petición forjada y reenviarla contra /api/admin/notes |
| crack_secret.py | Script propio (incluido en este paquete) para crackear offline un secreto HS256 corto probando una wordlist contra la firma real del token |
| Decodificador Base64url | Confirmar a mano que header y payload de un JWT son texto plano legible, no datos cifrados |