
Hunt for CVE-2026-18963 exploitation traces (Keycloak unauthenticated account takeover) in the Keycloak database
A psql script that searches a Keycloak PostgreSQL database for traces of
CVE-2026-18963 exploitation (unauthenticated account takeover through the
reset-credentials flow).
Published by KYOS. We wrote it while patching the Keycloak deployments we operate, to verify that nobody had been taken over during the exposure window.
CVE-2026-18963 is a flaw in the reset-credentials flow of keycloak-services
(keycloak#51833). An
unauthenticated attacker can complete the password reset process for any user
without clicking the mail verification link, then set a new password on the
account. No user interaction is required.
Checked against Keycloak 26.7.1 on a disposable instance. We are not publishing reproduction steps; the details below are the ones you need to read the queries.
The flow tracks which authenticator screen it is on in a way that is not tied
to the step that set it. That lets the reset-credentials flow be re-entered at
the mail-verification step, where the vulnerable ResetCredentialEmail.action()
returns success without ever checking for an action token. Execution then
carries on to the update-password form.
Two consequences shape every query in this repo:
SEND_RESET_PASSWORD event. The absence of that event is not a signature.action() marks the account's email address as verified
before it returns. The legitimate path never reaches action() at all, so
this is a state change that only the bypass produces. Q3 rests on it.The fix (keycloak#51844)
ties the screen state to the execution that set it, and only lets action()
succeed when an action token for that exact user is present.
Severity: Critical, CVSS v3.1 9.1 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N),
per Red Hat and
NVD. Root cause is
improper state validation in the authentication flow, fixed in
keycloak#51844.
| Stream | Fixed in |
|---|---|
| 26.7.x | 26.7.2 (release notes) |
| 26.6.x | 26.6.6 |
| 26.4.x (LTS) | 26.4.15 |
| 26.8 | 26.8.0 |
Everything below those versions in the supported streams is vulnerable. Out-of-support releases (26.5.x, 26.3 and older) get no fix: assume exposure and upgrade to a fixed stream. Red Hat lists legacy RH-SSO 7 as not affected; the Red Hat Build of Keycloak 26.4/26.6 is fixed in 26.4.15-1 / 26.6.6-1.
Disabling self-service password reset removes the vulnerable entry point:
Admin console > Realm settings > Login > "Forgot password" off, for every
realm. Via the admin API: PUT /admin/realms/{realm} with
{"resetPasswordAllowed": false}.
This blocks the login-actions/reset-credentials endpoint but also prevents
legitimate users from resetting their own password, so treat it as a stopgap
until you upgrade. Note that Red Hat does not list an endorsed mitigation for
this CVE; patching is the only real fix.
The first version of this script flagged a completed reset that had no
SEND_RESET_PASSWORD shortly before it.
That was wrong, and
vilmosnagy pointed it out
after reproducing the CVE on his own instance and watching the event appear
anyway. The reason is the first consequence above: the mail authenticator runs
normally, so the victim gets a real reset mail and Keycloak logs an ordinary
SEND_RESET_PASSWORD. The check was inverted. It would have cleared every
compromised account it looked at. It is gone.
Timing schemes have a smaller but real problem. The approach in
thomasdelorge's hunt script
correlates SEND_RESET_PASSWORD with UPDATE_PASSWORD on a shared code_id
and flags a short gap. The correlation key is right, and the author is explicit
that the threshold is a heuristic. Our testing says the same: a bypassed reset
and a legitimate one completed in the same browser produce identical event
types, identical detail keys and the same code_id. Only the delay differs,
which leaves the threshold carrying the whole verdict. Anyone in no hurry stays
under it. Real users trip it, because the code_id only stays the same when the
link is opened in the browser that started the flow, which is also the case
where the mail is likely already sitting open.
We found no event, no event detail and no error that separates the two. There is no "mail step skipped" event to look for. What does exist is a state side effect, and that is what Q3 uses.
cve-2026-18963-keycloak-hunt.sql runs six read-only queries:
| Query | What it finds |
|---|---|
| Q0 | Whether the realm stores events at all, their TTL, and whether event details are being persisted. Q2 correlates on the code_id detail, so if details are missing Q2 is blind and its silence means nothing. |
| Q1 | Every password credential set inside the exposure window (credential.created_date). This is the takeover itself, and it survives event expiry. |
| Q2 | Every completed credential update, correlated with the reset mail of the same authentication session, and classified. See below. |
| Q3 | The deterministic side effect: users who are email_verified with no legitimate cause. |
| Q4 | Admin-API credential resets and user writes, to explain Q1 and Q3 rows and rule out the admin-driven route. |
| Q5 | Raw reset/credential event timeline, for reading a single case by hand. |
| Verdict | Meaning |
|---|---|
NO_MAIL_WAS_SENT | The credential update shares a code_id with a failed send: no address on the account, or SMTP refused it. No link ever left the server, so nobody could have opened one, yet the flow still reached the password form. Legitimately impossible; treat as exploitation. |
CLEARED_TOKEN_CLICKED | The reset completed under a different code_id than any SEND_RESET_PASSWORD for that user. Only an action token consumed in another browser or session produces that, so the mail link really was clicked. Deterministically legitimate. |
CANDIDATE | Same code_id as the send. This is what the bypass looks like, and also what a legitimate same-browser reset looks like. Not a finding on its own. |
NOT_RESET_FLOW | No SEND_RESET_PASSWORD for that user in the window; the password changed some other way (account console, admin API, enrolment). Cross-check Q4. |
secs_after_send is printed for context. Do not use it as the verdict.
The vulnerable ResetCredentialEmail.action() marks the account's email
address as verified before it succeeds. The legitimate path never enters
action() at all, because authenticate() short-circuits on the action token,
so a genuine reset leaves email_verified alone. We checked this on 26.7.1 for
legitimate resets completed both in the browser that started them and in a
different one. Both left an unverified user unverified. Both bypassed resets
flipped the flag.
So an account whose password changed inside the window, which is now
email_verified, with no VERIFY_EMAIL/UPDATE_EMAIL event and no admin
write against it, has no innocent explanation for being verified.
One limitation matters a great deal here: accounts that were already verified
before the window are invisible to Q3, because the flag was already true.
Q3 is precise, not exhaustive. Its strongest row is verified_with_no_email = t,
because an account with no address at all can neither receive a reset link nor
be verified by opening one.
# defaults: realm 'master', window since 2026-06-01
psql -U keycloak -d keycloak -f cve-2026-18963-keycloak-hunt.sql
# explicit realm and window (run once per realm; plain values, psql quotes them)
psql -U keycloak -d keycloak \
-v realm=myrealm -v since=2026-05-01 \
-f cve-2026-18963-keycloak-hunt.sql
Set since to just before your vulnerable version went live. On Kubernetes:
kubectl exec -it my-postgres-pod -- \
psql -U keycloak -d keycloak -v realm=myrealm -v since=2026-05-01 \
-f - < cve-2026-18963-keycloak-hunt.sql
Check Q0 first. If events_in_window is 0, or details_with_code_id is 0
while events exist, Q2 cannot work and its silence proves nothing. Q1 and Q3
read table state and are unaffected by event retention.
Then work down:
NO_MAIL_WAS_SENT. Exploitation. Act on it.CLEARED_TOKEN_CLICKED. Clear these and stop thinking about them.admin_ops_on_user and has_verify_email_event = f
are exploitation, subject to the caveat that an account enrolled as verified
by an import path that writes no admin event will also land here.CANDIDATE that Q3 does not corroborate is unresolved. The database
cannot settle these, so go to the access log.This is deterministic wherever the logs still exist. Opening the emailed link is an HTTP request, and it is the one thing the bypass cannot fake. In your reverse proxy or ingress log, a genuine reset always shows
GET /realms/<realm>/login-actions/action-token?key=...
from the same client, between the mail going out and the password being set.
grep -E "login-actions/action-token" access.log
For each CANDIDATE in Q2, look for that request between the
SEND_RESET_PASSWORD and the UPDATE_PASSWORD. If it is not there, the mail
gate was never passed.
Two things to watch. HEAD requests on action-token come from mail-scanning
appliances and are ignored by Keycloak, so only a GET counts. And some mail
security products follow links automatically, which produces a real GET the
user never made; if your estate has one, corroborate with the user-agent and
source address rather than the request alone.
Finally, the cheapest source is often the users themselves. Every victim received a password-reset email they never requested. For a short window and a small candidate set, asking beats inference.
Rows in Q1 are not automatically compromises. Match each against a known legitimate cause (self-service reset, helpdesk action, new enrolment) and treat anything unexplained as a takeover candidate.
email_verified.event_entity.details_json_long_value, and details_json is
NULL for every row. In our 26.7.1 lab it was 29 rows out of 29. Read both
columns or you will silently match nothing.RESET_PASSWORD is not written on a successful reset. The completion events
are UPDATE_PASSWORD and UPDATE_CREDENTIAL; RESET_PASSWORD_ERROR does
appear on failures.SEND_RESET_PASSWORD is logged,
which is what sent us back to the source and got the inverted check removed.code_id correlation and the access-log check, both of which this
script builds on.MIT, see LICENSE. Provided as-is, without warranty.