Laboratorio cerrado · nada de esto sale de tu navegador

Escape room: cinco fallos de autenticación

Un edificio digital abandonado con cinco salas, y en cada una un mecanismo de autenticación que sigue funcionando mal desde hace años. Entras como Modo Dios: puedes ver el interior de los sistemas, y eso incluye el interior de los tokens.

Los JWT se firman y se verifican de verdad aquí dentro —hay una implementación de SHA-256 y HMAC-SHA256 en la propia página—, y los «servidores» son funciones JavaScript. No hay red, ni sistemas reales, ni nada que salga de la pestaña. Las ayudas y el tiempo restan puntos: lo que deduzcas tú, no.

Continúa las herramientas de Recursos: allí están la calculadora de hash, el HMAC y el inspector de JWT con los que jugar por tu cuenta.

MODO DIOSauditoría fantasma · v1.0
SIN INICIAR 0%
◈ 0 ⏱️ 00:00

EL EDIFICIO SIN PORTEROS

Informe de entrada

Qué vas a aprender

  • Sala 1 · Un JWT va firmado, no cifrado.
  • Sala 2 · Por qué alg: none es una puerta abierta.
  • Sala 3 · Entropía: qué separa un token adivinable de uno imposible.
  • Sala 4 · La ruta que se olvidó de pasar por el guardián.
  • Sala 5 · decode() lee. verify() protege.

Reglas del laboratorio

Todo lo que ves aquí está simulado dentro de esta página. Los "servidores" son funciones JavaScript. Los tokens se firman en tu navegador con una implementación real de HMAC-SHA256 incluida en el archivo. No hay red, no hay nada externo, no hay ningún sistema real implicado.

Pulsa 👁 RAYOS X en cualquier momento: es tu poder de Modo Dios. Revela el código y las tripas de cada sala. Pero todo poder se paga.

Cómo se puntúa

Cada sala vale 200 puntos de base. El Modo Dios lo ve todo, pero mirar cansa: cada ayuda que pidas y cada minuto que tardes te restan. Nadie te va a dar masticado lo que puedes averiguar tú.

CONCEPTOPUNTOSDETALLE
Sala resuelta+200por cada una de las cinco
Bonus de rapidez+60 / +30menos de 1 min / menos de 2 min 30 s en la sala
Intento fallido−15cada vez que pruebas algo que no es
Pista escrita−50una por sala, se paga una sola vez
👁 Rayos X−25la primera vez que los usas en cada sala
Decodificador del sótano−75herramienta de emergencia de la Sala 01
MÁXIMO POSIBLE1300cinco salas, sin ayudas, sin fallos, sin pausas

Ninguna sala baja de 0: lo peor que puede pasarte es sacar cero en ella. Y decodificar, deducir o calcular por tus propios medios no cuesta nada: de eso va el juego.

SALA 01

El Cofre Transparente

Token hallado en el cofre

CABECERA (header) CARGA (payload) FIRMA (signature)

⚠ Sótano · decodificador de emergencia −75 PUNTOS

El edificio conserva un decodificador Base64url automático. Funciona, y te dará la respuesta en un clic. Pero un Modo Dios que necesita herramientas no es gran cosa: deshacer Base64 por tus propios medios no cuesta ni un punto y es exactamente la lección de esta sala.

👁 Activa RAYOS X para ver la anatomía interna del token. −25 la primera vez en cada sala.

Visión de rayos X

// Anatomía de un JWT
  base64url(cabecera) . base64url(carga) . base64url(HMAC-SHA256(cabecera.carga, clave))
  └──── público ────┘   └──── público ───┘   └──────── prueba de integridad ────────┘

// Lo que la firma SÍ garantiza:
  ✔ nadie ha alterado la carga sin conocer la clave
// Lo que la firma NO hace:
  ✘ NO oculta la carga — cualquiera la lee con un atob()
  ✘ NO impide copiar el token entero y reutilizarlo

// Clave del laboratorio (sólo existe en esta página):
  LAB_SECRET = 

Reto · abrir la puerta

Dentro de la carga hay un campo llamado clave_boveda. Escribe su valor exacto.

AYUDAS
REVELACIÓN · SALA 01

Firmado ≠ cifrado. Un JWT es un sobre transparente con un sello de lacre. El sello impide falsificarlo; el sobre no impide leerlo. Cualquiera que intercepte, copie o simplemente reciba el token ve el contenido completo: identificadores, roles, correos, permisos... y cualquier "secreto" que alguien haya metido ahí pensando que Base64 lo protegía.

Base64url es una codificación, no un cifrado: convierte bytes en texto imprimible. Es reversible por definición y sin ninguna clave.

Ficha de defensa · qué hacer en tu código

  • Nunca metas datos sensibles en el payload: contraseñas, claves de API, números de tarjeta, datos personales innecesarios.
  • Guarda en el token lo mínimo para identificar: sub, role, exp. El resto se consulta en la base de datos.
  • Si de verdad necesitas confidencialidad en el token, no es un JWS: necesitas JWE (JSON Web Encryption) o un token opaco por referencia.
  • Da por hecho que el usuario leerá todo lo que pongas dentro. Porque lo hará.
// ✘ mal: el usuario puede leerlo todo
sign({ sub: id, plan_interno: "trial_hack", email: user.email }, key)

// ✔ bien: mínimo imprescindible + caducidad corta
sign({ sub: id, role: user.role }, key, { expiresIn: '15m' })
SALA 02

La Firma Rota

Tu pase actual (legítimo, firmado)

👁 Activa RAYOS X para leer el código del lector de sellos. −25 la primera vez en cada sala.

Visión de rayos X

// SERVIDOR LEGADO — implementado de verdad más abajo en este archivo
function servidorLegado(token) {
  const [h, p, s] = token.split('.');
  const cabecera   = JSON.parse(b64urlDecode(h));

  if (cabecera.alg === 'none') {            // ⚠ LA PUERTA ABIERTA
    return JSON.parse(b64urlDecode(p));     //   acepta sin comprobar NADA
  }

  if (!firmaValida(h, p, s, CLAVE)) throw new Error('firma inválida');
  return JSON.parse(b64urlDecode(p));
}

// El atacante no rompe la criptografía.
// Le dice al servidor: "hoy no usamos criptografía".
// Y el servidor le cree, porque el ATACANTE elige el algoritmo.

Forja de tokens

Edita las piezas y ensambla. Vaciar la firma deja el token terminado en un punto: cabecera.carga.

—
AYUDAS
REVELACIÓN · SALA 02

El atacante no debe poder elegir el algoritmo. El fallo de alg: none no está en la criptografía, está en la confianza: el servidor lee la cabecera —que viene del cliente— y obedece lo que ésta le dice sobre cómo validarse a sí misma. Es preguntarle al sospechoso si hay que registrarlo.

La misma familia de fallos incluye la confusión de algoritmos: un servidor que acepta HS256 cuando esperaba RS256 puede ser engañado usando la clave pública (que es pública) como si fuera el secreto HMAC.

Ficha de defensa · qué hacer en tu código

  • Fija el algoritmo en el servidor y rechaza cualquier otro, siempre con lista blanca.
  • Rechaza explícitamente none y cualquier token sin firma, aunque la librería diga que lo soporta.
  • No deduzcas la clave ni el algoritmo de la cabecera: decídelos por configuración.
  • Si usas kid para elegir clave, valídalo contra un conjunto cerrado (nunca como ruta de fichero ni consulta SQL).
// ✘ mal: la librería usa lo que diga la cabecera
jwt.verify(token, clave)

// ✔ bien: lista blanca explícita de algoritmos
jwt.verify(token, clave, { algorithms: ['HS256'], issuer: 'mi-api', audience: 'mi-app' })
SALA 03

La Llave de Guardarropa

⚠ Armario A · contador

Últimas llaves entregadas (en orden de emisión):

✔ Armario B · 256 bits aleatorios

Últimas llaves entregadas (generadas con el CSPRNG del navegador):

👁 Activa RAYOS X para ver cómo se generan ambas llaves. −25 la primera vez en cada sala.

Visión de rayos X

// ARMARIO A — predecible
let contador = 4271;
function nuevaSesionDebil() {
  contador += 3;                                  // paso fijo
  return 'TKT-' + String(contador).padStart(7, '0');
}
// Espacio de búsqueda real: ~1 intento. Y con Date.now() o Math.random(), igual de malo.

// ARMARIO B — impredecible
function nuevaSesionFuerte() {
  const bytes = crypto.getRandomValues(new Uint8Array(32));   // 256 bits
  return [...bytes].map(b => b.toString(16).padStart(2,'0')).join('');
}
// Espacio de búsqueda: 2^256 ≈ 1,16 × 10^77 valores.

Reto · dos condiciones

◻ Predecir la siguiente llave del armario A  ·  ◻ Ejecutar el ataque contra el armario B y ver el resultado

AYUDAS
REVELACIÓN · SALA 03

Un token opaco sólo vale lo que vale su entropía. Si el identificador de sesión se puede predecir, no hace falta robarlo: se calcula. Contadores, marcas de tiempo, hashes de correo, UUID v1 o Math.random() producen valores que parecen aleatorios pero cuyo espacio de búsqueda real es diminuto.

La diferencia no es de grado, es de categoría: adivinar la llave del armario A cuesta un intento; la del B, más intentos que átomos hay en la parte visible del universo.

Ficha de defensa · qué hacer en tu código

  • Genera identificadores de sesión y tokens con un CSPRNG, nunca con Math.random().
  • Mínimo 128 bits de entropía; 256 bits es el estándar cómodo hoy.
  • No derives el token de datos del usuario (id, email, hora de login): eso no es entropía, es disfraz.
  • Añade caducidad, rotación al iniciar sesión y revocación real en el servidor.
  • Compara tokens con una función de tiempo constante para no filtrar información por el tiempo de respuesta.
// ✘ mal
const sid = Date.now() + '-' + Math.random().toString(36).slice(2)

// ✔ bien (Node.js)
import { randomBytes, timingSafeEqual } from 'node:crypto'
const sid = randomBytes(32).toString('base64url')   // 256 bits

// ✔ bien (navegador / Web Crypto)
crypto.getRandomValues(new Uint8Array(32))
SALA 04

La Puerta sin Guardián

Mapa de rutas del edificio

Pulsa una ruta para llamar a esa puerta. (Con RAYOS X verás la cadena de middlewares de cada una.)

👁 Activa RAYOS X para ver qué middlewares protegen cada ruta. −25 la primera vez en cada sala.

Visión de rayos X

// Así se escribió el router. Busca la línea que falta.
router.get ('/api/perfil',    requireAuth, perfilHandler);
router.post('/api/pagos',     requireAuth, requireRole('user'), pagosHandler);
router.get ('/api/informes',  requireAuth, informesHandler);
router.post('/api/exportar',  requireAuth, requireRole('admin'), exportarHandler);
router.get ('/healthz',       healthHandler);        // público a propósito, no expone datos
router.get ('/debug/config',  configHandler);        // ⚠ ¿y el requireAuth?

// El problema no es que falte una comprobación:
// es que el modelo por defecto sea PERMITIR y haya que acordarse de prohibir.

Reto · la frase de servicio

La puerta olvidada filtra la configuración interna. Dentro hay un campo FRASE_DE_SERVICIO. Escríbelo aquí.

AYUDAS
REVELACIÓN · SALA 04

La autenticación perfecta en 19 rutas no sirve de nada si la número 20 no la tiene. Este es el fallo más común y más aburrido de todos: nadie rompió nada, simplemente alguien olvidó una línea. Rutas de depuración, endpoints internos, versiones antiguas de la API (/v1/ olvidada al publicar /v2/), webhooks, métricas, paneles de administración "que nadie conoce".

La causa raíz es el modelo por defecto: si tu router permite por defecto y la protección es opt-in, cada ruta nueva es una oportunidad de olvido. Invierte la carga.

Ficha de defensa · qué hacer en tu código

  • Denegar por defecto. Aplica el middleware de autenticación globalmente y declara una lista blanca explícita de rutas públicas.
  • Autoriza en la capa que no se puede saltar (servicio o acceso a datos), no sólo en el router.
  • Prohíbe en producción los endpoints de depuración: que ni siquiera se registren si NODE_ENV === 'production'.
  • Añade un test que recorra el árbol de rutas y falle si alguna no está ni protegida ni en la lista blanca.
  • Recuerda: una ruta no deja de existir por no estar documentada. La oscuridad no es control de acceso.
// ✔ denegar por defecto
const PUBLICAS = new Set(['/healthz', '/login'])
app.use((req, res, next) =>
  PUBLICAS.has(req.path) ? next() : requireAuth(req, res, next))

// ✔ que la ruta de depuración ni exista fuera de desarrollo
if (process.env.NODE_ENV !== 'production') app.get('/debug/config', requireAuth, configHandler)
SALA 05

La Sala del Espejo

Token manipulado

Alguien tomó un token legítimo de auditor, cambió la carga a root y dejó la firma original intacta (no tenía la clave para recalcularla).

✘ SERVIDOR A · usa decode()

✔ SERVIDOR B · usa verify()

👁 Activa RAYOS X para ver el código de ambos servidores. −25 la primera vez en cada sala.

Visión de rayos X

// SERVIDOR A
app.use((req, res, next) => {
  const carga = jwt.decode(req.headers.authorization);   // sólo LEE. No comprueba nada.
  req.usuario = carga;                                 // confía en texto del cliente
  next();
});

// SERVIDOR B
app.use((req, res, next) => {
  try {
    const carga = jwt.verify(req.headers.authorization, CLAVE, { algorithms: ['HS256'] });
    req.usuario = carga;                               // sólo si el MAC cuadra
    next();
  } catch { res.status(401).json({ error: 'token inválido' }); }
});

// decode() existe para depurar y para leer un token del que YA eres emisor.
// Usarlo para decidir permisos equivale a no tener autenticación.

Reto · el informe final

Lanza el token contra los dos servidores y después responde: ¿qué conclusión es la correcta?

AYUDAS
REVELACIÓN · SALA 05

La seguridad vive en la verificación del servidor. Un token es sólo texto que llega desde fuera. Lo que lo convierte en una credencial no es su formato ni su aspecto: es que un servidor con la clave haya recalculado el MAC —o comprobado la firma pública— y haya obtenido el mismo resultado. Sin ese paso, un JWT es un formulario que el usuario rellena a su gusto.

Por eso no importa cuántas comprobaciones haga el cliente: el cliente está en manos del atacante. El único punto de la cadena que decide es el servidor.

Ficha de defensa · qué hacer en tu código

  • decode() jamás para tomar decisiones de acceso. Sólo verify(), y sólo en el servidor.
  • Verifica también los claims: exp, nbf, iss, aud. Una firma válida de otro emisor sigue siendo un token ajeno.
  • Ninguna validación en el cliente cuenta como control de seguridad: es sólo experiencia de usuario.
  • Compara firmas en tiempo constante y guarda la clave fuera del código (variable de entorno o gestor de secretos).
// ✘ mal: el payload es texto del cliente
const u = jwt.decode(token); if (u.role === 'admin') /* ... */

// ✔ bien: verificación completa
const u = jwt.verify(token, CLAVE, {
  algorithms: ['HS256'], issuer: 'mi-api', audience: 'mi-app', clockTolerance: 5
});

SISTEMA AUDITADO

Informe de auditoría

Lo aprendido

SALA 01

Firmado ≠ cifrado

Un JWT es un sobre transparente con sello. Todo el mundo lee su contenido. Nunca metas secretos dentro; si necesitas confidencialidad, usa JWE o tokens por referencia.

SALA 02

El algoritmo lo decides tú, no el token

alg: none y la confusión de algoritmos existen porque el servidor obedece a la cabecera. Fija una lista blanca de algoritmos en la verificación.

SALA 03

Entropía criptográfica

Un token opaco vale lo que vale su aleatoriedad. CSPRNG, 128 bits como mínimo, 256 recomendado. Nada de contadores, timestamps ni Math.random().

SALA 04

Todas las rutas, sin excepción

Denegar por defecto y lista blanca de rutas públicas. Autoriza en la capa que no se puede saltar. Los endpoints de depuración no deberían existir en producción.

SALA 05

verify(), nunca decode()

La firma es la única prueba de integridad. Verifica también exp, iss y aud. Lo que valide el cliente no cuenta como seguridad.

REGLA GENERAL

Todo dato que llega del cliente es una afirmación, no un hecho

Un token, una cookie, una cabecera o un campo oculto sólo se convierten en verdad cuando el servidor los verifica con algo que el cliente no controla.

Checklist para llevarte

  • ☐ verify() con algorithms explícito en cada petición autenticada.
  • ☐ Payload mínimo y caducidad corta; refresco rotatorio y revocable.
  • ☐ Secretos en variables de entorno o gestor de secretos, nunca en el repositorio.
  • ☐ Identificadores de sesión de ≥128 bits desde un CSPRNG.
  • ☐ Middleware de auth global + lista blanca de rutas públicas, con test automático.
  • ☐ Autorización por rol comprobada en el servidor, además de la autenticación.
  • ☐ Endpoints de depuración deshabilitados en producción.
  • ☐ Comparaciones de tokens y firmas en tiempo constante.

Hoja de puntuación