Skip to content
KitploitKITPLOIT
ToolsBlog
Submit
ToolsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

··Feeds·Contact·Privacy·© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
CVE-2026-18963-Exploit — Exploit for Keycloak CVE-2026-18963 enabling unauthenticated account takeover via reset-credentials bypass. Includes safe detection, non-destructive proof, full takeover, username enumeration, and a lab with vulnerable and patched versions. | Kitploit
Tools/GitHubGitHub/snizi/cve-2026-18963-exploit
Authentication & AuthorizationVulnerability AnalysisExploitationWeb Application ExploitationPenetration Testing
GitHubsnizi/cve-2026-18963-exploit

CVE-2026-18963-Exploit

Exploit for Keycloak CVE-2026-18963 enabling unauthenticated account takeover via reset-credentials bypass. Includes safe detection, non-destructive proof, full takeover, username enumeration, and a lab with vulnerable and patched versions.

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share
View Repository
714 days agoNot yet reviewed

CVE-2026-18963 — Keycloak reset-credentials bypass → unauthenticated account takeover

CVE Affected Python Dependencies

Knowing only a username or e-mail address, an unauthenticated attacker sets an arbitrary password on any Keycloak 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.

Affected: Keycloak 26.0.0 – 26.7.1. Fixed in 26.7.2.


Am I vulnerable?

One command. No valid username required, and no side effects — it sends no e-mail, writes to no account, and stops before the exploitable step.

root@kitploit:~
git clone https://github.com/Snizi/CVE-2026-18963-Exploit
cd CVE-2026-18963-Exploit

python3 cve_2026_18963_poc.py \
  --base https://sso.example.com --realm YOUR_REALM \
  --client-id account \
  --redirect-uri https://sso.example.com/realms/YOUR_REALM/account/ \
  --safe-check

Python 3.9+, standard library only. Nothing to install.

Run it per realm — Forgot password is a per-realm setting, and master counts. Details, and why the check needs no user and touches nothing, in §4a.

Already know you are exposed? Jump to remediation and detection / threat hunting.

Try it without a target

The repo ships a lab that boots a vulnerable 26.7.1 and a patched 26.7.2 side by side against an identical realm, plus a mailbox to watch the reset e-mail arrive and stay unread while the account is taken over:

root@kitploit:~
cd lab && docker compose up -d

python3 ../cve_2026_18963_poc.py --base http://localhost:8080 \
  --realm poc --client-id poc-app --safe-check   # VULNERABLE
python3 ../cve_2026_18963_poc.py --base http://localhost:8100 \
  --realm poc --client-id poc-app --safe-check   # PATCHED

⚠️ 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.

References: keycloak#51833 · GHSA-4gv3-mc9p-5wqc · fix keycloak#51844


Contents

  • 1. Root cause
  • 2. Affected versions (including legacy lines)
  • 3. The lab
  • 4. Usage
    • 4a. Safe detection (--safe-check) — start here
    • 4b. Non-destructive proof (--check)
    • 4c. Full takeover
    • 4d. Username enumeration (--enum)
  • 5. Custom login themes
  • 6. Known gap — PKCE
  • 7. Validation performed
  • 8. Remediation
  • 9. Detection
  • 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:

root@kitploit:~
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:

root@kitploit:~
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);   // <-- attacker-usable form
    }
}

It renders a submittable form aimed at whatever execution is currently parked, instead of keeping the session pinned on "waiting for the e-mail".

The glue is processResult() case FORK: — when Send Reset Email fires it stamps CURRENT_AUTHENTICATION_EXECUTION = <reset-credential-email execution id> 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

root@kitploit:~
@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

root@kitploit:~
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)

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 to capture the reset mail — so you can watch it arrive and stay unread while the account is taken over.

root@kitploit:~
cd lab
docker compose up -d

Realm poc, public client poc-app, user victim / OriginalPassw0rd!, Keycloak admin admin / admin.

Pin different builds with KC_VULN_VERSION / KC_PATCHED_VERSION:

root@kitploit:~
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.

root@kitploit:~
--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 <base>/realms/<realm>/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.

root@kitploit:~
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

Why it needs no user, and sends no mail. ResetCredentialEmail.authenticate() forks for an unknown user too:

root@kitploit:~
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.

root@kitploit:~
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 [email protected] --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.

root@kitploit:~
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)).

IdentifierStep 6Verdict
real user200, reaches the Update Password formVALID
unknown user400 (NPE error page)INVALID
root@kitploit:~
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/<realm>/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.

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 — [email protected]

Released under the MIT License. Issues and PRs welcome — particularly PKCE support and additional real-world theme quirks.

Download Tool
ExitVerdictMeaning
0🔴 VULNERABLEthe parked e-mail gate was served — the bug itself
2🟢 PATCHEDthe flow forked to login and stayed there (fix #51844 present)
2🟡 MITIGATEDreset-credentials unreachable — Forgot password is off. Not a patch.
3⚪ INCONCLUSIVEunrecognised response — do not read this as a pass
LineVulnerableCommunity fix
Legacy (WildFly-based Keycloak, ≤ 17)not affected—
Quarkus 17 – 25.xnot affected—
26.026.0.0 – 26.0.17none
26.126.1.0 – 26.1.5none
26.226.2.0 – 26.2.16none
26.326.3.0 – 26.3.5none
26.426.4.0 – 26.4.1426.4.15 (vendor backport tag)
26.526.5.0 – 26.5.7none
26.626.6.0 – 26.6.526.6.6 (vendor backport tag)
26.726.7.0 – 26.7.126.7.2
ServiceURLVersion
kc-vulnhttp://localhost:808026.7.1 — vulnerable
kc-patchedhttp://localhost:810026.7.2 — patched control
kc-mailpithttp://localhost:8025victim's mailbox
ExitVerdictMeaning
0VULNERABLEthe parked e-mail gate was served — the bug itself
2PATCHEDthe flow forked to login and stayed there (fix #51844 present)
2MITIGATEDreset-credentials unreachable — Forgot password is off. Not a patch.
3INCONCLUSIVEunrecognised response — do not read this as a pass
TestTargetResult
--safe-check26.7.1VULNERABLE, exit 0 — e-mail gate served (execution ≠ choose-user)
--safe-check26.7.2PATCHED, exit 2 — forked to login and stayed
--safe-check, Forgot password off26.7.2MITIGATED, exit 2 — HTTP 400, flow unreachable
Full takeover26.7.1exit 0 — password set, OIDC code issued, password grant confirms
Full takeover26.7.2exit 2 — blocked at step 5, account untouched
--check26.7.1reached UPDATE_PASSWORD; password verified unchanged afterwards
--enum26.7.1victim and [email protected] VALID, does-not-exist INVALID
Credential state after takeover26.7.1new password → 200, old password → 400
Credential state after blocked run26.7.2old password → 200, attacker password → 400
Victim mailboxMailpitreset mails delivered and unread; the action-token link is never fetched