## https://sploitus.com/exploit?id=A76D2FC5-1440-568B-81C2-B0213465485E
# CVE-2026-18963 β Keycloak reset-credentials bypass β unauthenticated account takeover
[](https://github.com/advisories/GHSA-4gv3-mc9p-5wqc)
[](#4-usage)
[](#4-usage)
Knowing only a **username or e-mail address**, an unauthenticated attacker sets an
arbitrary password on that 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.
**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)
> ### β οΈ 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.
---
## 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); // ` 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.