0x01 写在前面
jdk8u20
原生反序列化漏洞是一个非常经典的漏洞,也是我分析过最复杂的漏洞之一。
在这个漏洞里利用了大量的底层的基础知识,同时也要求读者对反序列化的流程、序列化的数据结构有一定的了解
本文结合笔者自身对该漏洞的了解,写下此文,如有描述不当或者错误之处,还望各位师傅指出
0x02 jdk8u20 漏洞原理
jdk8u20
其实是对jdk7u21
漏洞的绕过,在《JDK7u21反序列化漏洞分析笔记》 一文的最后我提到了jdk7u21
的修复方式:
首先来看存在漏洞的最后一个版本(
611bcd930ed1
):http://hg.openjdk.java.net/jdk7u/jdk7u/jdk/file/611bcd930ed1/src/share/classes/sun/reflect/annotation/AnnotationInvocationHandler.java查看其 children 版本(
0ca6cbe3f350
):http://hg.openjdk.java.net/jdk7u/jdk7u/jdk/file/0ca6cbe3f350/src/share/classes/sun/reflect/annotation/AnnotationInvocationHandler.java
compare
一下:// 改之前 AnnotationType annotationType = null; try { annotationType = AnnotationType.getInstance(type); } catch(IllegalArgumentException e) { // Class is no longer an annotation type; all bets are off return; } // 改之后 AnnotationType annotationType = null; try { annotationType = AnnotationType.getInstance(type); } catch(IllegalArgumentException e) { // Class is no longer an annotation type; time to punch out throw new java.io.InvalidObjectException("Non-annotation type in annotation serial stream"); }
可以发现,在第一次的修复中,官方采用的方法是网上的第二种讨论,即将以前的 return 改成了抛出异常。
我们来看第一次修复后的AnnotationInvocationHandler.readObejct()
方法:
private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException {
s.defaultReadObject();
// Check to make sure that types have not evolved incompatibly
AnnotationType annotationType = null;
try {
annotationType = AnnotationType.getInstance(type);
} catch(IllegalArgumentException e) {
// Class is no longer an annotation type; time to punch out
throw new java.io.InvalidObjectException("Non-annotation type in annotation serial stream");
}
Map<String, Class<?>> memberTypes = annotationType.memberTypes();
// If there are annotation members without values, that
// situation is handled by the invoke method.
for (Map.Entry<String, Object> memberValue : memberValues.entrySet()) {
String name = memberValue.getKey();
Class<?> memberType = memberTypes.get(name);
if (memberType != null) { // i.e. member still exists
Object value = memberValue.getValue();
if (!(memberType.isInstance(value) ||
value instanceof ExceptionProxy)) {
memberValue.setValue(
new AnnotationTypeMismatchExceptionProxy(
value.getClass() + "[" + value + "]").setMember(
annotationType.members().get(name)));
}
}
}
}
在AnnotationInvocationHandler
类中,其重写了readObejct
方法,那么根据 oracle 官方定义的 Java 中可序列化对象流的原则——如果一个类中定义了readObject
方法,那么这个方法将会取代默认序列化机制中的方法读取对象的状态,可选的信息可依靠这些方法读取,而必选数据部分要依赖defaultReadObject
方法读取;
可以看到在该类内部的readObject
方法第一行就调用了defaultReadObject()
方法,该方法主要用来从字节流中读取对象的字段值,它可以从字节流中按照定义对象的类描述符以及定义的顺序读取字段的名称和类型信息。这些值会通过匹配当前类的字段名称来赋予,如果当前这个对象中的某个字段并没有在字节流中出现,则这些字段会使用类中定义的默认值,如果这个值出现在字节流中,但是并不属于对象,则抛弃该值。
在利用defaultReadObject()
还原了一部分对象的值后,最近进行AnnotationType.getInstance(type)
判断,如果传入的 type 不是AnnotationType
类型,那么抛出异常。
也就是说,实际上在jdk7u21
漏洞中,我们传入的AnnotationInvocationHandler
对象在异常被抛出前,已经从序列化数据中被还原出来。换句话说就是我们把恶意的种子种到了运行对象中,但是因为出现异常导致该种子没法生长,只要我们解决了这个异常,那么就可以重新达到我们的目的。
这也就是jdk8u20
漏洞的原理——逃过异常抛出。
那么具体该如何逃过呢?jdk8u20
的作者用了一种非常牛逼的方式。
再具体介绍这种方式之前,先简单介绍一些与本漏洞相关的基础知识,以便读者更明白本文的分析流程和细节。
0x03 基础知识
1、Try/catch块的作用
写程序不可避免的出现一些错误或者未注意到的异常信息,为了能够处理这些异常信息或错误,并且让程序继续执行下去,开发者通常使用try ... catch
语法。把可能发生异常的语句放在try { ... }
中,然后使用catch
捕获对应的Exception
及其子类,这样一来,在 JVM 捕获到异常后,会从上到下匹配catch
语句,匹配到某个catch
后,执行catch
代码块,从而达到继续执行代码的效果。
如jdk7u21中利用的正是这个:
try {
annotationType = AnnotationType.getInstance(type);
} catch(IllegalArgumentException e) {
// Class is no longer an annotation type; time to punch out
throw new java.io.InvalidObjectException("Non-annotation type in annotation serial stream");
}
当检测的结果不是AbbitatuibType
时,匹配到了IllegalArgumentException
异常,然后执行了catch
中的代码块。
但如果try ... catch
嵌套,又该如何判定呢?
可以看个例子
package com.panda.sec;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
public class test {
static double TEST_NUMBER = 0;
public static void math(int a, int b){
double c;
if (a != b) {
try {
TEST_NUMBER = a*(a+b);
c = a / b;
} catch (Exception e) {
System.out.println("内层出错了");
}
} else {
c = a * b;
}
}
public static void urlRequest(int a, int b, String url) throws IOException {
math(a, b);
try {
URL realUrl = new URL(url);
HttpURLConnection connection = (HttpURLConnection)realUrl.openConnection();
connection.setRequestProperty("accept", "*/*");
connection.connect();
System.out.println("状态码:" + connection.getResponseCode());
} catch (Exception e) {
System.out.println("外层出错了");
throw e;
}
System.out.println(TEST_NUMBER);
}
public static void main(String[] args) throws IOException {
urlRequest(1,0,"https://www.cnpanda.net");
System.out.println("all end");
}
}
先来看看代码逻辑,首先定义了全局变量TEST_NUMBER=0
,然后定义了math
和urlRequest
两个方法,并且在urlRequest
方法里,调用了math
方法,最后在main
函数中执行urlRequest
方法。
请读者不看下文的分析,先思考当变量值为以下情况时,这段代码会输出什么?
- 当
a=1,b=0
,url地址是https://www.cnpanda.net
时 - 当
a=1,b=0
,url地址是https://test.cnpanda.net
时 - 当
a=1,b=2
,url地址是https://www.cnpanda.net
时 - 当
a=1,b=2
,url地址是https://test.cnpanda.net
时
来看具体运行结果:
当a=1,b=0
,url地址是https://www.cnpanda.net
时:
这种情况下,b=0
使得a/b
中的分母为0,导致内层出错,因此会进入catch
块并打印出内层出错了
字符串,但是由于内层的catch
块并没有把错误抛出,因此继续执行剩余代码逻辑,向https://www.cnpanda.net
地址发起http请求,打印状态码为200,由于在math
方法中 TEST_NUMBER = a*(a+b)=1*(1+0)=1
,因此打印出TEST_NUMBER
为1.0
,最后打印all end
结束代码逻辑。
当a=1,b=0
,url地址是https://test.cnpanda.net
时:
这种情况下,b=0
使得a/b
中的分母为0,导致内层出错,因此会进入catch
块并打印出内层出错了
字符串,但是由于内层的catch
块并没有把错误抛出,因此继续执行剩余代码逻辑,向https://test.cnpanda.net
地址发起http请求,但是由于无法解析导致出错,进入catch
块,在catch
块中打印外层出错了
字符串,然后抛出错误,结束代码逻辑。
当a=1,b=2
,url地址是https://www.cnpanda.net
时:
这种情况下,b!=0
,因此a/b
会正常运算,不会进入catch
块,继续执行剩余代码逻辑,向https://www.cnpanda.net
地址发起http请求,打印状态码为200,由于在math
方法中 TEST_NUMBER = a*(a+b)=1*(1+2)=3
,因此打印出TEST_NUMBER
为3,最后打印all end
结束代码逻辑。
当a=1,b=2
,url地址是https://test.cnpanda.net
时:
这种情况下,b!=0
,因此a/b
会正常运算,不会进入catch
块,继续执行剩余代码逻辑,向https://test.cnpanda.net
地址发起http请求,但是由于无法解析导致出错,进入catch
块,在catch
块中打印外层出错了
字符串,然后抛出错误,结束代码逻辑。
从上面的示例可以得出一个结论,在一个存在try ... catch
块的方法(有异常抛出)中去调用另一个存在try ... catch
块的方法(无异常抛出),如果被调用的方法
(无异常抛出)出错,那么会继续执行完调用方法
的代码逻辑,但是若调用方法
也出错,那么会
终止代码运行的进程
这是有异常抛出
调用无异常抛出
,那么如果是无异常抛出
调用有异常抛出
呢?
如下代码:
package com.panda.sec;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
public class test {
static double TEST_NUMBER = 0;
public static void math(int a, int b,String url) throws IOException {
double c;
try {
urlRequest(url);
if (a != b) {
TEST_NUMBER = a*(a+b);
c = a / b;
} else {
c = a * b;
}
} catch (Exception e) {
System.out.println("外层出错了");
}
}
public static void urlRequest(String url) throws IOException {
try {
URL realUrl = new URL(url);
HttpURLConnection connection = (HttpURLConnection)realUrl.openConnection();
connection.setRequestProperty("accept", "*/*");
connection.connect();
System.out.println("状态码:" + connection.getResponseCode());
} catch (Exception e) {
System.out.println("内层出错了");
throw e;
}
System.out.println(TEST_NUMBER);
}
public static void main(String[] args) throws IOException {
math(1,0,"https://test.cnpanda.net");
System.out.println("all end");
}
}
同上面示例一样的代码逻辑(为了方便,做了略微调整,有些代码无意义也没有删除),只是,不同的是,这里在math
方法中调用了urlRequest
方法。
那么如下情况又会输出什么呢?
同样的,请读者不看下文的分析,先思考当变量值为以下情况时,这段代码会输出什么?
- 当
a=1,b=0
,url地址是https://www.cnpanda.net
时 - 当
a=1,b=0
,url地址是https://test.cnpanda.net
时 - 当
a=1,b=2
,url地址是https://www.cnpanda.net
时 - 当
a=1,b=2
,url地址是https://test.cnpanda.net
时
当a=1,b=0
,url地址是https://www.cnpanda.net
时
这种情况下,url
为https://www.cnpanda.net
,因此会在内层向该地址发起http请求,并且打印状态码为200,内层执行完毕后,继续执行外层剩余代码逻辑,b=0
使得a/b
中的分母为0,导致外层出错,因此会进入catch
块并打印出外层层出错了
字符串,最后打印all end
结束代码逻辑。
当a=1,b=0
,url地址是https://test.cnpanda.net
时
这种情况下,url
为https://test.cnpanda.net
,因此会在内层向该地址发起http请求,但是由于无法解析导致出错,进入catch
块,在catch
块中打印内层出错了
字符串,由于内层出错,导致外层也出错,直接进入外层的catch
块并打印出外层层出错了
字符串,最后打印all end
结束代码逻辑。
当a=1,b=2
,url地址是https://www.cnpanda.net
时
这种情况下,url
为https://www.cnpanda.net
,因此会在内层向该地址发起http请求,并且打印状态码为200,内层执行完毕后,继续执行外层剩余代码逻辑,b!=0
使得a/b
中的分母不为0,外层不会出错,因此执行完外层的逻辑,最后打印all end
结束整个代码逻辑。
当a=1,b=2
,url地址是https://test.cnpanda.net
时
这种情况下,url
为https://test.cnpanda.net
,因此会在内层向该地址发起http请求,因此会在内层向该地址发起http请求,但是由于无法解析导致出错,进入catch
块,在catch
块中打印内层出错了
字符串,由于内层出错,导致外层也出错,直接进入外层的catch
块并打印出外层层出错了
字符串,最后打印all end
结束代码逻辑。
从上面的示例可以得出一个结论,在一个存在try ... catch
块的方法(无异常抛出)中去调用另一个存在try ... catch
块的方法(有异常抛出),如果被调用的方法(有异常抛出)出错,那么会导致调用方法
出错且不会继续执行完调用方法
的代码逻辑,但是不会
终止代码运行的进程
2、序列化数据的结构
序列化数据的结构可以参考:
《Object Serialization Stream Protocol/对象序列化流协议》总结 https://www.cnpanda.net/talksafe/892.html
或者直接阅读官方文档:https://docs.oracle.com/javase/8/docs/platform/serialization/spec/protocol.html
使用SerializationDumper工具可以查看一段序列化数据的结构,如下图所示:
可以看到,序列化结构的骨架是由TC_*
和各种字段描述符构成,各个TC_*
及描述符的意思已经在《Object Serialization Stream Protocol/对象序列化流协议》一文中介绍了,想深入阅读的读者可以去看看。
3、序列化中的两个机制
引用机制
在序列化流程中,对象所属类、对象成员属性等数据都会被使用固定的语法写入到序列化数据,并且会被特定的方法读取;在序列化数据中,存在的对象有null、new objects、classes、arrays、strings、back references等,这些对象在序列化结构中都有对应的描述信息,并且每一个写入字节流的对象都会被赋予引用Handle
,并且这个引用Handle
可以反向引用该对象(使用TC_REFERENCE
结构,引用前面handle的值),引用Handle
会从0x7E0000
开始进行顺序赋值并且自动自增,一旦字节流发生了重置则该引用Handle会重新从0x7E0000
开始。
成员抛弃
在反序列化中,如果当前这个对象中的某个字段并没有在字节流中出现,则这些字段会使用类中定义的默认值,如果这个值出现在字节流中,但是并不属于对象,则抛弃该值,但是如果这个值是一个对象的话,那么会为这个值分配一个 Handle。
4、了解jdk7u21漏洞
这个是毋庸置疑要理解的,因为jdk8u20是对jdk7u21漏洞修复的绕过。
可以参考我之前写的文章:JDK7u21反序列化漏洞分析笔记:https://xz.aliyun.com/t/9704
0x04 从一个case说起
由于jdk8u20
真的比较复杂,因此为了方便理解,我写了一个简单的case,用于帮助读者理解下文。
假设存在两个类AnnotationInvocationHandler
和BeanContextSupport
,具体内容如下:
AnnotationInvocationHandler.java
package com.panda.sec;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;
public class AnnotationInvocationHandler implements Serializable {
private static final long serialVersionUID = 10L;
private int zero;
public AnnotationInvocationHandler(int zero) {
this.zero = zero;
}
public void exec(String cmd) throws IOException {
Process shell = Runtime.getRuntime().exec(cmd);
}
private void readObject(ObjectInputStream input) throws Exception {
input.defaultReadObject();
if(this.zero==0){
try{
double result = 1/this.zero;
}catch (Exception e) {
throw new Exception("Hack !!!");
}
}else{
throw new Exception("your number is error!!!");
}
}
}
BeanContextSupport.java
package com.panda.sec;
import java.io.ObjectInputStream;
import java.io.Serializable;
public class BeanContextSupport implements Serializable {
private static final long serialVersionUID = 20L;
private void readObject(ObjectInputStream input) throws Exception {
input.defaultReadObject();
try {
input.readObject();
} catch (Exception e) {
return;
}
}
}
Question:当传入AnnotationInvocationHandler
方法中的zero
等于0
的时候,如何能在序列化结束时调用AnnotationInvocationHandler.exec()
方法达到RCE
?
我们首先令zero
等于0,然后尝试调用AnnotationInvocationHandler.exec()
方法看看:
import java.io.*;
public class Main {
public static void payload() throws IOException, ClassNotFoundException {
AnnotationInvocationHandler annotationInvocationHandler = new AnnotationInvocationHandler(0);
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("payload1"));
out.writeObject(annotationInvocationHandler);
out.close();
ObjectInputStream in = new ObjectInputStream(new FileInputStream("payload1"));
AnnotationInvocationHandler str = (AnnotationInvocationHandler)in.readObject();
str.exec("open /System/Applications/Calculator.app");
}
public static void main(String[] args) throws IOException, ClassNotFoundException {
payload();
}
}
不出意外,由于zero
的值为0,所以使得result
的分母为0,导致出现异常,抛出 Exception("Hack !!!")
错误。
由于在代码中我们生成了序列化文件payload1
,所以现在可以利用SerializationDumper
工具来看看其数据结构:
STREAM_MAGIC - 0xac ed
STREAM_VERSION - 0x00 05
Contents
TC_OBJECT - 0x73
TC_CLASSDESC - 0x72
className
Length - 41 - 0x00 29
Value - com.panda.sec.AnnotationInvocationHandler - 0x636f6d2e70616e64612e7365632e416e6e6f746174696f6e496e766f636174696f6e48616e646c6572
serialVersionUID - 0x00 00 00 00 00 00 00 0a
newHandle 0x00 7e 00 00
classDescFlags - 0x02 - SC_SERIALIZABLE
fieldCount - 1 - 0x00 01
Fields
0:
Int - I - 0x49
fieldName
Length - 4 - 0x00 04
Value - zero - 0x7a65726f
classAnnotations
TC_ENDBLOCKDATA - 0x78
superClassDesc
TC_NULL - 0x70
newHandle 0x00 7e 00 01
classdata
com.panda.sec.AnnotationInvocationHandler
values
zero
(int)0 - 0x00 00 00 00
由于该数据结构比较短,可以来具体介绍一下。
STREAM_MAGIC - 0xac ed
是魔数,代表了序列化的格式;
STREAM_VERSION - 0x00 05
表示序列化的版本;
Contents
表示最终生成的序列的内容;
TC_OBJECT - 0x73
表示序列化一个新对象的开始标记;
TC_CLASSDESC - 0x72
表示一个新类的描述信息开始标记;
className
表示当前对象的类全名信息,下面紧跟着的内容也是className
的描述信息;
Length - 41 - 0x00 29
表示当前对象的类的长度为41
;
Value - com.panda.sec.AnnotationInvocationHandler - 0x636f6d2e70616e64612e7365632e416e6e6f746174696f6e496e766f636174696f6e48616e646c6572
表示当前对象的类的名称为com.panda.sec.AnnotationInvocationHandler
,后面的字符串是其十六进制表示;
serialVersionUID - 0x00 00 00 00 00 00 00 0a
定义了serialVersionUID
的值为20
;
newHandle 0x00 7e 00 00
表示为对象分配一个值为007e0000
的handle
(因为引用Handle
会从0x7E0000
开始进行顺序赋值并且自动自增),值得注意的是这里的handle
实际上没有被真正的写入文件,如果我们把这里的007e0000
加入到序列化数据中,会发生异常,从而终止反序列化进程,之所以会在这里显示出来,是因为serializationDumper
的作者为了方便使用者分析序列化数据的结构;
classDescFlags - 0x02 - SC_SERIALIZABLE
表示类描述信息标记为SC_SERIALIZABLE
,代表在序列化的时候使用的是java.io.Serializable
(如果使用的是java.io.Externalizable
,这里的标记就会变成classDescFlags - 0x04 - SC_EXTERNALIZABLE
);
fieldCount - 1 - 0x00 01
表示成员属性的数量为1,值得注意的是这里的fieldCount
同样是serializationDumper
的作者为了方便使用者分析序列化数据的结构而新设置的描述符,在官方序列化规范中是没有fieldCount
的;
Fields
表示接下来的内容是类中所有字段的描述信息,Fields
成员属性保存了当前分析的类对应的所有成员属性的元数据信息,它是一个数组结构,每一个元素都对应了成员属性的元数据描述信息,且不会重复;
0
表示接下来的内容是第一个字段的描述信息;
Int - I - 0x49
表示该字段的类型是int
型;
fieldName
表示当前字段的字段名信息,下面紧跟着的内容也是 fieldName
的描述信息;
Length - 4 - 0x00 04
表示当前字段名的长度为4
;
Value - zero - 0x7a65726f
表示当前字段名为zero
;
classAnnotations
表示和类相关的Annotation
的描述信息,这里的数据值一般是由ObjectOutputStream
的annotateClass()
方法写入的,但由于annotateClass()
方法默认为空,所以classAnnotations
后一般会设置TC_ENDBLOCKDATA
标识;(关于annotateClass
具体可以看我写的序列化流程分析总结一文)
TC_ENDBLOCKDATA - 0x78
数据块的结束标记,表示这个对象类型的描述符已经结束了;
superClassDesc
表示父类的描述符信息,这里为空;
TC_NULL - 0x70
表示当前对象是一个空引用;
newHandle 0x00 7e 00 01
表示为对象分配一个值为007e0001
的handle
,同上面的newHandle
一样,这里的handle
实际上没有被真正的写入文件;
classdata
表示下面紧跟着的是类数据中的所有内容;
` com.panda.sec.AnnotationInvocationHandler
values
zero
(int)0 - 0x00 00 00 00`表示类数据中的所有内容
以上就是所有的序列化数据的结构,当进行反序列化的时候,会依次从上到下读取序列化内容进行还原数据。
现在思考一个问题:如果在上面的序列化数据中插入一部分源代码中没有的数据,那么在反序列化的时候会发生什么?
在解决这个问题前,首先再来深入理解一下我们之前提到的引用机制,举个例子
比如以下代码进行一次序列化的序列化数据结构:
package com.panda.sec;
import java.io.*;
public class test implements Serializable {
private static final long serialVersionUID = 100L;
public static int num = 0;
private void readObject(ObjectInputStream input) throws Exception {
input.defaultReadObject();
System.out.println("hello!");
}
public static void main(String[] args) throws IOException {
test t = new test();
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("testcase"));
out.writeObject(t);
out.close();
}
}
如果上述代码进行两次序列化,那么这个数据结构会变成什么?
可以来看看:
package com.panda.sec;
import java.io.*;
public class test implements Serializable {
private static final long serialVersionUID = 100L;
public static int num = 0;
private void readObject(ObjectInputStream input) throws Exception {
input.defaultReadObject();
System.out.println("hello!");
}
public static void main(String[] args) throws IOException {
test t = new test();
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("testcase"));
out.writeObject(t);
out.writeObject(t); //二次序列化
out.close();
}
}
对比一下可以发现,在该序列化数据的结构中最后多了
TC_REFERENCE - 0x71
Handle - 8257537 - 0x00 7e 00 01
这里对应的就是前文基础知识里“序列化中的两个机制”中引用机制里的一段话
每一个写入字节流的对象都会被赋予引用
Handle
,并且这个引用Handle
可以反向引用该对象(使用TC_REFERENCE
结构,引用前面handle的值),引用Handle
会从0x7E0000
开始进行顺序赋值并且自动自增,一旦字节流发生了重置则该引用Handle会重新从0x7E0000
开始。
那么反序列化是如何处理TC_REFERENCE
块的呢?
我在反序列化流程分析总结 一文中写到这样一个流程:
在
readObject0
方法里有这样的一个判断:
是的,在反序列化的流程中,进入了readObject0
方法后,会判断读取的字节流中是否有TC_REFERENCE
标识,如果有,那么会调用readHandle
函数,但是我没有在文中具体说明readHandle
函数,可以一起来看看:
private Object readHandle(boolean unshared) throws IOException {
if (bin.readByte() != TC_REFERENCE) {
throw new InternalError();
}
passHandle = bin.readInt() - baseWireHandle;
if (passHandle < 0 || passHandle >= handles.size()) {
throw new StreamCorruptedException(
String.format("invalid handle value: %08X", passHandle +
baseWireHandle));
}
if (unshared) {
// REMIND: what type of exception to throw here?
throw new InvalidObjectException(
"cannot read back reference as unshared");
}
Object obj = handles.lookupObject(passHandle);
if (obj == unsharedMarker) {
// REMIND: what type of exception to throw here?
throw new InvalidObjectException(
"cannot read back reference to unshared object");
}
return obj;
}
这个方法会从字节流中读取TC_REFERENCE
标记段,它会把读取的引用Handle
赋值给passHandle
变量,然后传入lookupObject()
,在lookupObject()
方法中,如果引用的handle
不为空、没有关联的ClassNotFoundException
(status[handle] != STATUS_EXCEPTION
),那么就返回给定handle
的引用对象,最后由readHandle
方法返回给对象。
也就是说,反序列化流程还原到TC_REFERENCE
的时候,会尝试还原引用的handle
对象。
谈完了引用机制现在在来看数据插入的问题,如何能在类AnnotationInvocationHandler
的序列化数据中插入一部分源代码中没有的数据?
利用objectAnnotation
!
继续来看个例子:
package com.panda.sec;
import java.io.*;
public class test implements Serializable {
private static final long serialVersionUID = 100L;
public static int num = 0;
private void readObject(ObjectInputStream input) throws Exception {
input.defaultReadObject();
System.out.println("hello!");
}
private void writeObject(ObjectOutputStream output) throws IOException {
output.defaultWriteObject();
output.writeObject("Panda");
output.writeUTF("This is a test data!");
}
public static void main(String[] args) throws IOException, ClassNotFoundException {
test t = new test();
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("testcase_new"));
out.writeObject(t);
out.writeObject(t);
out.close();
}
}
在这个示例中,我们重写了writeObject
方法,并且在该方法中利用writeObject
和writeUTF
方法写入了Panda
对象以及This is a test data!
字符串,该段序列化数据内容如下:
为了更直白的看变化,我们可以用compare
工具来对比一下:
可以看到原先表示类描述信息标记由0x02 - SC_SERIALIZABLE
变成了0x03 - SC_WRITE_METHOD | SC_SERIALIZABLE
,并且在原有序列化数据结构的最下方还多了由objectAnnotation
标识的内容段,这里的内容段会在反序列化的时候被还原
为什么会有这种变化?
知识点1:如果一个可序列化的类重写了writeObject
方法,而且向字节流写入了一些额外的数据,那么会设置SC_WRITE_METHOD
标识,这种情况下,一般使用结束符TC_ENDBLOCKDATA
来标记这个对象的数据结束;
知识点2:如果一个可序列化的类重写了writeObject
方法,在该序列化数据的classdata
部分,还会多出个objectAnnotation
部分,并且如果重写的writeObject()
方法内除了调用defaultWriteObject()
方法写对象字段数据,还向字节流中写入了自定义数据,那么在objectAnnotation
部分会有写入自定义数据对应的结构和值;
这样一来是不是就有点明了了?
正常情况下,我们没有办法修改可序列化类本身的内容,也就没办法重写这个类中的writeObject
方法,也就没法让序列化数据中多出来objectAnnotation
内容段
可真的没办法吗?当然不是了!
序列化数据只是一块二进制的数据而已,只要按照序列化预定的规则来修改其hex数据,那么实际上就是相当于在重写的writeObject
方法中添加数据
在写入数据前,我们要考虑一件事,谁向谁写入数据?是先序列化AnnotationInvocationHandler
类然后向其中插入BeanContextSupport
对象,还是先序列化BeanContextSupport
类然后向其中插入AnnotationInvocationHandler
对象?
先思考jdk7u21
被修复的原因是什么?是因为在反序列化的过程中有异常抛出,从而导致反序列化的进程被终止了!
这让我们不得不联想到我们在基础知识的Try/catch块的作用
中做的结论:
在一个存在
try ... catch
块的方法(无异常抛出)中去调用另一个存在try ... catch
块的方法(有异常抛出),如果被调用的方法(有异常抛出)出错,那么会导致调用方法
出错且不会继续执行完调用方法
的代码逻辑,但是不会
终止代码运行的进程
我们要的就是不要终止我们的反序列化进程,这样我们就可以取得反序列化后的类对象。
所以我们需要先序列化BeanContextSupport
类(无异常抛出)然后向其中插入AnnotationInvocationHandler
对象(有异常抛出)
这里还有一点要注意,因为根据成员抛弃
机制我们知道,如果序列化流新增的这个值是一个对象的话,那么会为这个值分配一个 Handle
,但由于我们是手动插入Handle
,所以需要修改引用Handle
的值(就是TC_ENDBLOCKDATA
块中handle
的引用值)为AnnotationInvocationHandler
对象的handle
地址
具体过程如下:
第一步,先序列化BeanContextSupport
类,然后利用SerializationDumper
工具可以得到以下数据结构:
STREAM_MAGIC - 0xac ed
STREAM_VERSION - 0x00 05
Contents
TC_OBJECT - 0x73
TC_CLASSDESC - 0x72
className
Length - 32 - 0x00 20
Value - com.panda.sec.BeanContextSupport - 0x636f6d2e70616e64612e7365632e4265616e436f6e74657874537570706f7274
serialVersionUID - 0x00 00 00 00 00 00 00 14
newHandle 0x00 7e 00 00
classDescFlags - 0x02 - SC_SERIALIZABLE
fieldCount - 0 - 0x00 00
classAnnotations
TC_ENDBLOCKDATA - 0x78
superClassDesc
TC_NULL - 0x70
newHandle 0x00 7e 00 01
classdata
com.panda.sec.BeanContextSupport
values
第二步,序列化AnnotationInvocationHandler
类,然后利用SerializationDumper
工具可以得到以下数据结构:
STREAM_MAGIC - 0xac ed
STREAM_VERSION - 0x00 05
Contents
TC_OBJECT - 0x73
TC_CLASSDESC - 0x72
className
Length - 41 - 0x00 29
Value - com.panda.sec.AnnotationInvocationHandler - 0x636f6d2e70616e64612e7365632e416e6e6f746174696f6e496e766f636174696f6e48616e646c6572
serialVersionUID - 0x00 00 00 00 00 00 00 0a
newHandle 0x00 7e 00 00
classDescFlags - 0x02 - SC_SERIALIZABLE
fieldCount - 1 - 0x00 01
Fields
0:
Int - I - 0x49
fieldName
Length - 4 - 0x00 04
Value - zero - 0x7a65726f
classAnnotations
TC_ENDBLOCKDATA - 0x78
superClassDesc
TC_NULL - 0x70
newHandle 0x00 7e 00 01
classdata
com.panda.sec.AnnotationInvocationHandler
values
zero
(int)0 - 0x00 00 00 00
第三步,利用objectAnnotation
插入AnnotationInvocationHandler
对象:
STREAM_MAGIC - 0xac ed
STREAM_VERSION - 0x00 05
Contents
TC_OBJECT - 0x73
TC_CLASSDESC - 0x72
className
Length - 32 - 0x00 20
Value - com.panda.sec.BeanContextSupport - 0x636f6d2e70616e64612e7365632e4265616e436f6e74657874537570706f7274
serialVersionUID - 0x00 00 00 00 00 00 00 14
newHandle 0x00 7e 00 00
classDescFlags - 0x02 - SC_SERIALIZABLE
fieldCount - 0 - 0x00 00
classAnnotations
TC_ENDBLOCKDATA - 0x78
superClassDesc
TC_NULL - 0x70
newHandle 0x00 7e 00 01
classdata
com.panda.sec.BeanContextSupport
values
objectAnnotation // 从这里开始
TC_OBJECT - 0x73
TC_CLASSDESC - 0x72
className
Length - 41 - 0x00 29
Value - com.panda.sec.AnnotationInvocationHandler - 0x636f6d2e70616e64612e7365632e416e6e6f746174696f6e496e766f636174696f6e48616e646c6572
serialVersionUID - 0x00 00 00 00 00 00 00 0a
newHandle 0x00 7e 00 00
classDescFlags - 0x02 - SC_SERIALIZABLE
fieldCount - 1 - 0x00 01
Fields
0:
Int - I - 0x49
fieldName
Length - 4 - 0x00 04
Value - zero - 0x7a65726f
classAnnotations
TC_ENDBLOCKDATA - 0x78
superClassDesc
TC_NULL - 0x70
newHandle 0x00 7e 00 01
classdata
com.panda.sec.AnnotationInvocationHandler
values
zero
(int)0 - 0x00 00 00 00
TC_ENDBLOCKDATA - 0x78
TC_REFERENCE - 0x71
Handle - 8257539 - 0x00 7e 00 03
第四步,修改handle
值以及对应的classDescFlags
值:
STREAM_MAGIC - 0xac ed
STREAM_VERSION - 0x00 05
Contents
TC_OBJECT - 0x73
TC_CLASSDESC - 0x72
className
Length - 32 - 0x00 20
Value - com.panda.sec.BeanContextSupport - 0x636f6d2e70616e64612e7365632e4265616e436f6e74657874537570706f7274
serialVersionUID - 0x00 00 00 00 00 00 00 14
newHandle 0x00 7e 00 00
classDescFlags - 0x03 - SC_WRITE_METHOD | SC_SERIALIZABLE
fieldCount - 0 - 0x00 00
classAnnotations
TC_ENDBLOCKDATA - 0x78
superClassDesc
TC_NULL - 0x70
newHandle 0x00 7e 00 01
classdata
com.panda.sec.BeanContextSupport
values
objectAnnotation // 从这里开始
TC_OBJECT - 0x73
TC_CLASSDESC - 0x72
className
Length - 41 - 0x00 29
Value - com.panda.sec.AnnotationInvocationHandler - 0x636f6d2e70616e64612e7365632e416e6e6f746174696f6e496e766f636174696f6e48616e646c6572
serialVersionUID - 0x00 00 00 00 00 00 00 0a
newHandle 0x00 7e 00 02
classDescFlags - 0x02 - SC_SERIALIZABLE
fieldCount - 1 - 0x00 01
Fields
0:
Int - I - 0x49
fieldName
Length - 4 - 0x00 04
Value - zero - 0x7a65726f
classAnnotations
TC_ENDBLOCKDATA - 0x78
superClassDesc
TC_NULL - 0x70
newHandle 0x00 7e 00 03
classdata
com.panda.sec.AnnotationInvocationHandler
values
zero
(int)0 - 0x00 00 00 00
TC_ENDBLOCKDATA - 0x78
TC_REFERENCE - 0x71
Handle - 8257539 - 0x00 7e 00 03
注:这里最后的
Handle - 8257539 - 0x00 7e 00 03
中的8257539
是serializationDumper
中生成的数值,具体在序列化或反序列化流程中的体现,没有具体深究,该值不影响我们最终序列化数据的生成,该值的生成算法如下:public static void num(){ byte b1 = 0 ; byte b2 = 126; byte b3 = 0; byte b4 = 3; int handle = ( ((b1 << 24) & 0xff000000) + ((b2 << 16) & 0xff0000) + ((b3 << 8) & 0xff00) + ((b4 ) & 0xff) ); System.out.println("Handle - " + handle + " - 0x" + byteToHex(b1) + " " + byteToHex(b2) + " " + byteToHex(b3) + " " + byteToHex(b4)); }
其中,b1 b2 b3 b4组合是 00 7e 00 xx,即代表了引用handle的值,然后将这些值经过运算就可以得到最终的8257539值。
然后根据这段数据结构,转换成十六进制数据如下:
ac ed 00 05 73 72 00 20 636f6d2e70616e64612e7365632e4265616e436f6e74657874537570706f7274
00 00 00 00 00 00 00 14 03 00 00 78 70 73 72 00 29 636f6d2e70616e64612e7365632e416e6e6f746174696f6e496e766f636174696f6e48616e646c6572
00 00 00 00 00 00 00 0a 02 00 01 49 00 04 7a65726f 78 70 00 00 00 00 78 71 00 7e 00 03
这里需要注意的是,我们在前文提到了
newhandle
实际上没有被真正的写入文件,如果我们把这里的007e0000
加入到序列化数据中,会发生异常,从而终止反序列化进程,之所以会在这里显示出来,是因为serializationDumper
的作者为了方便使用者分析序列化数据的结构;
所以我们在构建十六进制数据的过程中要丢弃掉newhandle
对应的十六进制数据
最后以4个字符为一组,8个组为一行,整理可得:
aced 0005 7372 0020 636f 6d2e 7061 6e64
612e 7365 632e 4265 616e 436f 6e74 6578
7453 7570 706f 7274 0000 0000 0000 0014
0300 0078 7073 7200 2963 6f6d 2e70 616e
6461 2e73 6563 2e41 6e6e 6f74 6174 696f
6e49 6e76 6f63 6174 696f 6e48 616e 646c
6572 0000 0000 0000 000a 0200 0149 0004
7a65 726f 7870 0000 0000 7871 007e 0003
将这些内容替换掉在从一个case说起
最开始部分生成的payload1
里的内容
然后尝试再次运行以下代码:
package com.panda.sec;
import java.io.*;
public class Main {
public static void payload() throws IOException, ClassNotFoundException {
// AnnotationInvocationHandler annotationInvocationHandler = new AnnotationInvocationHandler(0);
//
// ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("payload1"));
// out.writeObject(annotationInvocationHandler);
// out.writeObject(annotationInvocationHandler);
// out.close();
ObjectInputStream in = new ObjectInputStream(new FileInputStream("payload1"));
System.out.println(in.readObject().toString());
AnnotationInvocationHandler str = (AnnotationInvocationHandler)in.readObject();
System.out.println(str.toString());
str.exec("open /System/Applications/Calculator.app");
}
public static void main(String[] args) throws IOException, ClassNotFoundException {
payload();
}
}
结果如下:
成功实现了RCE,并且可以看到,我们第一次反序列还原的对象是com.panda.sec.BeanContextSupport
,第二次反序列化还原的对象是com.panda.sec.AnnotationInvocationHandler
,也正对应了我们自己手动插入数据的顺序
0x05 jdk8u20 漏洞分析
我们在jdk8u20漏洞原理
部分提到逃过异常抛出
是该漏洞的关键所在,又经过上面一个case的分析,现在再来看看如何逃过异常抛出呢?没错,就是在jdk
源码中找到一个类似于该case
中的BeanContextSupport
类,让BeanContextSupport
成为外层,去调用jdk
源码中的AnnotationInvocationHandler
类,这样一来没有异常抛出就能够使反序列化流程不被终止,成功组成新的gadget链,完成一次完美的反序列化漏洞攻击。
那么在jdk
源码中到底有没有一个类似于该case
中的BeanContextSupport
类?答案是显而易见的,其实为了方便读者理解此处的内容,我在case中就把这个类的名称给了出来——是的,就是 java.beans.beancontext.BeanContextSuppor
类,我们利用的是该类中的readChildren
方法,来具体看看:
public final void readChildren(ObjectInputStream ois) throws IOException, ClassNotFoundException {
int count = serializable;
while (count-- > 0) {
Object child = null;
BeanContextSupport.BCSChild bscc = null;
try {
child = ois.readObject();
bscc = (BeanContextSupport.BCSChild)ois.readObject();
} catch (IOException ioe) {
continue;
} catch (ClassNotFoundException cnfe) {
continue;
}
synchronized(child) {
BeanContextChild bcc = null;
try {
bcc = (BeanContextChild)child;
} catch (ClassCastException cce) {
// do nothing;
}
if (bcc != null) {
try {
bcc.setBeanContext(getBeanContextPeer());
bcc.addPropertyChangeListener("beanContext", childPCL);
bcc.addVetoableChangeListener("beanContext", childVCL);
} catch (PropertyVetoException pve) {
continue;
}
}
childDeserializedHook(child, bscc);
}
}
}
可以看到,在该方法的第7行,对传入进来的ObjectInputStream
对象调用了readObject
方法进行反序列化处理,并且当在反序列化过程中如果出现异常,采用的是continue
处理。完美的符合我们的要求。
我们在上文中提到ObjectAnnotation
这个概念,并且其实可以发现,如果存在ObjectAnnotation
结构,那么一般是由TC_ENDBLOCKDATA - 0x78
去标记结尾的,但是这里其实存在一个问题,我们知道在jdk7u21修复中是因为IllegalArgumentException
异常被捕获后抛出了java.io.InvalidObjectException
,虽然这里我们可以利用BeanContextSupport
来强制序列化流程继续下去,但是抛出的异常会导致BeanContextSupport
的ObjectAnnotation
中TC_ENDBLOCKDATA - 0x78
结尾标志无法被正常处理,如果我们不手动删除这个TC_ENDBLOCKDATA - 0x78
那么会导致后面的结构归在ObjectAnnotation
结构中,从而读取错误,反序列化出来的数据不是我们预期数据。所以我们在生成BeanContextSupport
的ObjectAnnotation
中不能按照正规的序列化结构,需要将标记结尾的结构TC_ENDBLOCKDATA - 0x78
删除
也正由于我们把TC_ENDBLOCKDATA - 0x78
删除了,会导致我们在使用SerializationDumper
工具查看jdk8u20
的序列化数据结构出错,如下图所示:
这里还有一个tips点,就是我们在插入BeanContextSupport
对象的时候并不是像case中那样直接插入,而是借用假属性的概念插入。在成员抛弃
中我们提到
在反序列化中,如果当前这个对象中的某个字段并没有在字节流中出现,则这些字段会使用类中定义的默认值,如果这个值出现在字节流中,但是并不属于对象,则抛弃该值,但是如果这个值是一个对象的话,那么会为这个值分配一个 Handle。
所以我们插入一个任意类型为BeanContextSupport
的字段就可以在不影响原有的序列化流程的情况下,形成一个gadget链
这里可能有点难以理解,多说一点
我们知道一般gadget链是一链接着一链紧紧相连,通过写各种类之间的调用,就能够满足整个gadget链的要求,实现整个gadget链的相连。但在jdk8u20中,并非如此,因为LinkedHashSet
没法在满足绕过异常抛出的条件下直接调用BeanContextSupport
方法,但是BeanContextSupport
可以调用AnnotationInvocationHandler
方法,这也就导致我们的gadget链在LinkedHashSet
下一步断了,那怎么办?
只能通过修改序列化数据结构的方式,在LinkedHashSet
中强行插入一个BeanContextSupport
类型的字段值,由于在java反序列化的流程中,一般都是首先还原对象中字段的值,然后才会还原objectAnnotation
结构中的值(即是按照序列化数据结构的顺序),所以它会首先反序列化LinkedHashSet
,然后反序列LinkedHashSet
字段的值,由于在这个字段值中有一个BeanContextSupport
类型的字段,所以反序列化会去还原BeanContextSupport
对象,也就是objectAnnotation
中的数据
在反序列化BeanContextSupport
的过程中,会首先反序列化BeanContextSupport
的字段值,其中有个值为 Templates.class
的 AnnotationInvocationHandler
类的对象的字段,然后反序列化会去还原AnnotationInvocationHandler
对象,成功的关联了下一个链!
最后就是同 Jdk7u21
一样的流程,利用动态代理触发Proxy.equals(EvilTemplates.class)
,达到恶意类注入实现RCE的最终目的。
目前jdk8u20
反序列化漏洞 payload 的写法有以下几种方式:
- 原生的,通过数组手动去构建:https://github.com/pwntester/JRE8u20_RCE_Gadget/
- 通过字节码写入的方式构建:https://mp.weixin.qq.com/s/3bJ668GVb39nT0NDVD-3IA、https://xz.aliyun.com/t/8277#toc-5
- 通过
python javaSerializationTools
模块构建:https://mp.weixin.qq.com/s/SMq6aE5-qV9cINv1-74RgA
纵观以上的方法各有各的优劣,有的容易理解,但是构建起来却很麻烦,有的不容易理解,但是构建起来较为方便,其实还有如果读者全文看下来,还有一种更容易理解的方法,就是先把jdk7u21
漏洞利用的payload的序列化数据结构生成出来,然后向该数据结构中用类似case中的方法,去手动插入对象,但是这个工作量比较大,故我也没有手动去实现,有兴趣、有时间的朋友可以去尝试利用该方法生成payload(包你酸爽~但是对你理解jdk8u21
来说,这种方式是最直白的方式)
另外,jdk8u20特殊的一点在于,实际上BeanContextSupport
不能算是真正意义上的一条链,链还是jdk7u21中的那条链,只不过BeanContextSupport
是用来避免抛出异常用到的媒介。
0x06 总结
本文对jdk8u20
原生反序列化漏洞进行了分析,但和其他分析文章不同的是,本文没有按照常规的分析方法进行分析,而是重点写了一个case,用一个最简单的case去了解jdk8u20
最核心的问题点,然后从整体上阐述了jdk8u20
反序列化漏洞是怎么一回事,流程上是什么样的
站在读者的角度上去考虑,让自己如何用更直白的方式让别人理解你发的内容,我觉得这样的方式可以让我更能理解我所分析的漏洞、记忆我所写的内容,毕竟,每一个分析文章其实对于我来说都是一次整体上的总结
0x07 参考
https://github.com/pwntester/JRE8u20_RCE_Gadget
http://wouter.coekaerts.be/2015/annotationinvocationhandler
https://github.com/potats0/javaSerializationTools