How an XML parser mismatch opens a SAML authentication bypass

Two critical vulnerabilities — CVE-2025-25291 and CVE-2025-25292 — were found in ruby-saml up to version 1.17.0, a widely used library for SAML single sign-on on the service provider side. An attacker with access to any valid signature created with the key used to validate SAML responses or assertions for a given organization can forge their own assertions and log in as any user. The fix is in version 1.18.0; downstream libraries such as omniauth-saml must also be updated to versions that reference the patched release.

GitHub's Security Lab discovered the flaw during an evaluation of the library, prompted by an earlier authentication bypass (CVE-2024-45409) reported in October 2024. A researcher participating in GitHub's private bug bounty program, ahacker1, identified the same root cause independently. GitLab was found to be running an exploitable instance of the vulnerable code and was notified.

Two parsers, one verification path

During code review of the validate_signature method in xml_security.rb, the critical observation was that ruby-saml uses two different XML parsers in the same signature verification flow: REXML, a pure-Ruby parser, and Nokogiri, a wrapper around native libraries like libxml2 and Xerces. Nokogiri had been added to support XML canonicalization that REXML couldn't handle.

The same signature element is first located with REXML, then parsed again with Nokogiri for different steps of the verification. The suspicion was immediately clear: if the two parsers could be made to disagree about which element matches the same XPath query, ruby-saml might verify a signature that wasn't the one actually attached to the assertion — a parser differential.

A simplified SAML response follows this structure (namespaces removed for readability):

A diagram depicting a simplified SAML response on the left and the verification of the digest and the signature on the right.

The assertion element carries the user identity — typically a Subject element containing a NameID. The Assertion element (excluding the Signature portion) is canonicalized and compared against the DigestValue. Separately, the SignedInfo element is canonicalized and verified against the SignatureValue. Either the whole SAML response or just the embedded assertion may be signed.

The verification flow, step by step

Reading through the code path in validate_signature:

1. REXML extracts the first Signature element via a broad XPath query:

sig_element = REXML::XPath.first(
  @working_copy,
  "//ds:Signature",
  {"ds"=>DSIG}
)

2. From that element, REXML reads the SignatureValue node. The SignatureValue holds the actual cryptographic signature, while the companion SignedInfo node specifies what was signed, including the digest of the referenced element and key information. A typical (namespace-stripped) Signature element looks like:

<Signature>
    <SignedInfo>
        <CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
        <SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" />
        <Reference URI="#_SAMEID">
            <Transforms><Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" /></Transforms>
            <DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />
            <DigestValue>Su4v[..]</DigestValue>
        </Reference>
    </SignedInfo>
    <SignatureValue>L8/i[..]</SignatureValue>
    <KeyInfo>
        <X509Data>
            <X509Certificate>MIID[..]</X509Certificate>
        </X509Data>
    </KeyInfo>
</Signature>

3. The signature(s) are queried again, this time with Nokogiri:

noko_sig_element = document.at_xpath('//ds:Signature', 'ds' => DSIG)

4. The SignedInfo element from this Nokogiri query is canonicalized into a canon_string:

noko_signed_info_element = noko_sig_element.at_xpath('./ds:SignedInfo', 'ds' => DSIG)

canon_string = noko_signed_info_element.canonicalize(canon_algorithm)

5. REXML separately extracts SignedInfo from the document:

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

6. The Reference node inside this REXML-extracted SignedInfo is read, yielding the target of the digest:

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

7. The referenced element — matched by its ID attribute — is located with Nokogiri. The ID itself is extracted with REXML via extract_signed_element_id. Following the previous CVE-2024-45409 fix, there is a check ensuring only one element with a given ID exists:

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

8. The first of the located reference nodes is canonicalized, then hashed:

hashed_element = reference_nodes[0][..]canon_hashed_element = hashed_element.canonicalize(canon_algorithm, inclusive_namespaces)
hash = digest_algorithm.digest(canon_hashed_element)

9. REXML extracts the DigestValue to compare against the calculated hash:

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

10. The framework compares the hash of the Nokogiri-extracted element with the REXML-extracted digest:

unless digests_match?(hash, digest_value)

11. Finally, the canon_string produced earlier from Nokogiri's SignedInfo is verified against the REXML-extracted signature:

unless cert.public_key.verify(signature_algorithm.new, signature, canon_string)

The resulting architecture leaves two distinct parser boundaries:

  1. The assertion is located and canonicalized with Nokogiri, then hashed and compared against a digest that came from a REXML extraction.
  2. The SignedInfo is also canonicalized by Nokogiri and validated against a SignatureValue extracted by REXML.

Any way to make REXML and Nokogiri resolve to different elements for the same logical query breaks the integrity of that comparison. Finding such a differential in the two parsers — and building a working exploit chain from it — is what turns this code smell into a full account-takeover vulnerability.

Weaving Two XML Views Into One Authentication Bypass

The practical question that follows from the parser mismatch is whether an attacker can craft an XML document where REXML and Nokogiri genuinely disagree about the signature contents. The answer, confirmed by two independent researchers during the bug bounty, is yes.

One exploit, produced by ahacker1, was inspired by XML roundtrip vulnerabilities that Mattermost's Juho Forsén published in 2021. A second, distinct exploit was later developed with the assistance of Trail of Bits' coverage-guided Ruby fuzzer, ruzzy. Both achieve the same end: an authentication bypass.

The impact is severe. An attacker holding a single valid signature created with the organization's SAML key can forge assertions for any user. That signature could be obtained from a signed assertion or response belonging to any unprivileged user. In some configurations, it might even be extracted from publicly accessible signed metadata of a SAML identity provider.

The following example shows how this can be done. An additional Signature element has been inserted within the StatusDetail element, visible only to Nokogiri:

A diagram depicting a simplified SAML response on the left and the verification of the digest and the signature on the right. For both the signature and the digest verification one part is extracted using Nokogiri and the other using REXML.

In this constructed document, the verification steps become disconnected:

  • The SignedInfo element (A), which Nokogiri sees, is canonicalized and verified against the SignatureValue (B) extracted by REXML. This check passes.
  • The assertion is located by Nokogiri using its ID, then canonicalized and hashed (C). This hash is compared against the DigestValue (D) that REXML extracted. This check also passes, but the DigestValue itself is not covered by any valid signature.

Because both independent checks succeed, an attacker with a valid signed assertion for one user can fabricate assertions for any other, enabling full impersonation.

A Partial Mitigation: Checking for Parse Errors

Some of the currently undisclosed exploit variants can be blocked by checking for Nokogiri parsing errors on SAML responses. These errors do not raise exceptions; they must be inspected on the errors member of the parsed document:

doc = Nokogiri::XML(xml) do |config|
  config.options = Nokogiri::XML::ParseOptions::STRICT | Nokogiri::XML::ParseOptions::NONET
end

raise "XML errors when parsing: " + doc.errors.to_s if doc.errors.any?

This is not a complete solution, but it does make at least one known exploit path infeasible.

What to Look For

There are no known reliable indicators of compromise. One potential indicator was identified, but it only functions in debug-like environments, and publishing it would reveal too much about how to build a working exploit. The most practical advice is to monitor SAML login logs for sessions originating from IP addresses that are inconsistent with the user's expected location.

The Inherent Confusion of SAML and XML Signatures

If integrating SAML is difficult, writing a secure implementation of it that uses XML signatures is far harder. Some commentators have suggested that the most pragmatic path is to disregard parts of the specification, as even strict adherence does not guarantee a secure implementation.

Consider the structure of a SAML response when the assertion itself is signed:

A diagram showing a SAML response and its parts: the Assertion containing the Signature and the Signature containing the SignedInfo of which the DigestValue is a part.

The critical confusion arises from what is actually signed. The signature does not cover the entire assertion. Instead, it signs the SignedInfo element. Within that SignedInfo lies a DigestValue, which is the hash of the canonicalized assertion (with the signature element removed first). This two-stage process—hash verification and signature verification—creates an opportunity for implementations to lose the connection between the two.

In the ruby-saml case, the library extracted SignedInfo and correctly used it to verify the signature of its canonicalized string, canon_string. The vulnerability existed because it did not reuse that same extracted SignedInfo to obtain the digest value. If it had, the attack would have failed even with the two different parsers in play. The hash and the signature each validated on their own, but they had no relationship to each other. What's needed is a direct, unbroken chain from the hashed content to the hash and finally to the signature, and data should only be read from the exact portion of the document that was verified.

Lessons and Next Steps

This case underscores that using two different XML parsers in a security-sensitive path is inherently risky. However, a parser differential alone doesn't guarantee exploitability. A more robust defense-in-depth approach—such as checking for Nokogiri errors—can stop at least some practical attacks.

The initial fix for the authentication bypasses did not remove either XML parser, primarily to preserve API compatibility. The more fundamental issue—the disconnect between hash and signature verification—was the root cause that made the parser differential exploitable. Removing one of the XML parsers had been planned independently and is expected to land in a future major release along with other hardening measures.

Users of the ruby-saml library should update to version 1.18.0 immediately, which addresses CVE-2025-25291 and CVE-2025-25292. Any library that depends on ruby-saml, such as omniauth-saml, must also be updated to a version that references the fixed release. A proof-of-concept exploit will be published later in the GitHub Security Lab repository.

Acknowledgments

Thanks to Sixto Martín, maintainer of ruby-saml, and Jeff Guerra from the GitHub Bug Bounty program, as well as ahacker1 for contributions to this research.

Timeline

  • 2024-11-04: Bug bounty report of an authentication bypass filed against a GitHub test environment using ruby-saml.
  • 2024-11-04: Work begins to identify and test potential mitigations.
  • 2024-11-12: A second authentication bypass is found, invalidating the planned mitigations for the first.
  • 2024-11-13: Initial contact made with the ruby-saml maintainer.
  • 2024-11-14: Both parser differentials are reported to the maintainer, who responds immediately.
  • 2024-11-14: Patch work begins; removing one XML parser is deemed infeasible without breaking backwards compatibility.
  • 2025-02-04: A non-backwards compatible fix is proposed by ahacker1.
  • 2025-02-06: A backwards compatible fix is also proposed.
  • 2025-02-12: The 90-day disclosure deadline for the GitHub Security Lab advisories is reached.
  • 2025-02-16: The maintainer begins work on a fix aiming for both backwards compatibility and clarity.
  • 2025-02-17: GitLab is contacted to coordinate a release of their on-premises product with the ruby-saml fix.
  • 2025-03-12: The fixed version of ruby-saml is released.