# REPRO-2026-00337: Keycloak reset-credentials flow: unauthenticated account takeover (CWE-640) ## Summary Status: published Severity: critical CVSS: Unknown CWE: CWE-640 Weak Password Recovery Mechanism for Forgotten Password Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00337 CVE: CVE-2026-18963 ## Package Name: Unknown Ecosystem: Unknown Affected: Unknown Fixed: Unknown ## Root Cause # 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=`). 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/-/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. ## Reproduction Details Reproduced: 2026-08-24T11:14:38.647Z Duration: 17 seconds Tool calls: 228 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00337 pruva-verify CVE-2026-18963 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00337&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00337/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-18963 - Source: https://nvd.nist.gov/vuln/detail/CVE-2026-18963 ## Artifacts - bundle/repro/rca_report.md (analysis, 10775 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 23448 bytes) - bundle/logs/fixed-attempt-1.log (log, 1488 bytes) - bundle/logs/fixed-attempt-2.log (log, 1489 bytes) - bundle/logs/http/fixed-1/step5-refresh.html (other, 8010 bytes) - bundle/logs/http/vuln-1/step5-refresh.html (other, 7729 bytes) - bundle/logs/http/vuln-1/step6.hdr (other, 599 bytes) - bundle/logs/http/vuln-1/step7.hdr (other, 1439 bytes) - bundle/logs/keycloak-fixed.log (log, 3612 bytes) - bundle/logs/keycloak-vuln.log (log, 3611 bytes) - bundle/logs/reproduction_steps.log (log, 21640 bytes) - bundle/logs/smtp-capture-final.log (log, 20819 bytes) - bundle/logs/vuln-attempt-1.log (log, 2878 bytes) - bundle/logs/vuln-attempt-2.log (log, 2878 bytes) - bundle/repro/runtime_manifest.json (other, 1669 bytes) - bundle/repro/smtp_sink.py (script, 3314 bytes) - bundle/repro/validation_verdict.json (other, 1350 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00337 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00337/artifacts/bundle/repro/reproduction_steps.sh - Web: https://www.pruva.dev/reproductions/REPRO-2026-00337 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://www.pruva.dev