深入 Java 原生反序列化 & JDK7u21 利用链分析
本篇梳理了 Java 原生反序列化相关知识,同时分析了 JDK 7u21利用链
Serialization and Deserialization
参考网上文章梳理了一些序列化和反序列化的细节
Java 序列化将一个对象转换为二进制,反序列化将一个二进制恢复成对象
首先看一下序列化之后到底有什么内容被写入了二进制数据
public class SerializationDemo implements Serializable { |
SerializationDumper
STREAM_MAGIC - 0xac ed |
0xaced
,魔术头0x0005
,版本号 (JDK主流版本一致,下文如无特殊标注,都以JDK8u为例)0x73
,对象类型标识 (0x7n
基本上都定义了类型标识符常量,但也要看出现的位置,毕竟它们都在可见字符的范围,详见java.io.ObjectStreamConstants
)0x72
,类描述符标识0x0033...
,类名字符串长度和值 (Java序列化中的UTF8格式标准)0xf16f462a38386b46
,序列版本唯一标识 (serialVersionUID
,简称SUID)0x02
,对象的序列化属性标志位,如是否是Block Data模式、自定义writeObject()
,Serializable
、Externalizable
或Enum
类型等0x0002
,类的字段个数0x49
,整数类型签名的第一个字节,同理,之后的0x4c
为字符串类型签名的第一个字节 (类型签名表示与JVM规范中的定义相同)0x0008...
,字段名字符串长度和值,非原始数据类型的字段还会在后面加上数据类型标识、完整类型签名长度和值0x78
Block Data结束标识0x70
父类描述符标识,此处为null
0x0000000a
整数字段intField
的值 (Java序列化中的整数格式标准) ,非原始数据类型的字段则会按对象的方式处理,如之后的字符串字段stringField
被识别为字符串类型,输出字符串类型标识、字符串长度和值
序列化的执行流程
ObjectOutputStream
实例初始化时,将魔术头和版本号写入bout
(BlockDataOutputStream
类型) 中- 调用
ObjectOutputStream.writeObject()
开始写对象数据ObjectStreamClass.lookup()
封装待序列化的类描述 (返回ObjectStreamClass
类型) ,获取包括类名、自定义serialVersionUID
、可序列化字段 (返回ObjectStreamField
类型) 和构造方法,以及writeObject
、readObject
方法等writeOrdinaryObject()
写入对象数据- 写入对象类型标识
writeClassDesc()
进入分支writeNonProxyDesc()
写入类描述数据- 写入类描述符标识
- 写入类名
- 写入SUID (当SUID为空时,会进行计算并赋值,细节见下面关于SerialVersionUID章节)
- 计算并写入序列化属性标志位
- 写入字段信息数据
- 写入Block Data结束标识
- 写入父类描述数据
writeSerialData()
写入对象的序列化数据- 若类自定义了
writeObject()
,则调用该方法写对象,否则调用defaultWriteFields()
写入对象的字段数据 (若是非原始类型,则递归处理子对象)
- 若类自定义了
反序列化的执行流程
ObjectInputStream
实例初始化时,读取魔术头和版本号进行校验- 调用
ObjectInputStream.readObject()
开始读对象数据- 读取对象类型标识
readOrdinaryObject()
读取数据对象readClassDesc()
读取类描述数据- 读取类描述符标识,进入分支
readNonProxyDesc()
- 读取类名
- 读取SUID
- 读取并分解序列化属性标志位
- 读取字段信息数据
resolveClass()
根据类名获取待反序列化的类的Class
对象,如果获取失> 败,则抛出ClassNotFoundException
skipCustomData()
循环读取字节直到Block Data结束标识为止- 读取父类描述数据
initNonProxy()
中判断对象与本地对象的SUID和类名 (不含包名) 是否相同,若不同,则抛出InvalidClassException
- 读取类描述符标识,进入分支
ObjectStreamClass.newInstance()
获取并调用离对象最近的非Serializable
的父类的无参构造方法 (若不存在,则返回null
) 创建对象实例readSerialData()
读取对象的序列化数据- 若类自定义了
readObject()
,则调用该方法读对象,否则调用defaultReadFields()
读取并填充对象的字段数据
- 若类自定义了
补充一个细节是在进行反序列化的过程中这里进行了判断
如果满足 hasReadObjectMethod
的条件,会调用 invokeReadObject
引用 @l1nk3r 师傅的总结
是否重写了 readObject 影响的是 slotDesc.hasReadObjectMethod() 的结果
如果反序列化的过程中被反序列化类重写了 readObject ,该数据在反序列化的过程中核心流程走到 readSerialData 方法中的 slotDesc.invokeReadObject 方法,通过反射机制触发相关流程,并且调用重写的 readObject 。如果没有重写 readObject ,则调用 ObjectInputStream 类中的 readObject 方法,并且执行反序列化
同时补一张流程图
更为详细的流程分析可以看以下文章:
JDK 7u21 Gadget Chain
Basics
大致几点可以看一下 @天融信阿尔法实验室 这个脑图
CC 中我们也用到了通过 javassist 构造 Static Initializers,回顾我们之前构造调用 TemplatesImpl#newTransformer
结合 javassist 实现的一个 RCE 的 demo,当时的思路是用 IvokerTransformer#transform
去触发 TemplatesImpl#newTransformer
来达到 RCE
回看 TemplatesImpl
其他方法,其实调用了 TemplatesImpl#newTransformer
的也能 RCE,比如我们这里 TemplatesImpl#getOutputProperties
由此我们实现第一个 RCE demo
Level-0 demo
public class CommandDemo { |
另一个需要关注的点是 hashCode 的一个小 trick,f5a5a608
的 hashCode 值为 0
Gadget chain
看一下 javax.xml.transform.Templates
,里面有两个方法都是我们想调用的,意味着只要能循环打印这个 interface 的方法,并在某个类调用这个方法,我们即可 RCE
再结合同样在 CC 中用过的 AnnotationInvocationHandler 和动态代理,我们可以实现第二个 RCE demo
Level-1 demo
public class CommandDemo { |
从正向思维来看,先谈一下为什么要用 equals
来触发
核心原因是动态代理触发 AnnotationInvocationHandler#invoke
是这些 if 判断和参数附值。可以看出来 var4
需要等于 equals
,也就是我们需要调用这个方法才行,后面的 this.equalsImpl(var3[0]);
是一个关键方法,此时 var3[0]
是我们 POC 中的 equals
传入的参数,也就是 templates
AnnotationInvocationHandler#equalsImpl
这里会循环遍历 javax.xml.transform.Templates
中的方法,被我们传入的 templates
调用
后续寻找调用链要找到可以传入一个 proxy,同时调用 equals 方法,而且还能传入参数的地方
这里看一下 LinkedHashSet,其继承自 HashSet ,因为我们要找到满足调用链的方法,LinkedHashSet 是有序的所以这里使用它,构造方法直接调用自父类
其实现序列化和反序列化的逻辑也都存在在父类中,我们之前在 CC 中分析过 HashSet
其反序列化时会恢复键值对,并调用 HashMap 的 put 方法,将其放入 HashMap 中
HashMap#put
是我们调用的关键地方
public V put(K key, V value) { |
核心语句为 if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
,这里 key.equals(k)
是我们反序列化 POC 想调用的一个结构
因此根据逻辑运算符的短路特性,我们需要满足第一个条件,需要不满足第二个条件,那么才会调用到第三个条件
这里会有两次循环,先看第一次循环,第一次循环,key 为我们的 POC 的 testTarget.add(templates);
的 TemplatesImpl 对象,由于当前 table 变量指向的 Entry 对象是空的,所以 e 是为null,因此不满足 for 循环条件,在后面会调用 addEntry
添加进 table,table是一个Entry数组,用来存放我们通过map.put()
传入的键值对,并作为后续判断新传入的键值对和旧键值对是否重复的依据
第二次循环为了满足 e != null
那么需要控制 i 的值,使其能从 table 里面取出相应的对象,那么需要控制两次循环中 hash 之后的值相等
先看第一次循环中计算 hash 值
final int hash(Object k) { |
第二次循环计算 hash 值时,由于代理对象没有 hashCode 方法,会相应调用反射来解决,最终调用到 AnnotationInvocationHandler#hashCodeImpl
hashCodeImpl:294, AnnotationInvocationHandler (sun.reflect.annotation) |
此时的var2是一个Iterator对象,用来遍历memberValues对象中存储的键值对,可以看到memberValues中只有一个键值对就是,就是我们在初期通过反射生成 AnnotationInvocationHandler 对象时传入的 HashMap 对象中的那个键值对
key是一个字符串”f5a5a608”
Value值是和第一次循环时用来计算hash值的同一个TemplatesImpl对象
看一下具体 hash 值的计算
var1 += 127 * ((String)var3.getKey()).hashCode() ^ memberValueHashCode(var3.getValue()) |
具体分为了两段
127 * ((String)var3.getKey()).hashCode()
memberValueHashCode(var3.getValue()
第二段由于 Value 为计算hash值的同一个TemplatesImpl对象,因此计算出来跟之前的相等
一个小 trick 是:0和任何数字进行异或,得到的结果都是被异或数本身
那么只要控制第一段结果为 0 ,那么算出来异或值不变,那么结合我们之前说的trick,var3 此刻的 key 为 f5a5a608
,因此跟任何数相乘都为 0
回到我们 HashMap#put
的 if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
第一个判断为 hash 判断,我们刚刚通过构造已经让其相等了
第二个判断将第一次循环时的 key 取出和第二次循环时的 key 比较,第一次循环的key是TemplatesImpl对象,而第二次循环时 key 为 Proxy 对象,所以结果为flase
最后调用 key.equals(k)
,这里的 key 为第二次循环的,k 为第一次循环的,因此总结了需要使用一个有序的HashSet,也就是我们的 LinkedHashSet。这样才能保证我们的调用先后
JDK 7u21 payload
public class CommandDemo { |
利用链
Gadget Chain |
FIX
7u80版本
AnnotationInvocationHandler(Class<? extends Annotation> var1, Map<String, Object> var2) { |
对 this.type
进行了校验必须为 Annotation.class,同时增加了异常抛出,会导致我们反序列化的失败
7u25-b01、7u25-b02尚未如此修补,341行处仍在return。有些文章说修补方案是检
查了AnnotationInvocationHandler.type,估计他们是根据抛出的异常这么说的。事
实上对AnnotationInvocationHandler.type的检查一直存在,要求type与
java.lang.annotation.Annotation有派生、继承、实现关系,但7u25-b03之前发现
问题后抛出的异常被341行的catch捕捉之后没有继续抛异常,而是return了
后续 JRE8u20 对此进行了绕过,通过构造畸型序列化数据,使得针对 AnnotationInvocationHandler.type
的检查被命中时所抛出的异常被 BeanContextSupport.readChildren()
中的 try/catch
块捕获并 continue,但构造过于复杂,且没有新的 gadget,因此不在本文探讨范围内