
Proof of concept and vulnerability checker for CVE-2026-18963, an unauthenticated account takeover in the Keycloak reset-credentials flow. Single-file Python, optional Playwright browser component.
A proof of concept and vulnerability checker for the Keycloak reset-credentials flow bypass, with an optional browser component that renders the resulting state in a real Chrome window.
The tool is a single file, cve_2026_18963_poc.py, written against the Python
standard library. Playwright is the only third-party dependency and it is
imported lazily, so the HTTP side runs on a stock interpreter with nothing
installed.
cve_2026_18963_poc.py 1009 lines, Python 3.10 or newer
Credit. CVE-2026-18963 was reported to Red Hat by James Paremain, who is credited in the vendor advisory. Orevim did not discover or report this vulnerability. This repository is an independent proof of concept written from the published advisory.
CVE-2026-18963 is an authentication bypass in Keycloak's forgot-password flow. It is two defects acting together.
The unscoped note. When a user opens the authenticator selection screen,
Keycloak writes an AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED note into the
authentication session. The note is a bare boolean. It records that a
selection screen was displayed, not which execution displayed it. The note
outlives the execution that set it.
The unguarded handler. ResetCredentialEmail.action() sets emailVerified
on the resolved user and calls success() without checking the action token
that the emailed link is supposed to carry. The method assumes it can only be
reached by following that link.
Chained, an unauthenticated caller sets the note against the username
execution, advances the flow so the email execution becomes current, then
re-enters the reset URL. The stale note causes the selection screen to render
bound to the email execution. Posting an empty body to that execution's
loginAction invokes the unguarded handler directly. The flow advances to the
UPDATE_PASSWORD required action with no email ever read.
| Identifier | CVE-2026-18963, GHSA-4gv3-mc9p-5wqc |
| Class | CWE-640, weak password recovery mechanism for forgotten password |
| CVSS v3.1 | 9.1 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| Affected | >= 26.0.0, < 26.4.15, >= 26.5.0, < 26.6.6, >= 26.7.0, < 26.7.2 |
| Patched in | 26.4.15, 26.6.6, 26.7.2. The 26.8.0 line ships with the fix |
| Package | org.keycloak:keycloak-services (Maven) |
| Published | August 18, 2026 |
| Reported by | James Paremain, credited in the Red Hat advisory |
The only precondition is that the realm offers password recovery to unauthenticated users. No credential, token, or prior session is required.
Eight requests, in order. Tags match the step labels the tool prints and the
filenames it writes under --out.
| # | Tag | Method | What it does |
|---|---|---|---|
| 1 | login | GET | Authorization URL. Establishes the authentication session and yields the reset link. |
| 2 | reset | GET | Reset-credentials page. Carries the username form. |
| 3 | tryanother | POST | tryAnotherWay=on. Writes the unscoped note. |
| 4 | username | POST | The identifier. Advances the flow so the email execution becomes current. Sends recovery mail if the identifier resolves. |
| 5 | reenter | GET | The reset URL again. The stale note renders the selector bound to the email execution. |
| 6 | action | POST | Empty body to that execution's loginAction. Invokes the unguarded handler. |
| 7 | required-action | GET | Follows the Location to the UPDATE_PASSWORD page. |
| 8 | setpassword | POST | The new password. Only sent with --set-password. |
Step 6 is the exploit, and the empty body is load-bearing. Posting
authenticationExecution=<id> instead is the ordinary selector interaction: it
switches execution and re-sends the recovery mail, which never reaches the
defect. The empty POST answers no challenge, so Keycloak calls the handler for
whichever execution the stale note left current.
# Decide whether the instance is affected. Changes nothing, sends no mail.
./cve_2026_18963_poc.py --url https://sso.example.com --realm demo \
--client-id account-console --check
# Additionally report which identifiers resolve to a real account.
# Resolving accounts receive a recovery email. See the note below.
./cve_2026_18963_poc.py --url https://sso.example.com --realm demo \
--client-id account-console --check --users-file names.txt --rate 0.25
# Reach the set-password form for one account and stop there.
./cve_2026_18963_poc.py --url https://sso.example.com --realm demo \
--client-id account-console --username [email protected]
# Complete the takeover. This locks the account holder out.
./cve_2026_18963_poc.py --url https://sso.example.com --realm demo \
--client-id account-console --username [email protected] \
--set-password 'N3w-passw0rd!'
--check probes with a random identifier of the form
<16 hex>@<8 hex>.invalid. The .invalid TLD is reserved by RFC 2606 and
cannot resolve, so the probe cannot collide with a real account and cannot
cause mail to be sent. It establishes the build's status without touching
anyone.
Adding
--userschanges that. Step 4 is the ordinary forgot-password submission. On a realm with SMTP configured, an identifier that resolves to a user with an email address receives a genuine password-recovery email, on affected and patched builds alike. The bare probe does not, becauseResetCredentialEmail.authenticate()sends nothing when the user is null. Treat--check --usersas a flow that notifies real people and confirm it is within your authorization before running it.
The tool classifies on the response to step 6, the empty POST.
| Response | Outcome | Meaning |
|---|---|---|
| 400 | account-absent | The handler ran with no action token and resolved no user. The build is affected. |
302 with Location | form-reached | The handler ran and the account resolves. Affected, and this identifier is real. |
| Anything else | not-vulnerable | The empty POST did not advance. |
| No selector at step 5 | not-vulnerable | The stale note was not honored. |
| 302 after step 8 | password-set | The password changed and the realm issued a session. |
| 429 past the retry budget | rate-limited | No verdict. The run stopped. |
| A negative reached after any throttling | inconclusive | See below. |
The 400 is the important one. It separates the build is affected from this
account exists, which lets --check establish exposure against an instance
without enumerating a single real user. Resolving identifiers are then a
separate, opt-in question.
A negative is only sound if nothing was throttled. Keycloak expires the
authentication session on its own clock. A 429 mid-chain costs a backoff, and a
chain that resumes after a long sleep returns exactly the page a patched build
returns. Reporting that as not affected is the one failure mode a checker must
not have, so any negative reached after a throttle event is downgraded to
inconclusive with the throttle count and the advice to re-run slower.
Nothing about the deployment is hardcoded. The tool reads what it needs:
--url and --realm, or supplied directly with --discovery-url. Pass
--auth-url to skip discovery entirely.login-actions/reset-credentials.