SAML is a distributed system pretending to be a credential. The Identity Provider (IdP) signs an assertion saying "this is Alice, valid until 14:32:00Z." The Service Provider (SP), your application, verifies the signature and creates a session. Between those two machines sits XML: a format with namespaces, comments, optional whitespace, multiple valid serializations of the same logical tree, and a signature standard (XML-DSig) flexible enough to sign a fragment, a subtree, or the whole document, identified by a reference the document itself supplies. That flexibility is the vulnerability. Every SAML bypass in the modern era is some version of the same move: make the component that checks the signature and the component that reads the identity look at two different parts of the same document.
This piece walks SAML assertion desync the way our JWT and SSRF pieces walked their classes: the pattern in one paragraph, why it ships, the attacker's full decision tree, a composite real-world scenario grounded in real CVEs, and the Find / Prove / Fix / Verify contract that closes it. Verifiable security.
The attack pattern in one paragraph
A SAML Response is an XML document containing an <Assertion> with the user's identity, wrapped (usually) in an XML-DSig <Signature>. To validate, the SP must: (1) canonicalize the signed region to a fixed byte sequence (C14N), (2) hash it, (3) compare the hash to the signed <DigestValue>, (4) verify the signature over the <SignedInfo> using the IdP's public key, and (5) extract the identity from the element the signature actually protected. The desync is the gap between step 4 and step 5. In a signature-wrapping (XSW) attack, the attacker takes a legitimately-signed assertion, copies it, alters the copy to say "I am the admin," and arranges the document so the signature verifier resolves its Reference to the original (still-valid) assertion while the identity parser reads the attacker's injected one. Both checks "pass" against different elements. In the canonicalization (CVE-2024-45409) variant, the library's C14N transform and its XPath/Reference resolution disagree about which node set is in scope, so the verifier signs off on bytes that are not the bytes the application trusts, and an attacker with any single valid signed fragment can graft it onto a forged assertion. In the timing variant, library-level rounding of NotOnOrAfter down to the second, combined with cross-cell IdP clock skew, leaves a replay window for a captured assertion. Each path ends the same way: the SP creates a session for a user the IdP never authenticated.
The reason this is a forged session and not just "a parsing bug" is that the SP's session is unconditional once the assertion validates. There is no second factor after SAML; the assertion is the authentication. Forge the assertion and you are the user, not for one request, but for the full session lifetime, with whatever roles the assertion claimed.
Why this still ships in 2026
XML signature wrapping was published in 2012. Why did ruby-saml ship a fresh, critical instance of the class in 2024, and why do we still find it? Four structural reasons:
- XML-DSig signs a reference, not a position. The standard lets the signature point at an element by ID and lets transforms reshape the node set before hashing. "Which bytes are signed" is computed, not fixed, and any disagreement between the transform pipeline and the identity lookup is an exploit. The spec's flexibility is the attack surface; you cannot patch the spec, only constrain how libraries use it.
- Canonicalization and verification live in different code. The C14N transform, the XPath/Reference resolver, and the "read the Subject" logic are frequently three separate functions, sometimes three separate libraries. CVE-2024-45409 was precisely a disagreement between ruby-saml's signature handling and its document model. Each was internally correct; together they desynced.
- The SP trusts the document to tell it what to validate. Just like JWT's
algheader, a naive SAML SP reads theReference URI, theIssuer, and the assertion ID from attacker-controllable XML, then uses those to drive validation. Trusting the token to describe its own verification is the same root cause across protocols. - SAML is "done." Nobody owns it. SAML integrations were configured years ago, the library was pinned and forgotten, and the team that set it up has moved on. When CVE-2024-45409 dropped, the vulnerable code had been running untouched in production at thousands of orgs, including GitLab self-managed and many SaaS SPs, with no one watching the dependency for advisories.
The attacker decision tree
Four verification primitives, one outcome: the byte range the signature covers and the byte range the SP trusts are not the same.
The decision tree's branch point is step 2: the attacker fingerprints how the SP couples verification to extraction. An SP that verifies the signature and then re-parses the document to find the identity (rather than extracting from the exact verified node) is vulnerable to XSW. An SP whose canonicalizer and reference resolver come from libraries that disagree is vulnerable to the CVE-2024-45409 class. An SP that rounds NotOnOrAfter and trusts a skewed IdP clock is vulnerable to replay. The attacker tries the cheapest first; one valid signature is the only prerequisite for all of them.
A composite real-world scenario
The setting is an enterprise SaaS SP behind ADFS-style SAML SSO, a financial-services tenant where the workforce authenticates through a corporate IdP into a third-party application that gates sensitive data by a role attribute in the assertion. The SP embeds a SAML library in the vulnerable line (ruby-saml pre-1.17, the CVE-2024-45409 range). An attacker is a low-privilege employee, or anyone who can capture one legitimate SAMLResponse, which is a base64 POST body trivially logged by a browser SAML-trace extension.
Step one: capture. The attacker logs in normally and grabs their own valid, signed SAMLResponse. The assertion's <Subject> names them; an <AttributeStatement> carries role=analyst. The signature is real, issued by the IdP's key, and covers the assertion by reference.
<!-- Captured, legitimately-signed assertion (abbreviated) -->
<samlp:Response ...>
<saml:Assertion ID="_a1b2c3" ...>
<saml:Subject><saml:NameID>analyst@corp.example</saml:NameID></saml:Subject>
<saml:AttributeStatement>
<saml:Attribute Name="role"><saml:AttributeValue>analyst</saml:AttributeValue></saml:Attribute>
</saml:AttributeStatement>
<ds:Signature>... <ds:Reference URI="#_a1b2c3"/> ...</ds:Signature>
</saml:Assertion>
</samlp:Response>
Step two: wrap. The attacker keeps the original signed assertion intact (so the signature still verifies against #_a1b2c3) and grafts in a second, forged assertion with a different ID, a NameID of the CFO, and role=admin. The document is arranged so the SP's reference resolver points the signature at the original, while the SP's identity extraction, which re-scans for the "first assertion" or trusts the document order, reads the forged one.
<!-- Signature-wrapped forgery: signed original retained, forged twin injected -->
<samlp:Response ...>
<saml:Assertion ID="_forged"> <!-- READ by the SP -->
<saml:Subject><saml:NameID>cfo@corp.example</saml:NameID></saml:Subject>
<saml:AttributeStatement>
<saml:Attribute Name="role"><saml:AttributeValue>admin</saml:AttributeValue></saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
<saml:Assertion ID="_a1b2c3"> <!-- VERIFIED by the sig -->
... original Subject + the still-valid <ds:Signature> over #_a1b2c3 ...
</saml:Assertion>
</samlp:Response>
In the CVE-2024-45409 canonicalization variant, the attacker does not even need a clean wrap: the disagreement between ruby-saml's C14N transform and its node-set resolution lets a single valid signed fragment be grafted onto an otherwise-forged document, and the verifier still returns true. Either way, the SP runs its checks: the signature verifies (against the original element), the assertion "is signed," the Conditions audience matches, and the identity extraction returns cfo@corp.example, role=admin. The SP mints a session for the CFO with admin rights. The attacker never touched a password.
The Confluence dimension, CVE-2023-22515, exploited in the wild in 2023, is the same trust-the-request-over-the-real-state pattern one layer up: a broken-access-control / privilege-escalation flaw that let unauthenticated attackers reach setup endpoints and create administrator accounts. Where the SAML desync forges the assertion the SP trusts, the Confluence bug forged the application-state the access check trusted. Both are the auditor's static review of configuration disagreeing with the system's behaviour under a crafted request: the unifying shape of every SSO desync we hunt.
What we observe in customer environments
We are honest about the limits of our visibility. CelvexGroup's continuous validation runs against assets a customer scopes in, and our SAML signatures are deliberately read-only and spec-grounded: we fingerprint the desync shape in metadata and probe with synthetic, never-mutating requests, per our no-false-positive rule. We do not forge live admin sessions against production. What our R-03 identity-desync signature family (RED-IDDESYNC-EXT) and SAML-specific checks find, across engagements over the past nine months:
- A meaningful minority of SAML SPs exposed a vulnerable library fingerprint in their EntityDescriptor or behaviour:
ruby-saml,python-saml, ormod_auth_mellonat versions below the patched line for the wrapping and canonicalization classes. - Roughly a third of SP metadata lacked a documented
cacheDuration/validUntilrefresh policy, the configuration tell that often accompanies stale signature-validation handling. - Many SPs rounded
NotOnOrAfterto the second and trusted an IdP clock with multi-hour cross-cell skew, producing a measurable replay window for any captured assertion. - The majority of SAML SPs we reviewed did not bind the extracted identity to the exact signed element; they verified the signature and then re-parsed the document to find the Subject, the precise gap XSW drives a truck through.
The honest read: SAML desync is lower-frequency than JWT alg-confusion, but the blast radius is higher: a single successful forgery is a full admin session, and the vulnerable libraries sit unwatched in long-lived integrations exactly where nobody is monitoring the dependency feed.
What to do about it: the assertion-binding contract
The fix that ends the class is a single principle stated four ways: the bytes you verify must be the bytes you trust. Bind extraction to the exact verified element, and the desync has nowhere to live.
SAML assertion-binding contract: controls that end the class
- Extract identity only from the exact element the signature verified, and never re-parse. Verify the signature, capture the precise verified node, and read the Subject and attributes from that node object, not from a fresh query against the document. This single binding kills signature wrapping.
- Reject any SAMLResponse containing more than one assertion or more than one signature. A legitimate Response carries exactly one signed assertion. Multiple assertions or multiple signatures is the XSW signature itself, so fail closed.
- Pin and patch the SAML library; treat CVE-2024-45409 as a today problem. Upgrade ruby-saml to a patched release (1.17.0+ / 1.12.3+ per the advisory), and the equivalents for python-saml and mod_auth_mellon. Subscribe the dependency to a CVE feed so the next class instance does not run untouched for years.
- Use a single, fixed canonicalization and require enveloped-signature transforms only. Disallow arbitrary XPath transforms in the signature reference. Pin exclusive C14N. Eliminate the gap where the transform pipeline and the reference resolver can disagree.
- Enforce
NotOnOrAfterwith ceiling semantics and a tight, monitored clock-skew allowance. Compare against millisecond precision, do not round down, and keep IdP/SP clock skew small and alarmed. Reject any assertion replayed against its one-time-useInResponseTo/ assertion-ID cache. - Validate
Audience,Issuer, andRecipientagainst a fixed allow-list, and reject DTDs and external entities. The assertion must be for this SP, from this IdP, to this endpoint, checked against pinned values, not values read from the document. Disable DTD processing to close the XXE/entity-confusion variant.
The bytes you verify must be the bytes you trust. Bind the identity to the exact signed element, never re-parse the document, and signature wrapping has nowhere left to hide.
The audit, in concrete terms, is a library-version check plus a behavioural probe:
# Find the SAML library and version across the SP estate
$ grep -rE "ruby-saml|python-saml|python3-saml|mod_auth_mellon|onelogin" \
Gemfile.lock requirements*.txt poetry.lock 2>/dev/null
# Then verify the binding behaviourally against staging: submit a SAMLResponse
# with one valid signed assertion AND one forged-but-unsigned twin.
# - Vulnerable: SP extracts the FORGED identity → session for wrong user
# - Fixed: SP rejects (multi-assertion) OR reads only the signed node
Read each integration. Confirm the library is at or above the patched line. Confirm the SP extracts from the verified node and rejects multi-assertion documents. The exercise is finishable in a day per SP, and it converts the highest-blast-radius identity bug we hunt into a closed door.
How Celvex catches this
Find. Prove. Fix. Verify.
Our R-03 identity-desync signatures fetch SP metadata and fingerprint vulnerable SAML-library versions, missing refresh policy, NotOnOrAfter rounding, and the extract-after-verify gap, all read-only, spec-grounded, every FAIL citing a SAML/OASIS clause and a real CVE.
For a confirmed binding gap we ship a Proof Capsule that demonstrates the desync against a loopback IdP+SP stub, with a wrapped assertion accepted, Ed25519-signed for air-gapped verification, with the exact request and evidence.
The Capsule's remediation block points at the assertion-binding contract scoped to the finding: which library line to upgrade, the verify-then-extract-from-verified-node change, and the multi-assertion reject rule.
After the fix lands, the re-test confirms the wrapped/forged assertion is rejected and the verified node drives extraction. The finding closes automatically and the verified-fix event is recorded for the auditor.
Where we sit on the autonomy curve: at L1.5 today, our SAML and R-03 corpus reliably fingerprints the vulnerable-library, timing-rounding, and metadata-policy tells, and ships a loopback Proof Capsule for the SCIM/SAML desync exemplar. At L2 within 90 days, the corpus extends to behavioural extract-after-verify probing with synthetic wrapped assertions against scoped staging endpoints. At L3 within twelve months, the scanner mutates the wrapping and canonicalization permutations to fit unfamiliar SAML stacks and private federation profiles discovered in customer environments. We do not claim L3 today. We do claim that L1.5 catches the desync shape that turns one captured assertion into an arbitrary admin session, and ships a reproducible Capsule for each.
Bottom line
SAML's security is a single promise, that the bytes the signature covers are the bytes the application trusts, and every modern SAML bypass is that promise broken by a desync. Signature wrapping injects a forged twin the parser reads while the verifier checks the original. CVE-2024-45409 made ruby-saml's canonicalizer and reference resolver disagree so a single valid fragment forges any assertion. Timing rounding plus clock skew leaves a replay window. CVE-2023-22515 is the same trust-the-request-over-the-real-state pattern one layer up. The fix is one principle: bind the extracted identity to the exact verified element, reject multi-assertion documents, pin a single canonicalization, and patch the library. That work is finishable in a day per SP, and it converts the highest-blast-radius identity bug we hunt into a forgery that simply does not validate.
Verifiable security. Find it. Prove it. Fix it. Verify the fix held. That is what we ship.
Sources
- NVD: CVE-2024-45409 (ruby-saml signature verification bypass)
- GitHub Security Advisory: ruby-saml GHSA-jw9c-mfg7-9rx2
- NVD: CVE-2023-22515 (Atlassian Confluence broken access control / privilege escalation)
- CISA Advisory AA23-289A: CVE-2023-22515 exploited in the wild
- W3C: XML Signature Syntax and Processing (XML-DSig)
- OASIS: SAML 2.0 Core specification
- OWASP: SAML Security Cheat Sheet on signature wrapping
- CELVEX Group: Proof Capsule format
Probe your own SAML SPs.
Free Exposure Check, no signup required. We fingerprint the SAML desync surface, covering vulnerable library lines, timing windows, and the extract-after-verify gap, and ship a Proof Capsule for the highest-confidence finding.
Run a Free Scan →