Sploitus

Exploit for CVE-2026-18963

githubexploit Β· 2026-08-20

Exploit Code

README465 lines
## https://sploitus.com/exploit?id=A76D2FC5-1440-568B-81C2-B0213465485E
# 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)
[![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 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.