Xalan-J XSLT整数截断漏洞利用构造(CVE-2022-34169)学习与扩展
TLDR
本文是对CVE-2022-34169的学习,主要参考thanat0s大佬的文章
这是第一次遇到与 Java Class 字节码相关的漏洞,漏洞类型有点偏向于二进制溢出漏洞。
在分析漏洞过程中加强了对Java字节码的了解,学到了很多。
后面也尝试并成功构造了JDK-Xalan的paylod。
0x01 概述
什么是XSLT
可扩展样式表转换语言(英语:Extensible Stylesheet Language Transformations,缩写XSLT)是一种样式转换标记语言,可以将XML资料档转换为另外的XML或其它格式,如HTML网页,纯文字。XSLT最末的T字母表示英语中的“转换”(transformation)。它是XSL规范中的一部分,目前最新的建议版本为XSL 3.0。
什么是Xalan-J
Xalan-J 是 Apache 开源项目下的一个 XSLT 处理器的 Java 版本实现
漏洞产生原因
Xalan-Java 即时编译器(JIT) 会将传入的 XSLT 样式表使用 BCEL 动态生成 Java Class 字节码文件(Class 文件结构如下),XSLT 样式表中的字符串(String) 以及 >32767
的数值将存入到字节码的常量池表(constant_pool) 中。
漏洞产生的原因在于 Class 字节码规范中限制了常量池计数器大小(constant_pool_count) 为 u2 类型(2个无符号字节大小),所以 BCEL 在写入 > 0xffff(65535)数量的常量时需要进行截断处理,但是通过上面 dump() 方法中的代码可以看到,BCEL 虽然对 constant_pool_count 数值进行了处理,但实际依旧写入了 > 0xffff 数量的常量,因此大于 constant_pool_count 部分的常量最终将覆盖 access_flags 及后续部分的内容
1 | BCEL 的内部常量池表示使用标准的 Java 数组来存储常量,并且不对其长度施加任何限制。当生成的类文件在编译过程结束时被序列化时,数组长度被截短,但完整的数组被写出: |
1 | ClassFile { |
1 | cp_info { |
0x02 常量池
- 常量池:用于存放编译时期生成的各种
字面量
和符号引用
,这部分内容将在类加载后进入方法区/元空间的运行时常量池
中存放 - 常量池计数器:从 1 开始,也即 constant_pool_count=1 时表示常量池中有 0 个常量项,第 0 项常量用于表达
不引用任何一个常量池项目
的情况,常量池对于 Class 文件中的字段
和方法
等解析至关重要
可以使用 Java 自带的工具 javap 查看字节码文件中的常量池内容:javap -v select.class
也可以使用 Classpy GUI 工具进行查看,该工具在点击左侧相应字段信息时会在右侧定位出相应的十六进制范围,在构造利用时提供了很大的帮助
但是这两个工具无法对首部结构正确的畸形字节码文件进行解析(只输出正确结构的部分),并且未找到合适的解析工具
常量池表中具体存储的数据结构如下,根据 tag 标识来决定后续字节码所表达的含义:
根据参考文章测试分析得出结论:
- 增加常量池计数器的值的方法一:使用不同的字符串可以
字符串数量x2
的形式增加常量池计数器的值。AA 和 AAA 实际属于不同的常量。但以这种方式增加常量(<tn/>
),随着 n 不断的增加,所花费的时间也越来越大. - 增加常量池计数器的值的方法二:解决方法是使用
增加属性
替代增加元素
的方式增加常量池(每增加一对属性,常量池+4)。原因在于每新增一个元素(element)都将有translate()方法调用的开销,而新增属性只是增加一个 Hashtable#put()方法调用,因此将大大减少执行时间。
方式如下1
2//下面增加了[(tn-1)/2+2]
<t1 t2='t3' t4='t5' ... tn-1='tn'/> - 增加常量池计数器的值的方法三:除了可以通过字符串的形式增加常量池,根据漏洞作者的提示可以通过方法调用的形式添加
数值类型
的常量(数值需要 >32767 才会存储至常量池表中),如通过调用 java.lang.Math#ceil(double) 方法传入double
数值类型,因为double
属于基本数据类型,因此只会增加一个CONSTANT_Integer_info
数据结构,所以每增加一个double数值,常量池+1
具体原因:
尝试在 select.xslt 文件中添加 <AAA/>
并生成 Class 文件。查看Class文件字节码可以发现,对应到常量池中实际将增加 CONSTANT_String_info 和 CONSTANT_utf8_info 两项,其中 #092(CONSTANT_utf8_info) 中存储着字面量 AAA,#093(CONSTANT_String_info) 的 string_index 则指向 AAA 字面量所处的下标。
为了节省空间,对于相同的常量在常量池中只会存储一份,所以如下内容所生成的 Class 文件中的常量池计数器值依旧为 139
1 | <xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> |
需要注意的是 AA 和 AAA 实际属于不同的常量,将得到的常量池计数器值为:139+2=141,因此:使用不同的字符串可以 字符串数量x2 的形式增加常量池计数器的值
0x03 Class结构图
这里先展示一下整个 Class 文件最终构造的结构图,接下来将针对各个部分进行说明
0x04 payload构造
想详细了解 Java Class 字节码文件结构的可以参考链接:The Class File Format
access_flags & this_class
access_flags 第一个字节对应常量池的 tag,而 tag 值将决定后续的数据结构(查阅前面常量池结构表)
access_flags 的值决定了类的访问标识,如是否为 public ,是否为抽象类等等,如下为各个标识对应的mask 值,当与操作值 != 0
时则会增加相应的修饰符
在确定 access_flag 第一个字节的值(后续使用x1,x2..代替)之前,需要知道编译后的字节码会被进行怎样的处理。
可以看到最终将得到 TemplatesImpl 对象,其中 _bytecodes 即为 XSLT 样式表编译后的字节码内容,熟悉 Java 反序列化漏洞的应该对 TemplatesImpl 类不陌生,之后 newTransformer() 方法调用将会触发 defineClass() 及 newInstance() 方法的调用
由于 defineClass() 过程无法触发类 static{} 方法块中的代码,所以需要借助 newInstance() 调用的过程来触发static{}
、{}
、构造函数
方法块中的恶意代码,因此由于需要实例化类对象,所以类不能为接口、抽象类,并且需要被 public 修饰,所以 access_flags 需满足如下条件:
- access_flags.x1 & 任意修饰符 == 0
- access_flags.x2 & ACC_PUBLIC(0x01) != 0
这里选择设置 access_flags.x1 = 0x08,不选择 access_flags.x1 = 0x01 的原因在于字面量 length 变化会影响到 bytes 的数量,所以一旦发生变动,后续内容就会需要跟着变动,不太好控制。
而 access_flags.x2 的值这里将其设置为 0x07,而不使用 0x01 的原因在于,其值的设定会影响到常量池的大小,根据后续构造发现常量池大小需要满足 > 0x0600(1536) 大小,这部分后续methods[1].attributes[0]也会再进行说明
通过写入 tag = 6 的 double 数值常量(java.lang.Math#ceil(double)),可以实现连续控制 8 个字节内容,可借助如下脚本实现十六进制转换:
1 | import struct |
所以 this_class.x2 = 0x06,根据前面可知,this_class 是一个指向常量池的 常量池索引,所以为了使得截断后的常量池最小
,所以这个值需要尽可能的小,由于0x0006这个常量池已经被占用了,无法进行截断,所以最终确定值为 this_class = 0x0106(262)
这里的08070106中的0701还有另一层含义:
String 类型的 string_index 指向前一项 Utf8 字面量的下标,因此 tag = 8 string_index = 0x0701 则表示前一项是下标为 0x0701 = #1793 的 Utf8 字面量,当前下标为 #1794,所以得出结论是 access_flags 之前应有 1794(包含第 0 项) 个常量,则 constant_pool_count 截断后的值固定为 1794(0x0702),access_flags.x2 间接控制了常量池的大小(此时constant_pool_count的值应为0x0702,所以到t1051处截断,下面的字节就不是常量池里的内容了,直接赋值给access_flags及之后的字段)
根据字节码规范要求,this_class 应指向一个 CONSTANT_Class_info 结构的常量,也即如下图中 Class 对应的下标#0006
但是这里并不能选择常量池已有的这些Class常量,原因在于这些Class常量是XSLT解析的过程中会使用到的类,而字节码最终会被defineClass()加载为Class,将会导致类冲突问题(TODO:为啥会导致类冲突?)
解决方法是通过如下方法调用的方式加载一些XSLT解析过程不会引用的类,因为类是懒加载的,只有在被使用到的时候才会被加载进JVM,所以defineClass()调用时并不会存在com.sun.org.apache.xalan.internal.lib.ExsltStrings,从而解决了类冲突的问题,之后通过在其之前填充一些常量,使得 this_class = 0x0106(#262) 刚好指向 (Class): com/sun/org/apache/xalan/internal/lib/ExsltStrings 即可。
1 | <xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> |
super_class
super_class 同样也需要指向 CONSTANT_Class_info 类型索引,并且因为 TemplatesImpl 的原因依旧需要继承 org.apache.xalan.xsltc.runtime.AbstractTranslet 抽象类,所以直接指向 #0006 即可(位置固定不变)
因为主要目的是控制方法,并通过 newInstance() 触发恶意代码,所以对于接口
和字段
都可以不需要,直接设置为 0 即可:
- interfaces_count = 0x0000
- fields_count = 0x0000
method_count
经测试发现 static{} 方法块(<clinit>
)执行必须要有合法的构造函数<init>
存在,所以直接通过<init>
触发恶意代码即可,除此之外还需要借助一个方法的attribute
部分进行一些脏字符的吞噬(后续解释),所以类中至少需要 2 个方法。
经测试发现:在字节码层面,非抽象类可以不实现抽象父类的抽象方法,所以可以不实现抽象父类 AbstractTranslet 的 transform 方法,设置 method_count = 0x0002 即可
methods[0]
首先看到 method_info 结构:
1 | method_info { |
根据前面的构造可以看到 methods[0].access_flags.x1 = 0x06
,根据访问标识表可知当前方法为抽象(0x06 & 0x04 != 0) 方法,无法包含方法体,所以这也是至少需要存在两个方法的原因,但同时也发现一个问题:在字节码层面,抽象方法是可以存在于非抽象类中的
- methods[0].access_flags.x2 = 0x01:因为该方法不会被使用,所以直接给个 ACC_PUBLIC 属性即可
- methods[0].name_index(Utf8):选择指向了父类抽象方法名 transferOutputSettings,实际指向任何合法 Utf8 常量均可
- methods[0].descriptor_index(Utf8):选择指向了 transferOutputSettings 方法描述符,实际指向任何合法 Utf8 方法描述符均可
methods[0].attributes_count 表示当前方法体中attribute的数量,每个attribute都有着如下通用格式,根据attribute_name_index来决定使用的是哪种属性格式(如下表)
1 | attribute_info { |
这里主要关注 Code 属性,其中存储着方法块中的字节码指令
1 | Code_attribute { |
以如下代码为例查看相应的 Code 属性结构
1 | package org.example; |
可以看到构造函数<init>
中attributes_count = 1
说明只包含一个属性,attribute_nam_index
指向常量池#10(Utf8) Code
,表示当前为Code
属性,code_length 表示字节码指令长度为 17,code 部分则存储了具体的字节码指令
这里需要注意的是:如果attribute_name_index
没有指向合法的属性名,将使用通用格式来进行数据解析,因此可以利用这个特性来吞噬下一个double常量的tag标识
,因此这里设定
- methods[0].attributes_count = 0x0001:只需一个属性即可完成吞噬目的
- attribute_name_index(Utf8) = 0x0206:前面已经将 0x0106 设置为了 Class 类型,所以这里尽量指向更低位的常量池,所以选择使用 0x0206,同时需要注意的是 attribute_name_index 需指向合法的 Utf8 类型常量,所以还需要通过填充的方式确保指向的类型正确
- attribute_length = 0x00000005:属性值设定为 5 并使用 0xAABBCCDD 填充满一个 double 常量,这样可以刚好可以吞噬掉下一个 double 常量的 tag 标识,使得下一个 method[1].access_flags 可以直接通过 double 来进行控制
methods[1]
接下来看到第二个方法methods[1]
,首部这 8 个字节就可直接通过一个 double 数值类型进行控制,这里将构造所需的构造函数方法<init>
:
- access_flags = 0x0001:需要给与 PUBLIC 属性才能通过 newInstance() 实例化
- name_index:需要指向
<init>
的 Utf8 常量池下标,这里通过<AAA select="<init>"/>
代码提前添加<init>
常量,否则只有编译到构造函数方法时才会添加该常量 - descriptor_index:需指向
()V
的 Utf8 常量池下标 - attributes_count = 0x0003:这里将使用 3 个 attribute 构造出合法的方法块
- attributes[0]:用于吞噬 double 常量的 tag
- attributes[1]:用于构造 Code 属性块
- attributes[2]:用于吞噬后续垃圾字符
methods[1].attributes[0]
可以看到 methods[1].attributes[0].attribute_name_index.x1 = 0x06,是为了设置double的tag,又因为 attribute_name_index 是指向常量池的索引,所以需要常量池需要 > 1536(0x0600),这就是前面 access_flags.x2 >= 0x06 的原因
使用同样的方式,通过控制 attributes[0].attribute_length 吞噬掉下一个 double 常量的 tag
这样就可以完全控制 attributes[1].attribute_name_index,使其指向Utf8 Code
常量,后续数据将以Code_attribute
结构进行解析
- attribute_length 和 code_length 都得在 code[] 部分内容确定后进行计算
- max_stack = 0x00FF:操作数栈深度的最大值,数值计算,方法调用等都需要涉及,稍微设置大一些即可
- max_locals = 0x0600:局部变量表所需的存储空间,主要用于存放方法中的局部变量,因为不会涉及使用大量的局部变量,所以0x0600 完全够用了
- exception_table_length = 0x0000:异常表长度,经测试发现,在字节码层面,java.lang.Runtime.exec() 方法调用实际可以不进行异常捕获,所以这里也将其设置为 0
- attributes_count = 0x0000:Code 属性中的内部属性,用于存储如 LineNumberTable 信息,因为不涉及所以将其设置为 0 即可
这里提前看到 methods[1].attributes[2].attribute_name_index 字段,因为 attributes[2] 的作用也是用于吞噬后续的垃圾字符,所以可以和 methods[0].attributes[0].attribute_name_index 一样设置为 0x0206,所以 code 尾部需要有 3 个字节是位于 double 常量首部的
methods[1].code
接着看到最重要的字节码指令构造部分,可以通过Java字节码指令列表获取相关的 Opcode
并非需要每个字节挨个自行进行构造,可以直接编写一个恶意方法,然后提取其中 code 字节码指令部分即可,编写如下代码并获取其字节码指令:
1 | import org.apache.xalan.xsltc.DOM; |
根据上面的字节码指令即可构造出如下代码结构,其中有几点需要注意:
- 空操作可以使用 nop(0x00) 指令
- 对于 tag = 6 所对应的指令 iconst_6 需要配对使用 istore_1 指令
- 不使用 istore_0 的原因在于,局部变量表 0 位置存储着 this 变量引用
- 使用 ldc_w 替换 ldc,可以扩大常量池加载的范围
- 因为可以不涉及异常表,所以 goto 指令可以去除
- 根据前面的说明,末尾的 double 常量需要占用首部 3 个字节
对于 Methodref 方法引用类型,可以使用如下方法调用的方式进行添加
1 | <xsl:value-of select="Runtime:exec(Runtime:getRuntime(),'open -a calculator')" xmlns:Runtime="java.lang.Runtime"/> |
但是这里唯一存在问题的是:如何添加AbstractTranslet.<init>
方法引用,这里需要看到org.apache.xalan.xsltc.compiler.Stylesheet#translate()
方法,构造函数总是最后才进行编译,添加的AbstractTranslet.<init>
方法引用总是位于常量池末尾,所以这将导致截断后的常量池中很难包含MethodRef: AbstractTranslet.<init>
方法引用
然而构造函数<init>
中必须要调用super()
或this()
方法,否则会报错。
这里有两种解决办法:
- 漏洞作者的解决办法:JVM 会检查构造函数中 return 操作之前是否有调用 super() 方法,所以可以通过 return 前嵌入一个死循环即可解决这个问题
- thanat0s大佬的解决办法:通过如下代码可提前引入 AbstractTranslet.
方法引用: 可以将1
<xsl:value-of select="at:new()" xmlns:at="org.apache.xalan.xsltc.runtime.AbstractTranslet"/>
AbstractTranslet.<init>方法引用
设置到一个比较低位的常量池位置。
但是对于org.apache.xalan.xsltc.runtime.AbstractTranslet
类来说,由于是抽象类,按理说不能调用new()
方法进行实例化操作。
但是从 org.apache.xalan.xsltc.compiler.FunctionCall#findConstructors() 中可以看到,通过反射的方式获取了构造方法
并且直到添加方法引用之前(org.apache.xalan.xsltc.compiler.FunctionCall#translate) 都不会检查 XSLT 样式表中传入的类是否为抽象类,因此通过这种方式解决了AbstractTranslet.<init>
方法引用加载的问题
methods[1].attributes[2]
同样通过控制 attribute_length 长度吞噬掉剩余的垃圾字符,由于需要保留 ClassFile 尾部的 SourceFile 属性,所以长度设置为:从 0x12345678 -> 保留尾部 10 个字节(attributes_count + attributes),至此完整的利用就构造好了
CheckList
这里总结一下需要检查的一些项:
- #262 (0x0106) 需要指向 Class 引用 com.sun.org.apache.xalan.internal.lib.ExsltStrings
- 引用父类和当前类需要提前调用类方法加载
- 确认 methods[0].attribute_name_index 指向正确的 Utf8 引用
- 确认 access_flags 位于常量池 #1794 项
- 确认常量池大小为0x0702 (可以 Debug org.apache.bcel.classfile.ConstantPool#dump 方法)
- 确认各个所需常量是否指向正确的常量池位置
- 确认 methods[1].attributes[2].attribute_length 是否为:从 0x12345678 -> 保留末尾 10 个字节
- 构造两个方法,method[0]用来清理脏字符06,method[1]是无参构造器在里面exec
- 由于文件名也会添加至常量池,为避免影响对其他常量位置造成变动,长度需保证一致(6),select -> abcdef(文件名为test12和select时长度不一样)
- 运行前最好删除已生成的 *.class 文件(文件内容发生变动则不用)
- 最后清除脏数据只保留末尾10个字节
JDK-Xalan的paylod
对于JDK-Xalan paylod的构造主要参考于JDK-Xalan的XSLT整数截断漏洞利用构造
同时对工具进行了debug,包括但不限于修复了常量池长度大于0x0702时的处理异常、初始payload不存在标识位等情况。最终成功在mac上实现了payload的自动生成
项目地址https://github.com/altEr1125/AutoGenerateXalanPayload