jdk7u21和jdk8u20原生反序列化漏洞分析
环境jdk下载地址
https://www.oracle.com/java/technologies/javase/javase7-archive-downloads.html
https://www.oracle.com/java/technologies/javase/javase8-archive-downloads.html
jdk7u21反序列化
poc
1 | package com.just; |
原理分析
漏洞的关键触发利用点在 AnnotationInvocationHandler.equalsImpl(Object var1)
方法中。
1 | private Boolean equalsImpl(Object var1) { |
很容易看到这里有个明显的反射调用,有可能可以任意反射 RCE
,因此我们可以来看一下这个方法的流程和参数如何控制。
首先看参数,这个方法中使用了的属性只有 this.type
和 this.memberValues
,而在这个类的构造方法中我们可以看到,这两个属性是直接根据构造方法的传入的参数赋值的,因此可以完全可控。
这里说明一下这两个参数的作用,
type
属性存储被动态代理的是哪个类,memberValues
属性存储当前动态代理的成员属性Map
,可以把String
类型的key
当作属性名,Object
类型的value
当作属性值。
接下来我们分析一下这个方法的逻辑。这个方法的本质目的是在判断当前动态代理的对象和传入的 var1
参数是否等效。这里判断的逻辑是先根据 getMemberMethods
获取动态代理的原本类(原本类存在 AnnotationInvocationHandler
中的 type
属性中)的所有方法。
然后通过 this.asOneOfUs(var1)
判断 var1
是一个普通对象还是也是动态代理的对象,如果不是动态代理对象就返回 null
,反之返回那个动态代理对象的 InvocationHandler
。
接下来进入 if
判断语句,如果 var9 != null
,将当前判断的 equals
两边都为动态代理的对象,就根据二者 memberValues
属性是否一致来判断二者是否一致。不然就通过对 var1
来调用当前动态代理对象的方法,看其返回值和当前动态代理对象的 memberValues
存储的是否一致。
从上面我们可以分析出来,开发者员原本心里想的应该是
memberValues
存储的是方法名和其返回值的映射。
根据上面的分析,equalsImpl
方法是可以利用的,但是现在的关键是怎么调用 equalsImpl
。我们可以它只在 AnnotationInvocationHandler.invoke()
方法中被调用了。但是熟悉动态代理的人就知道 invoke()
这个方法是很好调用的,那现在的关键就差最后一步了。能不能控制 invoke()
方法以我们构造的恶意参数走到 equalsImpl()
的方法调用上。
这一看就可以,就不细说了。接下来的步骤就稍微简单了,调用一个动态代理的 equals
方法,传入 TemplatesImpl
对象。
这里使用 HashSet.readObject()
-> HashMap.put()
-> 任意对象.equals()
的 Gadget
。
原理这里简单介绍一下,这里在 HashSet.readObject()
的时候,会把其里面的元素都放到一个 map
中作为 key
,value
为一个空的 Object
对象。然后在 Map.put()
中,会计算当前 put
的元素的 hash
值,然后判断是否和当前 hash
表中的元素有重复的,如果是重复的就替换,反之就新加入 map
。关键就是在这里判断是否重复的逻辑,需要先让两个 map
中的元素 hash
值相同,但是元素不同才会调用 equals()
方法。因此这里我们需要想办法 hash
碰撞来调用 equals()
方法,对应到 poc
的逻辑就是需要让 map
中的两个元素在不相同的情况下 hash
值相等。
我们可以发现 poc
确实实现了这点,但是是怎么实现的呢?两个不同的元素 hash
怎么保证一样。
其实是因为这里根据 poc
的构造, map
第二个放入的元素是第一个放入的元素的动态代理,两个元素是有一定关系的,所以其 hash
的计算也应该有一定的关系。第一个对象没有重写 hashcode
方法所以我们没办法控制,然后我们可以分析动态代理对象的 hash
计算方式,发现它是重写了计算 hash
的代码的。
根据动态代理 hash
的计算,我们可以发现需要满足 ((String)var3.getKey()).hashCode()
的值为 0
就可以让动态代理的对象和被动态代理的对象的 hash
相同。也就是我们需要找一个字符串的 hash
为 0
,通过爆破,我们可以找到 f5a5a608
这个字符串。
从而我们可以看到 poc
中给 map
设置的 key
为 f5a5a608
。(类似的字符串还有很多)
方法调用栈
1 | HashSet.readObject() |
jdk8u20反序列化
介绍
提前做好这个漏洞分析比较难的准备。这里会先给出思路,然后想办法去实现,思路很难想到,只能照着学习。
jdk8u20
是对 jdk7u21
漏洞的绕过。jdk7u21
的漏洞修复方法主要是下面两个点:
- 在
AnnotationInvocationHandler
的构造方法中加入对传入参数类型的限制。
不过这一点比较好绕过,我们可以通过反射在调用构造方法以后再修改 AnnotationInvocationHandler
的属性。
- 在
AnnotationInvocationHandler.readObject()
方法中对异常进行抛出,而非直接return
。
存在漏洞的版本:
修复后的版本:
这一点的绕过就是 jdk8u20
漏洞的关键所在。
前置知识
由于 jdk8u20
漏洞绕过比较难,这里需要提前说一些后面需要用到的前置知识。
1.异常抛出终止程序运行的绕过
这里直接给出结论:
在一个存在
try-catch
块并且没有抛出异常的方法中如果调用了另一个存在try-catch
块并且抛出了异常的方法,如果被调用方法抛出了异常,那么被调用方法会中断执行,但是外层的调用方法不会中断执行。
下面给出一个示例代码测试:
1 | package com.just; |
运行结果:
2.序列化和反序列化的流程
直接给出结论:
在对一个对象序列化和反序列化的时候,如果
writeObject
方法和readObject
方法中存在了defaultWriteObject()
和defaultReadObject()
的调用,那么会在其中继续对其属性的对象调用writeObject
方法和readObject
方法。
测试如下:
3.readObject()和defaultReadObject()区别的理解
defaultReadObject()
的作用是按照默认的方式依次调用当前类所有属性的 readObject()
方法,并且将其返回值赋给其对应的属性。readObject()
的作用是读取字节流中的下一个 object
。
1 | package com.just.demo2; |
所以如果我们运行上面的代码,结果会如下:
会发现 Test
对象的属性无法成功反序列化成功。这是因为 Test
类的重写的 readObject()
方法中没有使用 defaultReadObject()
,从而不会将字节流读取的到值自动赋给属性,需要我们手动按照属性的顺序一个个顺序赋值。
如下:
1 | private synchronized void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException { |
需要注意的是,如果上面 Test
类中重写的 readObject()
调用的 readObject()
少了一个,会发现 main()
中的 readObject()
不会是 Test
类中未读取的第二个属性的值。根据运行结果说明,如果重写的 readObject()
方法中手动反序列化赋值的参数少了,并不会按顺序排到后面的 readObject()
方法中,而是直接被抛弃了。原理以后再分析,现在先只需要知道结论。
4.对同一个对象只会进行一次序列化和反序列化
这里也直接给出结论:
当
java
在多次写入同一个对象到字节流时,那么这个对象只会调用一次writeObject()
方法。
同理当java
在反序列化一个字节流时,如果此字节流中序列化了同一个对象多次,那么这个对象只会被调用一次readObject()
方法。
下面给出示例代码:
1 | package com.just.demo; |
1 | package com.just.demo; |
1 | package com.just.demo; |
输出:
如果这里第一次写入的 user
对象的 address
属性和第二次写入的 address
对象不一致,那么 Address
还是会调用两次 writeObject
和 readObject
。
原理分析
根据第一个前置知识 ,我们就有了一个绕过 AnnotationInvocationHandler.readObject()
中异常抛出的思路:
寻找一个外层方法来调用
AnnotationInvocationHandler.readObject()
,并且不会抛出异常。
但这只是第一步,后面的步骤就很难想了。这里直接给出结论:
我们需要寻找一个类,满足下面四个要求:
- 重写了
readObject()
方法。- 在
readObject()
方法中存在try-catch
语句。(可以是间接存在,比如readObject
调用了A
方法,A
方法中存在try-catch
语句)- 并且在
try-catch
语句中调用了ois.readObject()
。- 并且
try-catch
语句没有抛出异常。 (这个要求是关键!)
这里可以发现jdk
原生的java.beans.beancontext.BeanContextSupport#readObject()
方法满足条件,这个方法调用了this.readChildren()
方法,并且在this.readChildren()
中存在上面满足条件的try-catch
语句。
这里我们先不需要知道为什么要这么找,等到后面的分析就知道了。
寻找好了利用类,这里直接给出利用思路:
思路一:
在 jdk7u21
链子序列化后的字节码文件中给 HashSet
类对象强行加一个 BeanContextSupport
类的属性,然后给这个属性也强行加一个 AnnotationInvocationHandler
类的属性。
从而根据第二个前置知识,我们可以知道, HashSet.readObject()
中的 defaultReadObject()
会触发其属性的 BeanContextSupport.readObject()
( 根据第二个前置知识 ),再在 BeanContextSupport.readObject()
中通过 readObject()
触发 AnnotationInvocationHandler.readObject()
( 根据第三个前置知识 ) ,从而防止 AnnotationInvocationHandler.readObject()
的异常终止程序。
但是你可能会疑惑这里的逻辑有点问题,按照这里的说法加上 jdk7u21
链子的逻辑,AnnotationInvocationHandler.readObject()
应该会在 HashSet.readObject()
中调用两次:
BeanContextSupport
绕过的是第一次 AnnotationInvocationHandler.readObject()
因为异常终止程序,那怎么绕过第二次呢?
这里就要用到 第四个前置知识 ,我们只要让两次的 AnnotationInvocationHandler
类对象为同一个,那么第二次反序列化时就不会再次调用 AnnotationInvocationHandler
的 readObject
方法了。
这样逻辑思路就捋通了,接下来就是构造 poc
了。
思路二:
类似于思路一,只是换了个 BeanContextSupport
对象在 HashSet
中的位置。我们可以让 BeanContextSupport
对象也成为 HashSet
中的一个元素,这样就可以让它在最后的 for
循环中被调用 readObject
方法了。不过需要注意 BeanContextSupport
对象要在动态代理的对象之前放入 HashSet
,从而让它先反序列化。
此时 HashSet
中应有三个元素,按照顺序是:BeanContextSupport
-> Templates
-> Proxy
。
构造poc
这里构造 poc
是一个难点,需要很了解序列化后结果的结构。先参考 java序列化字节流的结构 来了解这一部分。这里分析起来太复杂了,而且用到的场景也不多,知道其大致的原理就是修改序列化的字节,给对象添加其本没有的字段就可以了。如果以后遇到相似的场景可以再来细致的分析字节。
POC
参考: https://github.com/pwntester/JRE8u20_RCE_Gadget
参考文章
https://xz.aliyun.com/t/9704
https://mp.weixin.qq.com/s/3bJ668GVb39nT0NDVD-3IA
https://github.com/pwntester/JRE8u20_RCE_Gadget