文章目录
本文展示了如何通过利用解析器层面的多种不一致性,在 Ruby 和 PHP 的 SAML 生态中实现完整的认证绕过。这些不一致性包括属性污染、命名空间混淆,以及一种新的“空规范化(Void Canonicalization)”攻击类别。通过这些技术,攻击者可以在仍向应用程序提交一个看似完全合法的 SAML 文档的情况下,彻底绕过 XML 签名校验。
摘要
安全断言标记语言(SAML 2.0)是一种复杂的认证标准,其构建基础是存在安全隐患且已经过时的 XML 技术。这些遗留基础使该协议长期以来难以维护,并在过去二十年中持续引发大量严重漏洞。
本文介绍了若干新型的签名包装(XSW)攻击类别,这些攻击能够在互联网上被广泛使用的开源 SAML 库中实现完全的认证绕过。
近期 SAML 漏洞数量的增加表明,安全的认证不可能偶然实现。要保障诸如 SAML 这样的协议安全,需要整个安全社区持续且协同的努力,而不仅仅是快速修补式的应对措施。

服务提供方发起的 SAML 流程
服务提供方发起(Service Provider-Initiated,SP-Initiated)的 SAML 流程是用户通过 SAML 进行身份认证时最常见的方式。当用户尝试访问服务提供方网站上的受保护资源时,该流程即被触发。由于用户尚未完成认证,服务提供方会生成一个 SAML 认证请求,并将用户重定向至身份提供方(Identity Provider,IdP)进行验证。
IdP 接收到该请求后,会对其合法性进行校验,随后生成一个包含数字签名断言(Assertion)的 SAML 响应(SAML Response),用于确认用户身份。该响应通过用户的浏览器返回给服务提供方(SP)。SP 随后验证数字签名,并从断言中提取用户信息(如用户名和电子邮箱)。如果签名和数据均有效,则授予用户访问权限。
XML 签名包装攻击(XSW)
该流程的整体安全性完全依赖于对 SAML 响应签名的校验方式。在许多实现中,签名验证与断言处理由不同的模块完成,甚至由不同的 XML 解析器负责。XML 签名包装攻击(XML Signature Wrapping,XSW)正是利用了这些组件之间的差异。
在典型场景下,攻击者拦截一个由可信身份提供方签名的合法 SAML 响应,并在同一文档中注入一个包含任意用户信息的恶意断言。当服务提供方处理该响应时,签名验证模块会正确验证消息中合法的部分,而 SAML 处理逻辑却错误地使用了攻击者注入的断言。结果是,攻击者伪造的数据被当作真实数据处理,从而导致权限提升。
Juraj Somorovsky 在其研究《On Breaking SAML: Be Whoever You Want to Be》中指出,这种攻击可以通过在 IdP 上注册账号、实施中间人攻击,甚至通过 Google dorking 搜索公开暴露的文件来实现。然而,这些前提条件要求极高。为任意网站获取一个有效签名的 SAML 断言极其困难。身份提供方几乎不会公开此类断言,即便攻击者设法捕获到一个,大多数服务提供方也只会接受一次,之后便会将其缓存并拒绝再次使用。
完整的认证绕过

因此,我们采取了一种不同的方法。与其尝试窃取或重用一个已签名的 Assertion,不如直接重用任何一个使用 IdP 私钥签名过的 XML 文档。
在拥有这一合法签名的前提下,我们便可以利用服务器存在缺陷的签名校验逻辑,使其误以为被签名的是我们构造的恶意 Assertion。
安全性的假象
在《SAML roulette: the hacker always wins》中,展示了如何利用文档类型声明(DTD)处理上的缺陷,对被广泛使用的 Ruby-SAML 库实施 XSW 攻击。为缓解这些问题,官方发布了两个安全补丁——1.12.4 和 1.18.0 版本。
在本文中,我以 Ruby-SAML 1.12.4 的补丁作为案例研究,说明为何渐进式修复并不足以解决问题;尽管多次尝试修补 XML 相关漏洞,其底层架构依然十分脆弱。
有缺陷的 XML 安全实现
1.12.4 安全补丁引入了两项新的检查,用以确保 SAML 文档中不包含 DTD,且是一个格式正确的 XML 文档。虽然这消除了我们最初的利用方式,但并未触及问题的根本原因。XML Security 库在验证流程的不同阶段仍然依赖两个不同的 XML 解析器——REXML 和 Nokogiri。
根据 SAML 规范,Assertion 元素,或其某个祖先元素,必须通过包裹式 XML 签名(enveloped XML Signature)被 Signature 元素所引用。
在 Ruby-SAML 的实现中,REXML 和 Nokogiri 均通过 XPath 查询 "//ds:Signature" 来定位 Signature 元素,该查询会返回文档中任意位置出现的第一个签名。随后,由 REXML 实现的附加逻辑会校验该签名的父元素是否为 Assertion。这个过于宽松的 XPath 查询成为漏洞利用中的关键组成部分。
XML 签名是一种两阶段的签名机制:被签名资源的哈希值(DigestValue)以及指向被签名元素的 URI 引用会存储在 Reference 元素中。包含这些引用的 SignedInfo 块随后会被整体签名,生成的 Base64 编码结果被放入 SignatureValue 元素。在 Ruby-SAML 的实现中,REXML 用于提取 DigestValue,并将其与通过 Nokogiri 处理并转换后的元素哈希值进行比较;同样由 REXML 提取的 SignatureValue,则需要与 Nokogiri 处理后的 SignedInfo 元素相匹配。这在两个 XML 处理方式不一致的解析器之间建立了一种脆弱的依赖关系。
属性污染
要构造一个可靠的利用,首先需要理解 XML 的一个基础特性——命名空间。XML 命名空间通过将元素和属性名与统一资源标识符(URI)关联,为其提供限定机制。
命名空间声明通过一类特殊的保留属性来定义。这类属性的名称必须要么正好是 xmlns(用于声明默认命名空间),要么以 xmlns: 前缀开头(用于定义带有特定前缀的命名空间)。例如:
<Response xmlns="urn:oasis:names:tc:SAML:2.0:protocol"/>
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
这两种形式都是合法的,并且都会将元素关联到相同的 SAML 2.0 Protocol 命名空间。
命名空间非常适合用于签名包装攻击,因为它们会直接影响 XPath 查询对 XML 元素的识别方式。大多数 SAML 库依赖 libxml2 进行 XML 解析,而该库继承了大量历史遗留的怪异行为。
libxml2 脆弱性的一个典型示例可以在 Hakim 的文章《Abusing libxml2 quirks to bypass SAML authentication on GitHub Enterprise (CVE-2025-23369)》中看到,该研究展示了如何滥用内部缓存行为以获得出乎意料的 XML 处理结果。不幸的是,由于 1.12.4 补丁已限制 Entity 和 Doctype,这一攻击向量已不再可行,从而迫使我们探索其他方式来利用解析不一致性。
一个有价值的线索直接来自 libxml2 文档中对 xmlGetProp 的说明:
This function looks in DTD attribute declarations for #FIXED or default declaration values.
NOTE: This function ignores namespaces. Use xmlGetNsProp or xmlGetNoNsProp for namespace-aware processing.
Ruby(Nokogiri)和 PHP 都暴露了 libxml2 的这些行为特性,从而可能导致签名验证与断言解析之间发生不同步。在 Nokogiri 中,诸如 node.attribute('ID')(而非 get_attribute)或简写形式 node['ID'] 的属性查找会忽略属性的命名空间,仅使用简单名称。当多个属性在简单名称上发生冲突(例如 ID 与 samlp:ID)时,只会返回其中一个,而文档并未保证返回哪一个。
在 PHP 的 DOM 中,DOMNamedNodeMap::getNamedItem 同样只通过简单名称获取属性。
这种歧义可以直接在解析器解析属性的方式中观察到。考虑以下两个看似等价的 XML 片段:
<samlp:Response ID="1" samlp:ID="2"> # 1
<samlp:Response samlp:ID="2" ID="1"> # 2
在第一种情况下,调用 xmlGetProp 返回 1,而在第二种情况下则返回 2。
这种差异完全取决于元素中属性的顺序,这是 libxml2 继承而来的行为。由于命名空间被忽略,且在存在重复属性时返回值是未定义的,开发者无法控制最终选中的是哪一个属性。
REXML 由于实现了独立于 libxml2 的 XML 解析逻辑,同样存在属性污染问题。attributes['ID'] 与 get_attribute("ID").value 在不同命名空间处理情况下会表现出不一致的行为。
<Response ID="1" samlp:ID="2"> # 1
<samlp:Response ID="1" samlp:ID="2"> # 2
在第一种情况下,通过 attributes['ID'] 访问属性返回 1,而在第二种情况下则返回 2。当存在命名空间前缀时,REXML 内部的查找逻辑会以不同方式处理属性名,导致其选择顺序与 libxml2 恰好相反。这种不一致性意味着,同一个 XML 文档在不同解析器中可能产生不同的属性值,使攻击者能够操纵究竟哪个元素被签名、哪个元素被实际处理:
<samlp:Response ID="attack" samlp:ID="ID">
<Signature>
<Reference URI="#ID"/>
</Signature>
<samlp:Extensions>
<Assertion ID="#ID"/>
</samlp:Extensions>
<Assertion ID="evil"/>
</samlp:Response>
攻击流程:
- 签名验证模块通过 XPath 查询
"//*[@ID='id']"定位 XML 签名的目标,该查询会忽略命名空间。 - 业务逻辑随后验证根元素的标识符是否与签名中引用的一致,其 ID 值通过不感知命名空间的属性获取方式取得(例如
element['ID']、getNamedItem('ID')或attributes['ID'])。
无 DTD 的 REXML 命名空间混淆
如前所述,xmlns 是一个保留属性,而 xml 是另一个保留前缀。二者均由 XML 规范定义,不能被重新声明或绑定到不同的值。
然而,在 REXML 中,这些内容在内部被当作普通属性处理。这一细微差异造成了一个严重的弱点。通过重新定义或注入命名空间声明,攻击者可以操纵命名空间感知型 XPath 查询的行为,从而使 REXML 解析并命中其他解析器(如 Nokogiri)能够正确忽略的元素:
<Signature xml:xmlns='http://www.w3.org/2000/09/xmldsig#'/>
该技术同样可以反向利用,使攻击者能够在保持文档合法性的前提下,将真实的 Signature 元素从 REXML 的 XPath 查询 "//ds:Signature" 中隐藏起来。通过精心构造元素嵌套并重新定义命名空间,可以使 Signature 节点对 Nokogiri 可见,而对 REXML 不可见:
<Parent xmlns='http://www.w3.org/2000/09/xmldsig#'>
<Child xml:xmlns='#anything'>
<Signature/>
</Child>
</Parent>
这使攻击者能够拆分签名检测逻辑,导致解析器在文档中一个非预期的位置定位并验证 Signature 元素。
XML Schema
在能够构造一个在 REXML 与 Nokogiri 中产生两种不同解析结果的合法 XML 文档之后,下一步便是确定在不违反 XML Schema 的前提下,可以在何处注入恶意元素。
XML Schema Definition(XSD)定义了所有基于 XML 编码的 SAML 协议消息的语法与语义。在 Ruby-SAML 中,随实现一同提供了十二个 XSD 文件,其中包括 protocol-schema.xsd,用于定义 SAML Response 中各个元素的结构与约束。
然而,仅依赖 XML Schema 校验并不能阻止恶意扩展的引入。所有已识别的扩展点的完整列表已在配套材料中给出。在这些扩展点中,有两个元素满足一个关键条件:它们可以在一个合法的 SAML Response 中出现在 Signature 元素之前——Extensions 元素和 StatusDetail 元素。本文将使用 Extensions 元素:
<samlp:Response>
<samlp:Extensions>
<Parent xmlns="http://www.w3.org/2000/09/xmldsig#">
<Child xml:xmlns="#other">
<Signature>
<SignedInfo>REAL SIGNATURE</SignedInfo>
</Signature>
</Child>
</Parent>
</samlp:Extensions>
<Assertion>
<Signature>
<SignedInfo>FAKE SIGNATURE</SignedInfo>
</Signature>
</Assertion>
</samlp:Response>
不可能的 XSW
在这一阶段,我们已经可以成功绕过 SignatureValue 的校验,但流程仍会在 DigestValue 校验处失败。原因在于 Nokogiri 对规范化(canonicalization)和摘要计算的处理方式。在计算摘要时,解析器会临时移除 Signature 元素,然后再计算哈希值,以确保签名本身不会被包含在被签名的数据中。
然而,在我们修改后的文档中,伪造的 Signature 元素仍然位于 Assertion 内部,这意味着解析器现在尝试对一个已经包含签名数据本身的字符串计算摘要。这会产生一种递归依赖关系——摘要必须包含其自身的哈希值。在这种情况下,想要得到一个有效的 DigestValue,理论上需要生成一次完美的哈希碰撞。
空规范化(Void Canonicalization)技术
为了解决这一看似不可能的问题,我们需要再次仔细审视 SAML 规范。根据标准,被引用的元素在计算哈希之前必须经过一个或多个 XML 转换(transformation)处理。通过针对这一转换阶段下手,我们引入了一类新的攻击方式——我称之为空规范化(Void Canonicalization)。
规范化定义了一种统一表示 XML 文档的方法,通过标准化属性顺序、空白字符、命名空间声明以及字符编码等细节,确保两个逻辑上等价的 XML 文档能够生成相同的规范化字节流,从而实现可靠的数字签名与比较。
规范化过程中的某些方面——例如是否包含或排除 XML 注释——已经在此前的签名包装(XSW)攻击中被利用过(如 SAMLStorm: Critical Authentication Bypass in xml-crypto and Node.js libraries)。然而,除了这些已知向量之外,规范化过程本身还存在更深层次的限制,同样可以被滥用。
我们来看一下 XML Signature Recommendation,其中明确警告了相对 URI 带来的风险:
限制:相对 URI 在规范化形式中将无法生效。
处理过程 SHOULD 创建一个新文档,在该文档中将相对 URI 转换为绝对 URI,从而降低新文档中的任何安全风险。
这种行为引入了一个机会点:如果规范化过程遇到某种限制,例如无法解析的相对 URI,它可能会返回错误,而不是输出规范化后的字符串。对攻击者而言,有利的是,只有极少数 XML 解析器被设计为能够正确处理这类失败。大多数实现会在静默中继续执行,将缺失的输出视为空值或“空”的规范化结果,从而有效地跳过本应被纳入摘要计算的数据。这种强大的不一致性,正是空规范化攻击这一新攻击类别的基础。
黄金 SAML 响应
为了演示这一行为,考虑以下利用了规范化漏洞的 SAML 响应:
<samlp:Response xmlns:ns="1">
<samlp:Extensions>
<Parent xmlns="http://www.w3.org/2000/09/xmldsig#">
<Child xml:xmlns="#other">
<Signature>
<SignedInfo>REAL SIGNATURE</SignedInfo>
</Signature>
</Child>
</Parent>
</samlp:Extensions>
<Assertion>
<Signature>
<SignedInfo>EMPTY STRING DIGEST VALUE</SignedInfo>
</Signature>
</Assertion>
</samlp:Response>
在这里,xmlns:ns="1" 声明定义了一个相对命名空间 URI。虽然这是一个格式正确的 XML 文档,但这会导致 libxml2 在规范化过程中出错。
而 Nokogiri 在处理规范化时并不会以安全的方式失败,而是简单地返回一个空字符串。当这种错误发生时,随后的 DigestValue 计算会在一个空输入上执行,从而生成一个有效的哈希值(SHA-256 的哈希值为 47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=)。
如果恶意用户能够访问空字符串的 SignatureValue,这一行为也可以被利用。因为规范化后的 SignedInfo 哈希值生成了最终的 SignatureValue,任何持有空字符串预先计算签名的攻击者,都可以利用这一点对任意 SAML 响应消息创建完全有效的签名。
另外,libxml2 规范化逻辑的另一个漏洞可见于我之前在 SAML Raider 仓库中对 CVE-2025-25292 的利用。遗憾的是,这种方法现在无法使用,因为它不是格式良好的 XML。
ruby-saml 1.12.4 和 php-saml 库存在规范化漏洞,其他 PHP XMLDSig 实现(如 Rob Richards 的 xmlseclibs)也受到影响。相比之下,XMLSec Library 和 Shibboleth xmlsectool 则没有此类漏洞。
获取有效签名
即使恶意用户无法直接访问已签名的 SAML Assertion,这并不意味着没有有效的、由 IdP 签名的 XML 文档可以公开获取。几种类型的合法签名数据可以被重新利用进行攻击。
最直接的来源是 SAML 元数据。遗憾的是,这些文件很少是签名的,但在某些情况下,通过在元数据 URL 后附加参数如 ?sign=true,可以获取到签名版的元数据。
另一个可靠的来源是已签名的错误响应。根据 SAML 规范,Request Abstract Type 只需要三个属性:ID、Version 和 IssueInstant。这些属性构成了一个有效 SAML 请求消息的最小结构。SAML Core 2.0 规范中定义了:
如果 SAML 响应者根据 SAML 语法或处理规则认为请求无效,则如果它回应,必须返回一个 SAML 响应消息。
这意味着,即使请求是格式错误或语法无效,身份提供方(IdP)仍然可以签名并返回一个错误响应来表示失败。下方是一个无效的 AuthnRequest 示例:
<samlp:AuthnRequest
ID="€"
IssueInstant="INVALID"
Version="INVALID">
</samlp:AuthnRequest>
如果反射的错误内容触发了规范化错误,导致摘要计算基于一个空字符串,那么一个已签名的错误消息也可以成为一个空签名的源头。
最终利用
最后,Web 服务联盟(Web Services Federation)元数据几乎总是公开可用,尤其是主要身份提供商的元数据。这些文档为攻击者提供了一个方便且合法的方式来获取有效的签名元素,即便 XML 并不完全符合 SAML 模式。
将所有部分结合起来:
- 从扩展点提取已封装的签名
- 保留的 xml 属性命名空间声明将 Signature 元素从 SAML 处理模块中隐藏,但仍保留用于数字签名
- 伪造的签名节点保持在 Assertion 元素内,但保持空字符串的 Digest 值
- 最终,空规范化会抛出一个未处理的异常,从而绕过哈希限制
真实使用场景
在一个大型 SaaS 的真实场景中(无法透露具体细节),我们结合了 Ruby-SAML 漏洞和 Gareth Heyes 的研究《Splitting the Email Atom: Exploiting Parsers to Bypass Access Controls》,生成了伪造的 SAML 响应,创建了一个新账户,并最终绕过了认证。
防御
为了缓解本研究中描述的风险,实施或维护 SAML 认证系统时应采用以下最佳实践:
- 使用严格的 XML 模式,最小化或避免扩展点。
- 确保仅使用已签名的元素进行任何后续处理。
- 保持所有 SAML 和 XML 安全库的最新版本,应用最新的安全补丁和版本更新。
- 避免使用电子邮件域后缀作为访问控制的方式,因为解析器差异可以被利用来绕过这些限制。
时间线
- 2025年4月29日 - Ruby-SAML 1.12.4 漏洞的详细信息已与维护者共享。
- 2025年8月27日 - Ruby-SAML 和 PHP-SAML 空规范化(libxml2)漏洞已披露给维护者。
- 2025年10月10日 - Rob Richards 的 xmlseclibs 中的 libxml2 漏洞已报告给维护者。
- 2025年12月8日 - Rob Richards 的 xmlseclibs 发布了版本 3.1.4,修复了 libxml2 规范化漏洞。
- 2025年12月8日 - Ruby-SAML 维护者发布公告,解决了影响所有 1.18.0 版本之前(包括 1.12.4)的 CVE-2025-66568 和 CVE-2025-66567。
结论
可靠的认证安全不能依赖于不受支持或维护不善的库。全面和持久的修复需要对现有 SAML 库进行重构。此类改动可能引入破坏兼容性的问题或回归,但这些变更对确保 XML 解析、签名验证和规范化逻辑的健壮性至关重要。没有这些基础性的重构,SAML 认证将继续面临几乎二十年来一直存在的相同攻击类别的威胁。
508

被折叠的 条评论
为什么被折叠?



