# CVE-2026-18963 — Keycloak reset-credentials unauthenticated account takeover (CWE-640)

## Summary

Keycloak's "reset credentials" (forgot-password) flow can be pivoted by an unauthenticated
attacker onto any victim account. Two defects combine: (1) the authenticator-selection
("try another way") state is stored as a plain boolean auth note
(`AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED="true"`), so a simple GET refresh of the
reset-credentials execution URL re-renders the selector screen for the *current* flow
execution; and (2) `ResetCredentialEmail.action()` blindly calls `context.success()`
without verifying that the request actually arrived via the emailed action token
(`ACTION_TOKEN_USER_ID` auth note). An attacker who initiates a password reset for a
victim's username can therefore re-enter the selector screen after the reset email was
sent to the victim, POST to the now-reachable `reset-credential-email` execution without
any action token, force the flow forward to the `UPDATE_PASSWORD` required action, and
set an attacker-chosen password on the victim account — a full unauthenticated account
takeover. The attacker never sees the victim's reset email.

## Impact

- Package/component: `org.keycloak:keycloak-services` (`services` module:
  `org.keycloak.authentication.DefaultAuthenticationFlow`,
  `org.keycloak.authentication.authenticators.resetcred.ResetCredentialEmail`)
- Affected: Keycloak upstream < 26.4.15, 26.5.x, 26.6.0–26.6.5, 26.7.0–26.7.1
  (verified at source level); Red Hat build of Keycloak 26.4/26.6 before container
  26.6-12; RH-SSO 7; JBoss EAP Expansion Pack (per advisory RHSA-2026:56519/56523/56524)
- Verified affected: `quay.io/keycloak/keycloak:26.7.1` (source tag 26.7.1,
  commit 73f08b397f193712b26d317210dce99898129709)
- Verified fixed: `quay.io/keycloak/keycloak:26.7.2` (source tag 26.7.2,
  contains backport of fix commit cf6e4c8be318f1e38c4001730fe6db6930dad050)
- Risk: critical (CVSS 9.1). Any unauthenticated remote attacker who knows a victim's
  username can take over the account (new password + authenticated session), provided
  the realm has "forgot password" enabled — a default, common configuration.

## Impact Parity

- Disclosed/claimed maximum impact: unauthenticated account takeover (authz_bypass).
- Reproduced impact from this run: unauthenticated account takeover — the victim's
  password was replaced without possessing the reset email link, an authenticated
  session (authorization code) as the victim was issued, and the new password was
  verified against the token endpoint (HTTP 200) while the old password stopped
  working (HTTP 400).
- Parity: `full`.
- Not demonstrated: nothing material — the claimed impact was fully reproduced.

## Root Cause

In the vulnerable code (`services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java`,
tag 26.7.1):

1. When the user clicks "try another way", `processAction()` stores
   `AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED = "true"` (a boolean), not bound to any
   execution.
2. `processFlow()` checks only `Boolean.parseBoolean(note)` plus a non-null
   `CURRENT_AUTHENTICATION_EXECUTION` note. After the attacker posts the victim's
   username, `ResetCredentialEmail.authenticate()` sends the reset email and calls
   `context.forkWithSuccessMessage(EMAIL_SENT)`; the FORK handling sets
   `CURRENT_AUTHENTICATION_EXECUTION` to the *email execution id*. A subsequent GET
   refresh of the original reset-credentials URL therefore re-renders
   `createSelectAuthenticatorsScreen(emailExecution)`, whose HTML form action leaks the
   email execution UUID (`execution=<uuid>`).
3. `ResetCredentialEmail.action()` (vulnerable) is:
   ```java
   public void action(AuthenticationFlowContext context) {
       context.getUser().setEmailVerified(true);
       context.success();
   }
   ```
   Posting to the leaked email-execution URL (no action token required) invokes this
   blind success, the required-elements chain continues to `reset-password` /
   `UPDATE_PASSWORD`, and the attacker sets a new password on the victim identity
   attached to the authentication session by `ResetCredentialChooseUser`.

Fix (upstream commit `cf6e4c8be318f1e38c4001730fe6db6930dad050`, PR #51844, backported
to 26.4.15 / 26.6.6 / 26.7.2):
- The selector note now stores the execution model id, and `processFlow()` only
  re-renders the selector when the note matches `CURRENT_AUTHENTICATION_EXECUTION`
  (otherwise the note is removed) — the GET refresh after the email fork no longer
  re-renders the selector screen.
- `ResetCredentialEmail.action()` now requires
  `user.getId().equals(authSession.getAuthNote(DefaultActionTokenKey.ACTION_TOKEN_USER_ID))`,
  i.e. success is only possible through the emailed action token; otherwise the flow
  fails with `INVALID_USER`.

The upstream regression test `ResetPasswordTest.resetPasswordTryAnotherWay()` served as
the PoC blueprint for the flow-selector re-entry.

## Reproduction Steps

1. `bundle/repro/reproduction_steps.sh` (self-contained; helper
   `bundle/repro/smtp_sink.py` is a promoted required artifact).
2. The script:
   - Verifies at source level that tag 26.7.1 lacks and tag 26.7.2 contains the fix hunk.
   - Starts a dependency-free Python SMTP sink container (captures the victim's reset
     email, proving the action-token link is generated and sent only to the victim).
   - Starts Keycloak 26.7.1 (vulnerable), provisions realm `cvetest`
     (`resetPasswordAllowed=true`, SMTP → sink), user `victim` (email
     `victim@cvetest.local`, password `OldPass123!`), and a public direct-grant client
     `atk-cli` used only for verification.
   - Runs the full browser-equivalent attack with curl (cookie jar, per-page rotating
     `session_code` honored): login page → forgot-password → POST `tryAnotherWay=on` →
     POST `username=victim` → GET-refresh the original reset URL → POST the leaked email
     execution URL → POST the new password.
   - Asserts the pivot: step-5 refresh re-renders `kc-select-credential-form`, step-6
     returns `302 → login-actions/required-action?execution=UPDATE_PASSWORD`, step-7
     returns `302 → /account/?...&code=...` (attacker session as victim), and the token
     endpoint returns 200 for the new password / 400 for the old one.
   - Repeats the attack twice (password restored between attempts via admin API).
   - Repeats the same procedure twice against Keycloak 26.7.2 (fixed): the refresh must
     re-render the "You should receive an email shortly" page (no selector) and the
     victim password must remain unchanged.
   - Writes `bundle/repro/runtime_manifest.json` on every exit (trap) and streams all
     output to `bundle/logs/reproduction_steps.log`.
3. Expected evidence: `2/2` vulnerable attempts EXPLOITED, `2/2` fixed attempts BLOCKED,
   exit code 0.

## Evidence

- `bundle/logs/reproduction_steps.log` — full transcript of both phases.
- `bundle/logs/vuln-attempt-{1,2}.log` — per-attempt exploit transcripts. Key excerpts
  (attempt 1):
  - `step5 GET refresh -> selector markers=1 email-sent markers=0` and the re-rendered
    selector form action now contains `execution=0089b76d-...` (the email execution,
    different from the choose-user execution `141b34d5-...` used in steps 2–4).
  - `step6 POST email execution (no action token) -> HTTP/1.1 302 Found Location:
    .../login-actions/required-action?execution=UPDATE_PASSWORD...` — the blind
    `context.success()` fired without any action token.
  - `step7 new password -> HTTP/1.1 302 Found Location:
    http://localhost:8080/realms/cvetest/account/?session_state=...&code=...` — the
    attacker is issued an authorization code as `victim`.
  - `step8 takeover verify: NEW password token HTTP 200, OLD password token HTTP 400`.
- `bundle/logs/fixed-attempt-{1,2}.log` — `step5 GET refresh -> selector markers=0
  email-sent markers=1`; old password still 200, new password 400 → BLOCKED.
- `bundle/logs/smtp-capture-final.log` — the captured "Reset password" email addressed
  `To: victim@cvetest.local` containing the real
  `/realms/cvetest/login-actions/action-token?key=eyJ...` link (which the attacker never
  receives).
- `bundle/logs/http/<role>-<n>/step*.html|*.hdr` — raw HTTP responses/headers for every
  step of every attempt.
- `bundle/logs/keycloak-vuln.log`, `bundle/logs/keycloak-fixed.log` — server logs;
  `server-version` reported by `/admin/serverinfo` was 26.7.1 / 26.7.2 respectively.
- `bundle/repro/runtime_manifest.json` — runtime identity (image digests
  `quay.io/keycloak/keycloak@sha256:f1f1f01e…` vulnerable,
  `…@sha256:83133051…` fixed), attempt results, proof artifact list.
- Environment: rootless Docker 27.5.1 on Linux x86_64; Keycloak `start-dev` (H2),
  hostname `http://localhost:8080`; no sanitizers involved (product runtime proof).

## Recommendations / Next Steps

- Upgrade to a fixed build: upstream Keycloak ≥ 26.4.15 / ≥ 26.6.6 / ≥ 26.7.2; Red Hat
  build of Keycloak 26.6-12 container or later (RHSA-2026:56519, 56523, 56524).
- The upstream fix is the correct approach: bind the selector-screen auth note to the
  execution model id and require the `ACTION_TOKEN_USER_ID` note in
  `ResetCredentialEmail.action()`.
- Regression testing: keep `ResetPasswordTest.resetPasswordTryAnotherWay()` and add an
  end-to-end test asserting that a GET refresh after the email fork cannot surface the
  email execution's action URL.
- Defense-in-depth: consider invalidating or single-using the email execution state once
  the action token is issued, and rate-limiting reset-credential initiations per
  username/IP.

## Additional Notes

- Idempotency: the script is fully idempotent — it re-creates the docker network and
  containers, truncates the SMTP capture, and restores the victim's password between
  attempts. Verified by two consecutive successful runs (exit 0 both times).
- The exploit uses only unauthenticated endpoints; no SMTP access, no victim
  interaction, and no knowledge beyond the victim's username are required. The victim's
  email delivery is not even required to succeed for the pivot (Keycloak forks the flow
  with a success message even when sending fails), but this reproduction proves real
  email generation via the SMTP capture.
- The fork behavior (`forkWithSuccessMessage` → cloned tab on the browser flow) is why
  the legitimate "email sent" page is displayed on a cloned authentication-session tab
  while the original tab retains the selector note and the email execution as current —
  the exact state the GET refresh abuses.
- The verification client `atk-cli` (public, direct grants) exists only to prove the
  password change; it plays no role in the exploit path itself.
