Gregor Samsa:利用Java的XML签名验证

访问原始链接 Google 翻译

作者:Felix Wilhelm,Project Zero

今年早些时候,我在Java标准库深处发现了一个令人惊讶的攻击面:一个处理不受信任XSLT程序的自定义JIT编译器,在XML签名验证期间暴露给远程攻击者。本文讨论的是CVE-2022-34169,这是该JIT编译器中的一个整数截断漏洞,导致许多基于Java的Web应用程序和支持SAML单点登录标准的身份提供商存在任意代码执行风险。

OpenJDK已于2022年7月修复了该问题。漏洞代码来源Xalan-J使用的Apache BCEL项目也在2022年9月发布了补丁。

虽然本文讨论的漏洞已修复,但供应商和用户应预期SAML中还会存在更多漏洞。

从安全研究人员的角度来看,这个漏洞是内存安全语言中整数截断问题的一个例子,其利用方式非常类似于内存损坏。虽然不如C或C++代码库中典型的内存安全问题常见,但内存安全语言中仍然存在"怪异机器",即使我们进入光明的内存安全未来,这些问题仍将困扰我们。

在深入探讨漏洞及其利用之前,我先简要概述XML签名和SAML。是什么让XML签名成为如此有趣的目标?我们为什么要关注它们?

引言

XML签名是21世纪初发明的安全协议的典型例子。它们存在高复杂性、大攻击面以及大量可配置功能等问题,这些功能可能以令人惊讶的方式削弱或破坏其安全保证。XML签名的现代使用主要局限于某些晦涩的协议和遗留应用程序,但有一个重要的例外:SAML。SAML(安全断言标记语言)是现代Web应用程序中使用的两个主要单点登录标准之一。虽然其替代方案——基于OAuth的OpenID Connect(OIDC)正日益流行,但SAML仍然是大型企业和复杂集成的实际标准。

SAML依赖XML签名来保护通过浏览器转发的消息。这使得XML签名验证成为攻击现代多租户SaaS应用程序的一个非常有趣的外部攻击面。虽然理解本文不需要详细了解SAML,但感兴趣的读者可以查看Okta的理解SAML文章或SAML 2.0维基条目以更好地理解该协议。

SAML SSO登录通过在应用程序(称为服务提供商SP)和身份提供商(IdP)之间交换XML文档来工作。当用户尝试登录SP时,服务提供商会创建SAML请求。IdP查看SAML请求,尝试对用户进行身份验证,并将SAML响应发送回SP。成功的响应将包含有关用户的信息,应用程序随后可以使用这些信息授予对其资源的访问权限。

在最广泛使用的SAML流程(称为SP重定向绑定/IdP POST响应)中,这些文档通过用户的浏览器使用HTTP重定向和POST请求转发。为了防止用户修改,SAML响应的安全关键部分(称为断言)必须由IdP进行加密签名。此外,IdP可能要求SP也对SAML请求进行签名,以防止冒充攻击。

这意味着IdP和SP都必须解析和验证潜在恶意行为者传递给它们的XML签名。为什么这是个问题?让我们仔细看看XML签名的工作方式:

XML签名

大多数签名方案对原始字节流进行操作,并对网络传输的数据进行签名。相反,XML签名标准(称为XMLDsig)试图对签名XML文档的不重要更改具有鲁棒性。这意味着更改签名文档中的空格、行尾或注释不应使其签名失效。

XML签名由一个特殊的Signature元素组成,示例如下:

…

9bc34549d565d9505b287de0cd20ac77be1d3f2c

....

....

  • SignedInfo子元素包含CanonicalizationMethod和SignatureMethod元素,以及一个或多个描述完整性保护数据的Reference元素。
  • KeyInfo描述签名者密钥,可以包含原始公钥、X509证书或仅密钥ID。
  • SignatureValue包含使用CanonicalizationMethod规范化后,使用SignatureMethod对SignedInfo元素进行的加密签名。

此时,只有SignedInfo元素的完整性受到保护。要了解如何将此保护扩展到实际数据,我们需要查看Reference元素的工作方式:理论上,Reference URI属性可以指向外部文档(分离签名)、作为子元素嵌入的元素(封装签名)或外部文档中的任何元素(被封装签名)。实际上,大多数SAML实现使用被封装签名,Reference URI将指向当前文档树中某处的签名元素。

在验证或签名期间处理Reference时,引用的内容会通过一系列Transforms传递。XMLDsig支持多种转换,从规范化、base64解码到XPath甚至XSLT。处理完所有转换后,生成的字节流将传递到DigestMethod元素指定的加密哈希函数中,结果存储在DigestValue中。

这样,由于整个Reference元素是SignedInfo的一部分,其完整性保护也扩展到引用的元素。

因此,验证XML签名可以分为两个独立的步骤:

  • 引用验证:遍历所有嵌入的引用,对于每个引用,获取引用的数据,通过Transforms链处理,并计算其哈希摘要。将计算的摘要与存储的DigestValue进行比较,如果不同则失败。
  • 签名验证:首先使用指定的CanonicalizationMethod算法规范化SignedInfo元素。使用SignatureMethod中指定的算法和KeyInfo中描述的签名者密钥计算SignedInfo的签名。将结果与SignatureValue进行比较,如果不同则失败。

有趣的是,这两个步骤的顺序可能是特定于实现的。虽然XMLDsig RFC将引用验证列为第一步,但首先执行签名验证可能具有安全优势,我们将在后面看到。

在SAML的上下文中,正确验证XML签名并确保我们关心的数据受到保护非常困难。这将是后续博客文章的主题,但此时我们想专注于引用验证步骤:

作为此步骤的一部分,验证签名的应用程序必须在攻击者控制的输入上运行攻击者控制的转换。查看XMLDsig支持的转换列表,有一个看起来特别有趣:XSLT。

XSLT(可扩展样式表语言转换)是一种功能丰富的基于XML的编程语言,设计用于转换XML文档。在XML签名中嵌入XSLT转换意味着验证器必须在引用的XML数据上运行XSLT程序。

下面的代码片段展示了一个简单的XSLT转换示例。执行时,它会获取存储在内的每个元素,抓取其内容的第一个字符,并将其作为的一部分返回。因此abcdef将被转换为ad

<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"

xmlns="http://www.w3.org/TR/xhtml1/strict" exclude-result-prefixes="foo"

version="1.0">

<xsl:output encoding="UTF-8" indent="no" method="xml" />

<xsl:template match="/input">

<xsl:for-each select="data">

<xsl:value-of select="substring(.,1,1)" />

将功能齐全的语言运行时暴露给外部攻击者似乎是个坏主意,那么让我们看看这个功能在Java的OpenJDK中是如何实现的。

Java中的XSLT

Java处理XML签名的主要接口是java.xml.crypto.XMLSignature类及其sign和validate方法。我们主要对validate方法感兴趣,如下所示:

// https://github.com/openjdk/jdk/blob/master/src/java.xml.crypto/share/classes/org/jcp/xml/dsig/internal/dom/DOMXMLSignature.java

@Override

public boolean validate(XMLValidateContext vc)

throws XMLSignatureException

{

[..]

// 验证签名

boolean sigValidity = sv.validate(vc); (A)

if (!sigValidity) {

validationStatus = false;

validated = true;

return validationStatus;

}

// 验证所有引用

@SuppressWarnings("unchecked")

List refs = this.si.getReferences();

boolean validateRefs = true;

for (int i = 0, size = refs.size(); validateRefs && i < size; i++) {

Reference ref = refs.get(i);

boolean refValid = ref.validate(vc); (B)

LOG.debug("Reference [{}] is valid: {}", ref.getURI(), refValid);

validateRefs &= refValid;

}

if (!validateRefs) {

LOG.debug("无法验证引用");

validationStatus = false;

validated = true;

return validationStatus;

}

[..]

}

正如我们所见,validate方法首先在(A)处验证SignedInfo元素的签名,然后在(B)处验证所有引用。这意味着针对XSLT运行时的攻击将需要SignedInfo元素的有效签名,我们稍后将讨论攻击者如何绕过此要求。

(B)处对ref.validate()的调用最终会进入如下所示的DomReference.validate方法:

public boolean validate(XMLValidateContext validateContext)

throws XMLSignatureException

{

if (validateContext == null) {

throw new NullPointerException("validateContext cannot be null");

}

if (validated) {

return validationStatus;

}

Data data = dereference(validateContext); (D)

calcDigestValue = transform(data, validateContext); (E)

[..]

validationStatus = Arrays.equals(digestValue, calcDigestValue); (F)

validated = true;

return validationStatus;

}

代码在(D)处获取引用的数据,在(E)处转换它,并在(F)处将结果的摘要与签名中存储的摘要进行比较。大部分复杂性隐藏在(E)处的transform调用背后,该调用遍历Reference中定义的所有Transform元素并执行它们。

由于这是Java,在到达有趣的部分之前,我们必须经过许多间接层。如果你想跟随并单步执行代码,请查看下面的调用栈:

at com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.newTransformer(TemplatesImpl.java:584) at com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl.newTransformer(TransformerFactoryImpl.java:818) at com.sun.org.apache.xml.internal.security.transforms.implementations.TransformXSLT.enginePerformTransform(TransformXSLT.java:130) at com.sun.org.apache.xml.internal.security.transforms.Transform.performTransform(Transform.java:316) at org.jcp.xml.dsig.internal.dom.ApacheTransform.transformIt(ApacheTransform.java:188) at org.jcp.xml.dsig.internal.dom.ApacheTransform.transform(ApacheTransform.java:124) at org.jcp.xml.dsig.internal.dom.DOMTransform.transform(DOMTransform.java:173) at org.jcp.xml.dsig.internal.dom.DOMReference.transform(DOMReference.java:457) at org.jcp.xml.dsig.internal.dom.DOMReference.validate(DOMReference.java:387) at org.jcp.xml.dsig.internal.dom.DOMXMLSignature.validate(DOMXMLSignature.java:281)

如果我们指定XSLT转换且未启用org.jcp.xml.dsig.secureValidation属性(我们稍后会回到这一点),我们将最终进入文件src/java.xml/share/classes/com/sun/org/apache/xalan/internal/xsltc/trax/TransformerFactoryImpl.java,该文件是名为XSLTC的模块的一部分。

XSLTC(XSLT编译器)最初是Apache Xalan项目的一部分。OpenJDK分叉了基于Java的XSLT运行时Xalan-J,以提供XSLT支持作为Java标准库的一部分。虽然原始Apache项目有许多OpenJDK分叉不支持的功能,但大部分核心代码是相同的,CVE-2022-34169影响了两个代码库。

XSLTC负责将XSLT样式表编译成Java类,以提高性能,与基于解释的方法相比。虽然这在大量数据上重复运行相同样式表时具有优势,但在XML签名验证的上下文中,这是一个有些令人惊讶的选择。从攻击者的角度思考,我们现在可以向功能齐全的JIT编译器提供任意输入。这真是一个意想不到的攻击面!

XSLTC中的漏洞

/**

  • 当格里高尔·萨姆沙从不安的睡梦中醒来时,他发现自己

  • 在床上变成了一只巨大的昆虫。他仰卧着,那坚硬的

  • 像铁甲一般的背贴着床,他稍稍抬了抬头,

  • 看见自己那穹顶似的棕色肚子分成了好多块弧形的硬片,

  • 被子几乎盖不住肚子尖,都快滑下来了。比起偌大的身躯来,

  • 他那许多只腿真是细得可怜,都在他眼前无可奈何地舞动着。

  • "我出了什么事啦?"他想。这可不是梦……

*/

protected final static String DEFAULT_TRANSLET_NAME = "GregorSamsa";

事实证明,该代码库的作者是卡夫卡的粉丝。

那么,这个编译过程是什么样的呢?XSLTC将XSLT样式表作为输入,并返回一个JIT编译的Java类(称为translet)作为输出。然后JVM加载这个类,构造它,XSLT运行时通过JIT编译的方法执行转换。

Java类文件包含所有类方法的JVM字节码、一个描述所有使用常量的所谓常量池,以及其他重要的运行时细节,如其超类名称或访问标志。

// https://docs.oracle.com/javase/specs/jvms/se18/html/jvms-4.html

ClassFile {

u4             magic;

u2             minor_version;

u2             major_version;

u2             constant_pool_count;

cp_info        constant_pool[constant_pool_count-1]; // cp_info是可变大小的对象

u2             access_flags;

u2             this_class;

u2             super_class;

u2             interfaces_count;

u2             interfaces[interfaces_count];

u2             fields_count;

field_info     fields[fields_count];

u2             methods_count;

method_info    methods[methods_count];

u2             attributes_count;

attribute_info attributes[attributes_count];

}

XSLTC依赖Apache字节码工程库(BCEL)动态创建Java类文件。作为编译过程的一部分,XSLT输入中的常量(如字符串或数字)被转换为Java常量,然后存储在常量池中。以下代码片段显示了如何编译XSLT整数表达式:适合字节或短整型的小整数使用bipush或sipush指令内联存储在字节码中。较大的整数使用cp.addInteger方法添加到常量池中:

// org/apache/xalan/xsltc/compiler/IntExpr.java

public void translate(ClassGenerator classGen, MethodGenerator methodGen) {

ConstantPoolGen cpg = classGen.getConstantPool();

InstructionList il = methodGen.getInstructionList();

il.append(new PUSH(cpg, _value));

}

// org/apache/bcel/internal/generic/PUSH.java

public PUSH(final ConstantPoolGen cp, final int value) {

if ((value >= -1) && (value <= 5)) {

instruction = InstructionConst.getInstruction(Const.ICONST_0 + value);

} else if (Instruction.isValidByte(value)) {

instruction = new BIPUSH((byte) value);

} else if (Instruction.isValidShort(value)) {

instruction = new SIPUSH((short) value);

} else {

instruction = new LDC(cp.addInteger(value));

}

}

这种方法的问题在于,XSLTC和BCEL都没有正确限制常量池的大小。由于描述常量池大小的constant_pool_count只有2字节长,其最大大小限制为2**16 - 1或65535个条目。实际上,甚至更少的条目是可能的,因为某些常量类型占用两个条目。然而,BCEL的内部常量池表示使用标准Java数组存储常量,并且不对其长度强制执行任何限制。

当XSLTC处理包含更多常量的样式表,并且在编译过程结束时将BCEL的内部类表示序列化为类文件时,数组长度被截断为短整型,但完整的数组被写出:

这意味着constant_pool_count现在将包含一个较小的值,并且攻击者控制的常量池部分将被解释为常量池之后的类字段,包括方法和属性定义。

利用常量池溢出

要理解我们如何利用这一点,我们首先需要仔细查看常量池的内容。池中的每个条目都以一个1字节的标签开始,描述常量的类型,后跟实际数据。下表显示了JVM支持的不完整常量类型列表(完整列表请参阅官方文档)。不需要全部阅读,但在遍历利用过程时,我们会经常回到这个表格。

常量类型 标签 描述 布局
CONSTANT_Utf8 1 可变大小的UTF-8字符串值 CONSTANT_Utf8_info {
u1 tag;
u2 length;
u1 bytes[length];
}
CONSTANT_Integer 3 4字节整数 CONSTANT_Integer_info {
u1 tag;
u4 bytes;
}
CONSTANT_Float 4 4字节浮点数 CONSTANT_Float_info {
u1 tag;
u4 bytes;
}
CONSTANT_Long 5 8字节长整型 CONSTANT_Long_info {
u1 tag;
u4 high_bytes;
u4 low_bytes;
}
CONSTANT_Double 6 8字节双精度浮点数 CONSTANT_Double_info {
u1 tag;
u4 high_bytes;
u4 low_bytes;
}
CONSTANT_Class 7 对类的引用。链接到描述名称的Utf8常量。 CONSTANT_Class_info {
u1 tag;
u2 name_index;
}
CONSTANT_String 8 JVM"字符串"。链接到UTF8常量。 CONSTANT_String_info {
u1 tag;
u2 string_index;
}
CONSTANT_Fieldref
CONSTANT_Methodref
CONSTANT_InterfaceMethodref
9
10
11
对类字段或方法的引用。链接到类常量 CONSTANT_Fieldref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
CONSTANT_Methodref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
CONSTANT_InterfaceMethodref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
CONSTANT_NameAndType 12 描述字段或方法。 CONSTANT_NameAndType_info {
u1 tag;
u2 name_index;
u2 descriptor_index;
}
CONSTANT_MethodHandle 15 描述方法句柄。 CONSTANT_MethodHandle_info {
u1 tag;
u1 reference_kind;
u2 reference_index;
}
CONSTANT_MethodType 16 描述方法类型。指向包含方法描述符的UTF8常量。 CONSTANT_MethodType_info {
u1 tag;
u2 descriptor_index;
}

利用CVE-2022-34169的完美常量类型应该是动态大小的,包含完全由攻击者控制的内容。不幸的是,这样的类型并不存在。虽然CONSTANT_Utf8是动态大小的,但其内容不是原始字符串表示,而是JVM称为"修改后的UTF-8"的编码格式。这种编码对存储的数据引入了一些重要限制,并排除了空字节,使其在破坏类字段方面大多无用。

我们能得到的次优选择是具有完全内容控制的固定大小常量类型。CONSTANT_Long似乎是一个明显的候选者,但XSLTC在编译过程中从不创建攻击者控制的长整型常量。相反,我们可以使用大浮点数创建具有(几乎)完全控制内容的CONSTANT_Double条目。这为我们提供了一个很好的原语,可以用类似0x06 0xXX 0xXX 0xXX 0xXX 0xXX 0xXX 0xXX 0xXX 0x06 0xYY 0xYY 0xYY 0xYY 0xYY 0xYY 0xYY 0xYY 0x06 0xZZ 0xZZ 0xZZ 0xZZ 0xZZ 0xZZ 0xZZ 0xZZ的字节模式破坏常量池后面的类字段。

不幸的是,仅凭这个原语不足以制作有用的类文件,因为常量池之后的字段有要求:

cp_info        constant_pool[constant_pool_count-1];

u2             access_flags;

u2             this_class;

u2             super_class;

access_flags是一个大端掩码,描述类的访问权限和属性标志。

虽然JVM乐于忽略未知的标志值,但我们需要避免设置像ACC_INTERFACE(0x0200)或ACC_ABSTRACT(0x0400)这样导致类不可用的标志。这意味着我们不能使用CONSTANT_Double条目作为第一个越界常量,因为其标签字节0x06将被解释为这些标志。

this_class是常量池的索引,必须指向描述此文件定义的类的CONSTANT_Class条目。幸运的是,JVM和XSLTC都不太关心我们假装是哪个类,因此该值可以指向XSLTC最终生成的几乎任何CONSTANT_Class条目。(唯一的限制是它不能是像java.lang这样的受保护命名空间的一部分。)

super_class是常量池中另一个CONSTANT_Class条目的索引。虽然JVM对任何类都满意,但XSLTC期望这是对org.apache.xalan.xsltc.runtime.AbstractTranslet类的引用,否则类文件的加载和初始化将失败。

经过大量试验和错误,我最终采用以下方法来满足这些要求:

CONST_STRING          CONST_DOUBLE

0x08 0x07 0x02  0x06 0xXX 0xXX 0x00 0x00 0x00 0x00 0xZZ 0xZZ

access_flags  this_class   super_class  ints_count  fields_count methods_count

  1. 我们制作一个XSLT输入,导致池中有0x10703个常量。这将导致截断的池大小为0x703,索引0x703处的常量开始(由于基于0的索引)将被解释为access_flags。
  2. 在池有0x702个常量时,我们在编译输入期间触发添加新字符串常量。这将首先在索引0x702处创建CONSTANT_Utf8条目,在索引0x703处创建CONSTANT_String条目。String条目将引用前面的Utf8常量,因此其值将是标签字节0x08后跟索引0x07 0x02。这导致可用的access_flags值为0x0807。
  3. 在索引0x704处添加CONSTANT_Double条目。其0x06标签字节将被解释为this_class字段的一部分。接下来的2个字节可用于控制super_class字段的值。通过将接下来的4个字节设置为0x00,我们创建空的接口和字段数组,然后将最后两个字节设置为我们想要定义的方法数量。

唯一剩下的要求是,我们需要在常量池的索引0x206处添加一个CONSTANT_Class条目,这相对简单。

下面的片段显示了将覆盖第一个头字段的生成XSLT输入的一部分。在用大量字符串常量填充常量池以获取属性和值之后,jEb元素的CONST_STRING条目最终位于索引0x703。然后对ceiling函数的XSLT函数调用触发在索引0x704处添加受控的CONST_DOUBLE条目:

<jse jsf='jsg' … jDL='jDM' jDN='jDO' jDP='jDQ' jDR='jDS' jDT='jDU' jDV='jDW' jDX='jDY' jDZ='jEa' />

<xsl:value-of select='ceiling(0.000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008344026969402015)'/>

我们构造了初始头字段,现在进入了类文件定义的有趣部分:方法表。这是定义类所有方法及其字节码的地方。在XSLTC生成Java类后,XSLT运行时将加载该类并实例化对象,因此实现任意代码执行的最简单方法是创建恶意构造函数。让我们看看方法表,了解如何定义可工作的构造函数:

ClassFile {

[...]

u2             methods_count;

method_info    methods[methods_count];

[...]

}

method_info {

u2             access_flags;

u2             name_index;

u2             descriptor_index;

u2             attributes_count;

attribute_info attributes[attributes_count];

}

attribute_info {

u2 attribute_name_index;

u4 attribute_length;

u1 info[attribute_length];

}

Code_attribute {

u2 attribute_name_index;

u4 attribute_length;

u2 max_stack;

u2 max_locals;

u4 code_length;

u1 code[code_length];

u2 exception_table_length;

{   u2 start_pc;

u2 end_pc;

u2 handler_pc;

u2 catch_type;

} exception_table[exception_table_length];

u2 attributes_count;

attribute_info attributes[attributes_count];

}

方法表是method_info结构体的动态大小数组。每个结构体描述方法的access_flags、指向其名称的常量表索引(作为utf8常量),以及另一个指向方法描述符的索引(另一个CONSTANT_Utf8)。

后面是属性表,这是一个从存储在常量表中的Utf8键到内联存储的动态大小值的动态大小映射。幸运的是,我们需要提供的唯一属性是Code属性,它包含方法的实际字节码。

回到我们的有效载荷,我们可以看到方法表的开始与常量池表中下一个条目的标签字节对齐。这意味着CONSTANT_Double的0x06标签将破坏第一个方法的access_flag字段,使其对我们不可用。相反,我们必须创建两个方法:第一个作为基本填充物以获得正确的对齐,第二个作为实际的构造函数。幸运的是,JVM忽略未知属性,因此我们可以使用动态大小的属性值。下图显示了我们如何使用一系列CONST_DOUBLE条目创建具有几乎完全控制体的构造函数方法。

CONST_DOUBLE: 0x06 0x01 0xXX 0xXX 0xYY 0xYY 0x00 0x01 0xZZ

CONST_DOUBLE: 0x06 0x00 0x00 0x00 0x05 0x00 0x00 0x00 0x00

CONST_DOUBLE: 0x06 0x00 0x01 0xCC 0xCC 0xDD 0xDD 0x00 0x03

CONST_DOUBLE: 0x06 0x00 0x00 0x00 0x00 0x04 0x00 0x00 0x00

CONST_DOUBLE: 0x06 0xCC 0xDD 0xZZ 0xZZ 0xZZ 0xZZ 0xAA 0xAA

CONST_DOUBLE: 0x06 0xAA 0xAA 0xAA 0xAA 0xAA 0xAA 0xAA 0xAA

CONST_DOUBLE: 0x06 0xAA 0xAA 0xAA 0xAA 0xAA 0xAA 0xAA 0xAA

CONST_DOUBLE: 0x06 0xAA 0xAA 0xAA 0xAA 0xAA 0xAA 0xAA 0xAA

CONST_DOUBLE: 0x06 0xAA 0xAA 0xAA 0xAA 0xAA 0xAA 0xAA 0xAA

第一个方法头

access_flags 0x0601

name_index  0xXXXX

desc_index 0xYYYY

attr_count 0x0001

属性 [0]

name_index 0xZZ06

length 0x00000005

data   “\x00\x00\x00\x00\x06”

第二个方法头

access_flags 0x0001

name_index  0xCCCC ->

desc_index 0xDDDD  -> ()V

attr_count 0x0003

属性 [0]

name_index 0x0600

length 0x00000004

data   “\x00\x00\x00\x06

属性 [1]

name_index 0xCCDD -> Code

length 0xZZZZZZZZ

data  有效载荷

属性 [2] ...

我们仍然需要绕过一个限制:JVM字节码不能独立工作,而是引用和依赖常量池中的条目。实例化类或调用方法需要池中有相应的常量条目。这是一个问题,因为我们的漏洞不允许我们创建假的常量池条目,因此我们仅限于XSLTC在编译期间添加的常量。

幸运的是,有一种方法可以向常量池添加任意类和方法引用:Java的XSLT运行时支持调用任意Java方法。由于这显然不安全,此功能受运行时设置保护,并且在签名验证期间始终禁用。

然而,XSLTC在处理样式表时仍将处理和编译这些函数调用,并且调用仅在运行时被阻止(请参阅FunctionCall.java中的相应代码)。这意味着我们可以通过添加如下所示的XSLT元素来获取对所有所需方法和类的引用:

<xsl:value-of select="rt:exec(rt:getRuntime(),'...')" xmlns:rt="java.lang.Runtime"/>

在我们获得可工作的类文件之前,还需要绕过两个最终检查:

  • JVM强制要求子类的每个构造函数在返回之前调用超类构造函数。可以通过在我们的构造函数中从不返回来绕过此检查,要么在末尾添加无限循环,要么抛出异常,这是我在漏洞概念验证中使用的方法。一个稍微更复杂但更干净的方法是向对象池添加对AbstractTranslet构造函数的引用并调用它。这是thanat0s在其漏洞分析文章中使用的方法。
  • 最后,我们需要跳过XSLTC输出的其余部分。这可以通过在类属性表中构造一个具有正确大小的单个大属性来完成。

一旦我们将所有这些链接在一起,我们最终会得到一个签名的XML文档,可以在签名验证期间触发任意JVM字节码的执行。我跳过了一些此漏洞利用的实现细节,因此如果你想重现此漏洞,请查看带有大量注释的概念验证脚本。

影响和限制

理论上,每个处理XML签名的未修补Java应用程序都容易受到此漏洞利用的攻击。但是,有两个重要的限制:

由于引用仅在SignedInfo元素的签名验证后才被处理,因此应用程序可以根据其使用KeySelector%3B%0A%20%20%20%20%7D-,Using%20KeySelectors,-KeySelectors%20are)类的方式受到保护。在其KeySelector中使用受信任密钥白名单的应用程序将受到保护,只要这些密钥未被泄露。例如,配置了单个受信任IdP密钥的单租户SAML SP。实际上,许多此类应用程序仍然容易受到攻击,因为它们不直接使用KeySelector,而是在自己的应用程序逻辑中在不受限制的签名验证后强制执行此限制。此时漏洞已经被触发。支持客户提供的身份提供商的多租户SAML应用程序(如大多数现代云SaaS所做的那样)也不受此限制的保护。

SAML身份提供商只有在支持(并验证)签名的SAML请求时才会受到攻击。

即使没有CVE-2022-34169,在签名验证期间处理XSLT也很容易被滥用于DoS攻击。因此,可以启用属性org.jcp.xml.dsig.secureValidation以禁止XML签名中的XSLT转换。有趣的是,对于所有JDK版本<17,如果应用程序未在Java安全管理器下运行,则此属性默认为false。由于安全管理器很少用于服务器端应用程序,并且JDK 17仅在一年前发布,我们预计许多应用程序不受此保护。对大型基于Java的SSO提供商的有限测试证实了这一假设。缺乏广泛使用的另一个原因可能是org.jcp.xml.dsig.secureValidation在较新的JDK版本中也禁用了SHA1算法的使用。由于SHA1仍被企业客户广泛使用,在不手动配置合适的jdk.xml.dsig.secureValidationPolicy的情况下简单地启用该属性可能不可行。

结论

XML签名(尤其是SAML)为外部攻击者提供了巨大而复杂的攻击面。尽管Java提供了可用于解决此漏洞的配置选项,但它们很复杂,可能会破坏实际用例,并且默认情况下是关闭的。

依赖SAML的开发人员应确保他们了解与之相关的风险,并应通过禁用其用例不需要的功能来尽可能减少攻击面。额外的深度防御方法,如早期验证签名密钥、基于白名单的有效转换链方法以及对SAML票证的严格模式验证,可用于进一步加固。