# CVE-2026-18963 — Keycloak reset-credentials bypass → unauthenticated account takeover [![CVE](https://img.shields.io/badge/CVE-2026--18963-critical)](https://github.com/advisories/GHSA-4gv3-mc9p-5wqc) [![Affected](https://img.shields.io/badge/affected-Keycloak%2026.0.0--26.7.1-orange)](#2-affected-versions-including-legacy-lines) [![Python](https://img.shields.io/badge/python-3.9%2B-blue)](#4-usage) [![Dependencies](https://img.shields.io/badge/dependencies-none-brightgreen)](#4-usage) 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. ```bash 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](#4a-safe-detection---safe-check--start-here). **Already know you are exposed?** Jump to [remediation](#8-remediation) and [detection / threat hunting](#9-detection). ### 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: ```bash 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](https://github.com/keycloak/keycloak/issues/51833) · [GHSA-4gv3-mc9p-5wqc](https://github.com/advisories/GHSA-4gv3-mc9p-5wqc) · fix [keycloak#51844](https://github.com/keycloak/keycloak/pull/51844) --- ## Contents - [1. Root cause](#1-root-cause) - [2. Affected versions (including legacy lines)](#2-affected-versions-including-legacy-lines) - [3. The lab](#3-the-lab) - [4. Usage](#4-usage) - [4a. Safe detection (`--safe-check`) — start here](#4a-safe-detection---safe-check--start-here) - [4b. Non-destructive proof (`--check`)](#4b-non-destructive-proof---check) - [4c. Full takeover](#4c-full-takeover) - [4d. Username enumeration (`--enum`)](#4d-username-enumeration---enum) - [5. Custom login themes](#5-custom-login-themes) - [6. Known gap — PKCE](#6-known-gap--pkce) - [7. Validation performed](#7-validation-performed) - [8. Remediation](#8-remediation) - [9. Detection](#9-detection) - [Author](#author) --- ## 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`: ```java 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: ```java 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 = ` 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` ```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](https://github.com/axllent/mailpit) to capture the reset mail — so you can watch it arrive and stay unread while the account is taken over. ```bash 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`: ```bash 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 `/realms//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. ```bash 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: ```java 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. ```bash 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. ```bash 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 | ```bash 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//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 **Snizi** — [github.com/Snizi](https://github.com/Snizi) — luandmsimoes@gmail.com Released under the [MIT License](LICENSE). Issues and PRs welcome — particularly PKCE support and additional real-world theme quirks.