{"repro_id":"REPRO-2026-00337","version":6,"title":"Keycloak reset-credentials flow: unauthenticated account takeover (CWE-640)","repro_type":"security","status":"published","severity":"critical","description":"CVE-2026-18963 is an unauthenticated account-takeover vulnerability (CVSS 9.1) in Keycloak's reset-credentials flow (keycloak-services, Maven org.keycloak:keycloak-services). ROOT CAUSE (confirmed via deep research): 1) org.keycloak.authentication.requiredactions.ResetCredentialEmail.action() blindly calls context.success() without binding the reset action-token to the authenticated session identity; 2) the boolean AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED auth note allows the authenticator-selection (try-another-way) screen to be re-entered via a simple GET refresh of the execution URL, letting an attacker pivot an unauthenticated reset flow onto a victim account after the victim's own reset email link was generated. FIXED in upstream commit cf6e4c8 (PR #51844) via action-token identity binding and execution-ID-bound selector state. Regression test ResetPasswordTest.resetPasswordTryAnotherWay() serves as the PoC blueprint. REPRODUCTION STRATEGY: stand up a vulnerable Keycloak (pre-26.6-12 / pre-fix tag), enable 'forgot password' on the realm, trigger a reset flow for a victim, and drive the flow-selector re-entry to complete the reset without the email link. Affected: Red Hat build of Keycloak 26.4/26.6, RH-SSO 7, JBoss EAP Expansion Pack. Fixed in keycloak 26.6-12 container / RHSA-2026:56519, 56523, 56524.","root_cause":"# CVE-2026-18963 — Keycloak reset-credentials unauthenticated account takeover (CWE-640)\n\n## Summary\n\nKeycloak's \"reset credentials\" (forgot-password) flow can be pivoted by an unauthenticated\nattacker onto any victim account. Two defects combine: (1) the authenticator-selection\n(\"try another way\") state is stored as a plain boolean auth note\n(`AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED=\"true\"`), so a simple GET refresh of the\nreset-credentials execution URL re-renders the selector screen for the *current* flow\nexecution; and (2) `ResetCredentialEmail.action()` blindly calls `context.success()`\nwithout verifying that the request actually arrived via the emailed action token\n(`ACTION_TOKEN_USER_ID` auth note). An attacker who initiates a password reset for a\nvictim's username can therefore re-enter the selector screen after the reset email was\nsent to the victim, POST to the now-reachable `reset-credential-email` execution without\nany action token, force the flow forward to the `UPDATE_PASSWORD` required action, and\nset an attacker-chosen password on the victim account — a full unauthenticated account\ntakeover. The attacker never sees the victim's reset email.\n\n## Impact\n\n- Package/component: `org.keycloak:keycloak-services` (`services` module:\n  `org.keycloak.authentication.DefaultAuthenticationFlow`,\n  `org.keycloak.authentication.authenticators.resetcred.ResetCredentialEmail`)\n- Affected: Keycloak upstream < 26.4.15, 26.5.x, 26.6.0–26.6.5, 26.7.0–26.7.1\n  (verified at source level); Red Hat build of Keycloak 26.4/26.6 before container\n  26.6-12; RH-SSO 7; JBoss EAP Expansion Pack (per advisory RHSA-2026:56519/56523/56524)\n- Verified affected: `quay.io/keycloak/keycloak:26.7.1` (source tag 26.7.1,\n  commit 73f08b397f193712b26d317210dce99898129709)\n- Verified fixed: `quay.io/keycloak/keycloak:26.7.2` (source tag 26.7.2,\n  contains backport of fix commit cf6e4c8be318f1e38c4001730fe6db6930dad050)\n- Risk: critical (CVSS 9.1). Any unauthenticated remote attacker who knows a victim's\n  username can take over the account (new password + authenticated session), provided\n  the realm has \"forgot password\" enabled — a default, common configuration.\n\n## Impact Parity\n\n- Disclosed/claimed maximum impact: unauthenticated account takeover (authz_bypass).\n- Reproduced impact from this run: unauthenticated account takeover — the victim's\n  password was replaced without possessing the reset email link, an authenticated\n  session (authorization code) as the victim was issued, and the new password was\n  verified against the token endpoint (HTTP 200) while the old password stopped\n  working (HTTP 400).\n- Parity: `full`.\n- Not demonstrated: nothing material — the claimed impact was fully reproduced.\n\n## Root Cause\n\nIn the vulnerable code (`services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java`,\ntag 26.7.1):\n\n1. When the user clicks \"try another way\", `processAction()` stores\n   `AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED = \"true\"` (a boolean), not bound to any\n   execution.\n2. `processFlow()` checks only `Boolean.parseBoolean(note)` plus a non-null\n   `CURRENT_AUTHENTICATION_EXECUTION` note. After the attacker posts the victim's\n   username, `ResetCredentialEmail.authenticate()` sends the reset email and calls\n   `context.forkWithSuccessMessage(EMAIL_SENT)`; the FORK handling sets\n   `CURRENT_AUTHENTICATION_EXECUTION` to the *email execution id*. A subsequent GET\n   refresh of the original reset-credentials URL therefore re-renders\n   `createSelectAuthenticatorsScreen(emailExecution)`, whose HTML form action leaks the\n   email execution UUID (`execution=<uuid>`).\n3. `ResetCredentialEmail.action()` (vulnerable) is:\n   ```java\n   public void action(AuthenticationFlowContext context) {\n       context.getUser().setEmailVerified(true);\n       context.success();\n   }\n   ```\n   Posting to the leaked email-execution URL (no action token required) invokes this\n   blind success, the required-elements chain continues to `reset-password` /\n   `UPDATE_PASSWORD`, and the attacker sets a new password on the victim identity\n   attached to the authentication session by `ResetCredentialChooseUser`.\n\nFix (upstream commit `cf6e4c8be318f1e38c4001730fe6db6930dad050`, PR #51844, backported\nto 26.4.15 / 26.6.6 / 26.7.2):\n- The selector note now stores the execution model id, and `processFlow()` only\n  re-renders the selector when the note matches `CURRENT_AUTHENTICATION_EXECUTION`\n  (otherwise the note is removed) — the GET refresh after the email fork no longer\n  re-renders the selector screen.\n- `ResetCredentialEmail.action()` now requires\n  `user.getId().equals(authSession.getAuthNote(DefaultActionTokenKey.ACTION_TOKEN_USER_ID))`,\n  i.e. success is only possible through the emailed action token; otherwise the flow\n  fails with `INVALID_USER`.\n\nThe upstream regression test `ResetPasswordTest.resetPasswordTryAnotherWay()` served as\nthe PoC blueprint for the flow-selector re-entry.\n\n## Reproduction Steps\n\n1. `bundle/repro/reproduction_steps.sh` (self-contained; helper\n   `bundle/repro/smtp_sink.py` is a promoted required artifact).\n2. The script:\n   - Verifies at source level that tag 26.7.1 lacks and tag 26.7.2 contains the fix hunk.\n   - Starts a dependency-free Python SMTP sink container (captures the victim's reset\n     email, proving the action-token link is generated and sent only to the victim).\n   - Starts Keycloak 26.7.1 (vulnerable), provisions realm `cvetest`\n     (`resetPasswordAllowed=true`, SMTP → sink), user `victim` (email\n     `victim@cvetest.local`, password `OldPass123!`), and a public direct-grant client\n     `atk-cli` used only for verification.\n   - Runs the full browser-equivalent attack with curl (cookie jar, per-page rotating\n     `session_code` honored): login page → forgot-password → POST `tryAnotherWay=on` →\n     POST `username=victim` → GET-refresh the original reset URL → POST the leaked email\n     execution URL → POST the new password.\n   - Asserts the pivot: step-5 refresh re-renders `kc-select-credential-form`, step-6\n     returns `302 → login-actions/required-action?execution=UPDATE_PASSWORD`, step-7\n     returns `302 → /account/?...&code=...` (attacker session as victim), and the token\n     endpoint returns 200 for the new password / 400 for the old one.\n   - Repeats the attack twice (password restored between attempts via admin API).\n   - Repeats the same procedure twice against Keycloak 26.7.2 (fixed): the refresh must\n     re-render the \"You should receive an email shortly\" page (no selector) and the\n     victim password must remain unchanged.\n   - Writes `bundle/repro/runtime_manifest.json` on every exit (trap) and streams all\n     output to `bundle/logs/reproduction_steps.log`.\n3. Expected evidence: `2/2` vulnerable attempts EXPLOITED, `2/2` fixed attempts BLOCKED,\n   exit code 0.\n\n## Evidence\n\n- `bundle/logs/reproduction_steps.log` — full transcript of both phases.\n- `bundle/logs/vuln-attempt-{1,2}.log` — per-attempt exploit transcripts. Key excerpts\n  (attempt 1):\n  - `step5 GET refresh -> selector markers=1 email-sent markers=0` and the re-rendered\n    selector form action now contains `execution=0089b76d-...` (the email execution,\n    different from the choose-user execution `141b34d5-...` used in steps 2–4).\n  - `step6 POST email execution (no action token) -> HTTP/1.1 302 Found Location:\n    .../login-actions/required-action?execution=UPDATE_PASSWORD...` — the blind\n    `context.success()` fired without any action token.\n  - `step7 new password -> HTTP/1.1 302 Found Location:\n    http://localhost:8080/realms/cvetest/account/?session_state=...&code=...` — the\n    attacker is issued an authorization code as `victim`.\n  - `step8 takeover verify: NEW password token HTTP 200, OLD password token HTTP 400`.\n- `bundle/logs/fixed-attempt-{1,2}.log` — `step5 GET refresh -> selector markers=0\n  email-sent markers=1`; old password still 200, new password 400 → BLOCKED.\n- `bundle/logs/smtp-capture-final.log` — the captured \"Reset password\" email addressed\n  `To: victim@cvetest.local` containing the real\n  `/realms/cvetest/login-actions/action-token?key=eyJ...` link (which the attacker never\n  receives).\n- `bundle/logs/http/<role>-<n>/step*.html|*.hdr` — raw HTTP responses/headers for every\n  step of every attempt.\n- `bundle/logs/keycloak-vuln.log`, `bundle/logs/keycloak-fixed.log` — server logs;\n  `server-version` reported by `/admin/serverinfo` was 26.7.1 / 26.7.2 respectively.\n- `bundle/repro/runtime_manifest.json` — runtime identity (image digests\n  `quay.io/keycloak/keycloak@sha256:f1f1f01e…` vulnerable,\n  `…@sha256:83133051…` fixed), attempt results, proof artifact list.\n- Environment: rootless Docker 27.5.1 on Linux x86_64; Keycloak `start-dev` (H2),\n  hostname `http://localhost:8080`; no sanitizers involved (product runtime proof).\n\n## Recommendations / Next Steps\n\n- Upgrade to a fixed build: upstream Keycloak ≥ 26.4.15 / ≥ 26.6.6 / ≥ 26.7.2; Red Hat\n  build of Keycloak 26.6-12 container or later (RHSA-2026:56519, 56523, 56524).\n- The upstream fix is the correct approach: bind the selector-screen auth note to the\n  execution model id and require the `ACTION_TOKEN_USER_ID` note in\n  `ResetCredentialEmail.action()`.\n- Regression testing: keep `ResetPasswordTest.resetPasswordTryAnotherWay()` and add an\n  end-to-end test asserting that a GET refresh after the email fork cannot surface the\n  email execution's action URL.\n- Defense-in-depth: consider invalidating or single-using the email execution state once\n  the action token is issued, and rate-limiting reset-credential initiations per\n  username/IP.\n\n## Additional Notes\n\n- Idempotency: the script is fully idempotent — it re-creates the docker network and\n  containers, truncates the SMTP capture, and restores the victim's password between\n  attempts. Verified by two consecutive successful runs (exit 0 both times).\n- The exploit uses only unauthenticated endpoints; no SMTP access, no victim\n  interaction, and no knowledge beyond the victim's username are required. The victim's\n  email delivery is not even required to succeed for the pivot (Keycloak forks the flow\n  with a success message even when sending fails), but this reproduction proves real\n  email generation via the SMTP capture.\n- The fork behavior (`forkWithSuccessMessage` → cloned tab on the browser flow) is why\n  the legitimate \"email sent\" page is displayed on a cloned authentication-session tab\n  while the original tab retains the selector note and the email execution as current —\n  the exact state the GET refresh abuses.\n- The verification client `atk-cli` (public, direct grants) exists only to prove the\n  password change; it plays no role in the exploit path itself.\n","cve_id":"CVE-2026-18963","cwe_id":"CWE-640 Weak Password Recovery Mechanism for Forgotten Password","source_url":"https://nvd.nist.gov/vuln/detail/CVE-2026-18963","reproduced_at":"2026-08-24T11:14:38.647851+00:00","duration_secs":17.0,"tool_calls":228,"handoffs":2,"total_cost_usd":0.032475,"agent_costs":{"claim_matcher":0.019478,"learning_policy":0.012997},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.019478},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.012997}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-08-24T11:14:39.385687+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":10775,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":23448,"category":"reproduction_script"},{"path":"bundle/logs/fixed-attempt-1.log","filename":"fixed-attempt-1.log","size":1488,"category":"log"},{"path":"bundle/logs/fixed-attempt-2.log","filename":"fixed-attempt-2.log","size":1489,"category":"log"},{"path":"bundle/logs/http/fixed-1/step5-refresh.html","filename":"step5-refresh.html","size":8010,"category":"other"},{"path":"bundle/logs/http/vuln-1/step5-refresh.html","filename":"step5-refresh.html","size":7729,"category":"other"},{"path":"bundle/logs/http/vuln-1/step6.hdr","filename":"step6.hdr","size":599,"category":"other"},{"path":"bundle/logs/http/vuln-1/step7.hdr","filename":"step7.hdr","size":1439,"category":"other"},{"path":"bundle/logs/keycloak-fixed.log","filename":"keycloak-fixed.log","size":3612,"category":"log"},{"path":"bundle/logs/keycloak-vuln.log","filename":"keycloak-vuln.log","size":3611,"category":"log"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":21640,"category":"log"},{"path":"bundle/logs/smtp-capture-final.log","filename":"smtp-capture-final.log","size":20819,"category":"log"},{"path":"bundle/logs/vuln-attempt-1.log","filename":"vuln-attempt-1.log","size":2878,"category":"log"},{"path":"bundle/logs/vuln-attempt-2.log","filename":"vuln-attempt-2.log","size":2878,"category":"log"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1669,"category":"other"},{"path":"bundle/repro/smtp_sink.py","filename":"smtp_sink.py","size":3314,"category":"script"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1350,"category":"other"}]}