← Back to Attack Research

SAML desync: how a clock and a canonicalization bug forge any session

A SAML assertion is signed XML. The signature covers a digest of the document, but which bytes are the document? When the canonicalizer and the signature verifier disagree, an attacker injects a forged assertion the verifier validates and the application trusts. CVE-2024-45409 turned ruby-saml into exactly that. Here is the attacker decision tree from an altered assertion to any authenticated session, and the assertion-binding fix.

SAML's entire security rests on one promise: the bytes the signature covers are the bytes the application reads. Break that promise, by making the signature verifier and the assertion parser disagree about which element is "the signed one," and an attacker forges a session for any user, with no password, no MFA, and no token theft. In September 2024, CVE-2024-45409 did exactly that to ruby-saml (and every framework that embeds it, including GitLab), letting an attacker who could view a single valid signature impersonate arbitrary users. The same class, trusting what the document says over what the signature actually covers, is the engine behind CVE-2023-22515, the Confluence broken-access bug exploited in the wild to mint admin accounts. This piece walks the desync, covering signature-wrapping, canonicalization disagreement, and assertion timing, from a captured or altered assertion to an arbitrary authenticated session, and the binding fix that ends the class.

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:

  1. 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.
  2. 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.
  3. The SP trusts the document to tell it what to validate. Just like JWT's alg header, a naive SAML SP reads the Reference URI, the Issuer, 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.
  4. 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

ATTACKER DECISION TREE SAML Assertion Desync → Session ┌───────────────────────────────────────────┐ │ 1. Obtain one valid signed assertion │ │ - log in legitimately, capture the │ │ SAMLResponse (POST body / SAML-trace) │ │ - or any single signed fragment from │ │ the same IdP (CVE-2024-45409) │ └───────────────────┬───────────────────────┘ │ ▼ ┌───────────────────────────────────────────┐ │ 2. Probe how the SP resolves "the signed │ │ element" │ │ - does it verify-then-extract by ID? │ │ - does it accept >1 Assertion? │ │ - which C14N / transform does it use? │ └───────────────────┬───────────────────────┘ ┌───────┼─────────────┬──────────────┐ ▼ ▼ ▼ ▼ ┌────────┐ ┌────────┐ ┌──────────┐ ┌──────────────┐ │ XSW │ │ C14N │ │ NotOnOr │ │ comment / │ │ wrap │ │ desync │ │ After │ │ entity / DTD │ │ inject │ │ (graft │ │ rounding │ │ namespace │ │ forged │ │ valid │ │ + clock │ │ confusion │ │ assert │ │ sig) │ │ skew │ │ (id-confusion)│ └───┬────┘ └───┬────┘ └────┬─────┘ └──────┬───────┘ │ │ │ │ ▼ ▼ ▼ ▼ ┌───────────────────────────────────────────┐ │ 3. Forge the identity in the part the SP │ │ READS but the sig DOESN'T cover │ │ - Subject/NameID -> victim user │ │ - AttributeStatement role -> admin │ │ - Conditions/Audience -> this SP │ └───────────────────┬───────────────────────┘ │ ▼ ┌───────────────────────────────────────────┐ │ 4. Replay the crafted SAMLResponse │ │ → SP verifies sig (vs original element) │ │ → SP extracts identity (vs forged elem) │ │ → session minted for arbitrary user │ └───────────────────────────────────────────┘

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:

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

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.

Find

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.

Prove

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.

Fix

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.

Verify

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

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 →