
Exploit para Keycloak CVE-2026-18963 que permite la toma de control de cuentas sin autenticación mediante la evasión de reset-credentials. Incluye detección segura, prueba no destructiva, toma de control completa, enumeración de nombres de usuario y un laboratorio con versiones vulnerables y parcheadas.
Conociendo solo un nombre de usuario o dirección de correo electrónico, un atacante no autenticado establece una contraseña arbitraria en cualquier cuenta de Keycloak. El correo de restablecimiento de contraseña se envía a la víctima real y nunca es necesario — el atacante nunca lee una bandeja de entrada, nunca hace clic en un enlace, y no posee ninguna credencial o sesión previa.
Afectado: Keycloak 26.0.0 – 26.7.1. Corregido en 26.7.2.
Un solo comando. No se requiere un nombre de usuario válido, y sin efectos secundarios — no envía correo, no escribe en ninguna cuenta, y se detiene antes del paso explotable.```bash git clone https://github.com/Snizi/CVE-2026-18963-Exploit cd CVE-2026-18963-Exploit
python3 cve_2026_18963_poc.py
--base https://sso.example.com --realm YOUR_REALM
--client-id account
--redirect-uri https://sso.example.com/realms/YOUR_REALM/account/
--safe-check
Python 3.9+, solo biblioteca estándar. Nada que instalar.
| Salida | Veredicto | Significado |
|:---:|---|---|
| `0` | 🔴 **VULNERABLE** | se sirvió la puerta de correo estacionada — el bug en sí |
| `2` | 🟢 **PARCHEADO** | el flujo se bifurcó al inicio de sesión y se quedó allí (fix #51844 presente) |
| `2` | 🟡 **MITIGADO** | restablecimiento de credenciales inalcanzable — *Forgot password* está desactivado. **No es un parche.** |
| `3` | ⚪ **NO CONCLUYENTE** | respuesta no reconocida — **no lo interpretes como una aprobación** |
Ejecútalo por realm — *Forgot password* es una configuración por realm, y `master` cuenta.
Detalles, y por qué la comprobación no necesita usuario y no toca nada, en
[§4a](#4a-safe-detection---safe-check--start-here).
**¿Ya sabes que estás expuesto?** Salta a [remediación](#8-remediation) y
[detección / threat hunting](#9-detection).
### Pruébalo sin un objetivo
El repositorio incluye un laboratorio que arranca una versión vulnerable **26.7.1** y una parcheada **26.7.2**
lado a lado contra un realm idéntico, además de un buzón para observar cómo llega el correo de restablecimiento
y permanece sin leer mientras la cuenta es tomada:```bash
cd lab && docker compose up -d
python3 ../cve_2026_18963_poc.py --base http://localhost:8080 \
--realm poc --client-id poc-app --safe-check # VULNERABLE
python3 ../cve_2026_18963_poc.py --base http://localhost:8100 \
--realm poc --client-id poc-app --safe-check # PATCHED
⚠️ Solo pruebas autorizadas
Este repositorio existe para defensores, respondedores de incidentes y probadores de penetración autorizados. Ejecútalo contra sistemas que poseas o para los que tengas permiso por escrito para probar. Todo lo que hay aquí incluye un laboratorio vulnerable autocontenido (
lab/), por lo que no es necesario tocar nada externo para aprender cómo funciona el fallo. Apuntarlo a infraestructura de terceros sin autorización es ilegal en la mayoría de las jurisdicciones y no es algo que este proyecto respalde.
Referencias: keycloak#51833 · GHSA-4gv3-mc9p-5wqc · fix keycloak#51844
Dos defectos encadenados. Ninguno es explotable por sí solo.
services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java
processAction() — cualquier POST que lleve la clave de formulario tryAnotherWay:```java
processor.getAuthenticationSession().setAuthNote(
AuthenticationProcessor.AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED, "true");
return createSelectAuthenticatorsScreen(model);
La nota es un **booleano simple sin registro de a qué conjunto de ejecución pertenece**. Solo se limpia en la rama que maneja un parámetro `authenticationExecution` enviado. Si se omite ese parámetro — como hace este PoC en todo momento —, la marca permanece activa durante toda la vida de la sesión de autenticación.
`processFlow()` — mientras la marca sea verdadera, se omite la evaluación normal del flujo:```java
if (Boolean.parseBoolean(authSession.getAuthNote(AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED))) {
String lastExecutionId = authSession.getAuthNote(CURRENT_AUTHENTICATION_EXECUTION);
if (lastExecutionId != null) {
AuthenticationExecutionModel executionModel =
realm.getAuthenticationExecutionById(lastExecutionId);
if (executionModel != null)
return createSelectAuthenticatorsScreen(executionModel); // <-- attacker-usable form
}
}
It renders a submittable form aimed at whatever execution is currently parked, instead of keeping the session pinned on "waiting for the e-mail".
The glue is processResult() case FORK: — when Send Reset Email fires it stamps
CURRENT_AUTHENTICATION_EXECUTION = <reset-credential-email execution id> and forks
the browser to the login page. The parked execution is precisely the e-mail gate.
`services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java````java @Override public void action(AuthenticationFlowContext context) { context.getUser().setEmailVerified(true); context.success(); }
Unconditional. Nothing verifies that the flow was resumed by a valid action token,
so *reaching* `action()` is treated as equivalent to proving mailbox control.
### The chain```
tryAnotherWay POST → sticky AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED="true"
submit victim identifier → mail sent to victim, e-mail execution parked (FORK)
re-enter reset-credentials → sticky flag serves a form targeting the parked e-mail execution
POST that form → ResetCredentialEmail.action() → success() → gate bypassed
→ flow advances to UPDATE_PASSWORD → attacker sets the password
Seis solicitudes HTTP, sin autenticación, sin parámetro authenticationExecution en ningún
punto.
model.getId(), y processFlow() lo respeta solo cuando
es igual a CURRENT_AUTHENTICATION_EXECUTION; de lo contrario, lo elimina. En el ataque,
los dos difieren (id de choose-user vs. id de e-mail-gate), exactamente lo que detecta el
parche, y exactamente la señal en la que se basa --safe-check.ResetCredentialEmail.action() ahora requiere
context.getUser().getId().equals(authNote(ACTION_TOKEN_USER_ID))
y, de lo contrario, falla con INVALID_USER.