Java原生序列化和反序列化分析

前言:

之前分析各种组件或者框架的反序列化漏洞,fastjson或者shiro或者weblogic还有spring;其漏洞出发点都是在readObject;但是具体为什么是要在readObject点触发还没有细致的学习,今天恶补一下Java的原生序列化和反序列化;

序列化的应用:

当两个进程在进行远程通信时,彼此可以发送各种类型的数据。无论是何种类型的数据,都会以二进制序列的形式在网络上传送。发送方需要把这个Java对象转换为字节序列,才能在网络上传送;接收方则需要把字节序列再恢复为Java对象。

正文:

简单写一个demo来对序列化和反序列化进行一个debug学习;模仿一波类似反序列化漏洞点;

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
import java.io.*;

class SU implements Serializable{
public int idcard;
public String school;
public SU() {
this.idcard=410881;
this.school="ncu";
}
}
class information extends SU implements Serializable {
public String id = "s1mple";
public int num = 20;
public String hacker = "hack";

private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
System.out.println("success_to_readObject");
}
public information() {
System.out.println("success");
}
}
public class cmd {
public static void main(String args[]) throws IOException, ClassNotFoundException {
information test = new information();
//ObjectOutputStream s1mple = new ObjectOutputStream(new FileOutputStream("test.ser"));
//s1mple.writeObject(test);
ObjectInputStream s2mple = new ObjectInputStream(new FileInputStream("test.ser"));
Object cc = s2mple.readObject();
System.out.println(cc);
}
}

由这个程序来简单debug一下;

序列化

1.ObjectOutputStream实例化;

I0OJW8.png

verifySubclass函数确定继承权限;这里ObjectOutputStream自然符合;

new一个BlockDataOutputStream底层流对象往out也就是FileOutputStream里写数据;FileOutputStream里调用open(name, append);写入文件中;追溯name和append;不难发现;

1
String name = (file != null ? file.getPath() : null);//append==false;

回归上图,构造两个Table;接着writeStreamHeader方法写入序列化头(魔术头,版本号);接着bout打开缓冲区;写入的魔术头,版本号写入到FileOutputStream流中,然后再写入文件中,这里可debug去看下目标文件;

IDjmVJ.png

可以看到释放缓冲区之后会将内容写入到文件中;整个过程是动态的、实时的,并非是最后一下子写入文件;因为涉及到很多缓冲区对外的开和关;下面会分析;

2.初始化完成之后调用writeObject;

IBVsbt.png

enableOverride属性在初始化的时候被设置为false;这里直接去拿writeObject0;在writeObject0中先关闭缓冲区对外通道;然后利用lookup方法返回desc;方便之后对类进行内省;

IDxVAJ.png

最后判断其继承的类型;从而进行相应的write函数调用;因为继承Serializable,所以这里是以二进制的形式write出来;这也解释了为什么序列化要继承Serializable接口;

IrPVAO.png

3.序列化数据

IrF71H.png

向缓冲区buf里写入TC_OBJECT;然后调用writeClassDesc写入类的元数据;

在序列化数据之前,会先对对象进行类型的判别;

IrASVx.png

一种是Null,一种是handle一种是Proxy代理类型,最后一种是一般类型;这里自己随便写的一个类自然是一般类型,所以进入writeNonProxyDesc方法中;

IrZZL9.png

先将TC_CLASSDESC标识写入buf缓冲区,为了避免重复覆盖,利用了pos探针去进行标识,所以此TC_CLASSDESC标识写在byte缓冲区的第二个元素位置;这点追溯下不难发现;

向下进入writeClassDescriptor方法;写入指定的类描述符的对象输出流;这里通俗点来讲就是向ObjectOutputStream流中的缓冲区写入目标类属性的数量,目标类的属性类型和属性名;其实本质上来说是调用了writeNonProxy方法;

Iruw7j.png

writeNonProxy里也有写入一些重要的点,这里追溯一下:可以看到写入序列化目标类名和serialVersionUID;

IyOCNj.png

然后判断写入标识;因为目标类继承了可序列化接口,所以目标类可序列化,那么自然写入序列化标识 SC_SERIALIZABLE;

IyOIx0.png

总的来说,调用writeClassDescriptor方法写入的点为:目标类类名长度和类名(调用writeUTF,再追溯最后是调用writeShort写入长度,writeBytes写入方法名),serialVersionUID,序列化标识SC_SERIALIZABLE,域数量,域类型和域名;

写完之后利用setBlockDataMode函数打开缓冲区对外通道释放掉缓冲区,将缓冲区的内容传入文件输出流中,最后写入文件;

IrKpCt.png

IrKFKS.png

然后再次关闭关闭缓冲区对外的通道,写入TC_ENDBLOCKDATA (对象的可选块数据块的结尾)。至此属性类型和属性名还有serialVersionUID等写入完毕;

IrKRRP.png

然后递归调用writeClassDesc写入父类元信息。看到Super就无疑了;

IrMFRx.png

和之前的流程一样,先写入TC_CLASSDESC;然后写入父类的属性类型和属性名、serialVersionUID等等;这点和上面的一样,不再赘述;

IrMmee.png

IrMyOU.png

代码不是很难理解;写完父类属性信息之后再次打开缓冲区对外通道,写入文件流然后实时写入文件;

IrQP0g.png

最后当然再向缓冲区写入TC_ENDBLOCKDATA以表示此过程写入结束;

上面这些流程走完之后就开始序列化写入数据了;

IrQBAH.png

进入writeSerialData函数;利用getClassDataLayout方法拿到实例化目标类和其父类;然后进入for依次对两个类进行处理;

IrcZCR.png

先检查目标类有没有自定义writeObject方法;也就是有没有重写writeObject方法;如果有则调用invokeWriteObject方法去执行自定义的writeObject方法;这自然是没有;

IrcTi9.png

所以最后进入调用默认的序列化属性器;

Irgpid.png

进入defaultWriteFields函数;

首先拿到Class对象,然后判断Class对象和Obj实例化对象是否为空,并且进入isInstance函数;判断是否实例化;看下isInstance函数的说明;

Specifically, if this Class object represents a declared class, this method returns true if the specified Object argument is an instance of the represented class (or of any of its subclasses); it returns false otherwise.; 翻译过来大概如下:

具体地说,如果这个类对象表示一个声明的类,那么如果指定的对象参数是所表示的类(或其任何子类)的实例,那么这个方法返回true;否则返回false。

再具体一点针对于isInstance函数可以看这个链接https://blog.csdn.net/cumt951045/article/details/107798107

这里是先来处理父类SU,obj(information)是SU的子类;所以自然符合;返回true;再进行逻辑运算最后为false;不抛出错误,继续向下进行;

I6XRDf.png

调用checkDefaultSerialize方法;检查对象有没有正确的初始化;然后排查默认序列化时异常;

IckSvF.png

然后去利用getPrimDataSize函数去获取基本类型的域的个数;然后生成存储基本类型域值的字节数组;获取基本类型域的值,然后将其类型和域名写入缓冲区中;这里是int所以写入为 I;(想请理解看参考后面down的结果)

然后获取对象的所有域;接着创建一个java数组对象类型的引用类型域数组;

IcFdtx.png

至于基本类型域的概念具体可看此链接;

https://blog.csdn.net/weixin_34703307/article/details/114072459

通过getObjFieldValues函数拿到非原生数据的值,这里debug拿到的是ncu,也就是String school域;然后通过for循环,调用writeObject0函数下writeString方法写入序列化流中;

1
2
3
4
5
6
7
long utflen = bout.getUTFLength(str);
if (utflen <= 0xFFFF) {
bout.writeByte(TC_STRING);
bout.writeUTF(str, utflen);
} else {
bout.writeByte(TC_LONGSTRING);
bout.writeLongUTF(str, utflen);

写入方法如上代码;写入相应标识和长度还有值;

最后附上down的结果;

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
STREAM_MAGIC - 0xac ed
STREAM_VERSION - 0x00 05
Contents
TC_OBJECT - 0x73
TC_CLASSDESC - 0x72
className
Length - 11 - 0x00 0b
Value - information - 0x696e666f726d6174696f6e
serialVersionUID - 0x25 fa 22 16 4d ac da 14
newHandle 0x00 7e 00 00
classDescFlags - 0x02 - SC_SERIALIZABLE
fieldCount - 3 - 0x00 03
Fields
0:
Int - I - 0x49
fieldName
Length - 3 - 0x00 03
Value - num - 0x6e756d
1:
Object - L - 0x4c
fieldName
Length - 6 - 0x00 06
Value - hacker - 0x6861636b6572
className1
TC_STRING - 0x74
newHandle 0x00 7e 00 01
Length - 18 - 0x00 12
Value - Ljava/lang/String; - 0x4c6a6176612f6c616e672f537472696e673b
2:
Object - L - 0x4c
fieldName
Length - 2 - 0x00 02
Value - id - 0x6964
className1
TC_REFERENCE - 0x71 //java序列化协议是一个很严谨的协议,不会出现两个一摸一样的对象,如果二次出现就如这里的操作一样,会通过reference去指向上一个的内容,类似指针
Handle - 8257537 - 0x00 7e 00 01
classAnnotations
TC_ENDBLOCKDATA - 0x78
superClassDesc
TC_CLASSDESC - 0x72
className
Length - 2 - 0x00 02
Value - SU - 0x5355
serialVersionUID - 0xb0 ac 0a f9 e3 94 3e af
newHandle 0x00 7e 00 02
classDescFlags - 0x02 - SC_SERIALIZABLE
fieldCount - 2 - 0x00 02
Fields
0:
Int - I - 0x49
fieldName
Length - 6 - 0x00 06
Value - idcard - 0x696463617264
1:
Object - L - 0x4c
fieldName
Length - 6 - 0x00 06
Value - school - 0x7363686f6f6c
className1
TC_REFERENCE - 0x71
Handle - 8257537 - 0x00 7e 00 01
classAnnotations
TC_ENDBLOCKDATA - 0x78
superClassDesc
TC_NULL - 0x70
newHandle 0x00 7e 00 03
classdata
SU
values
idcard
(int)410881 - 0x00 06 45 01
school
(object)
TC_STRING - 0x74
newHandle 0x00 7e 00 04
Length - 3 - 0x00 03
Value - ncu - 0x6e6375
information
values
num
(int)20 - 0x00 00 00 14
hacker
(object)
TC_STRING - 0x74
newHandle 0x00 7e 00 05
Length - 4 - 0x00 04
Value - hack - 0x6861636b
id
(object)
TC_STRING - 0x74
newHandle 0x00 7e 00 06
Length - 6 - 0x00 06
Value - s1mple - 0x73316d706c65

反序列化

更好的理解我采用如下的源码来表示;

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
import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.net.URL;
import java.util.HashMap;
class SU implements Serializable{
public int idcard;
public String school;
public SU() {
this.idcard=410881;
this.school="ncu";
}
}
class information extends SU implements Serializable {
public String id = "s1mple";
public int num = 20;
public String hacker = "hack";
private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
//in.defaultReadObject();
tests();
System.out.println("success_to_readObject");
System.out.println(num);
}
public information() {
System.out.println("success");
}
public void tests(){
System.out.println("test");
}
}
public class cmd {
public static void main(String args[]) throws IOException, ClassNotFoundException, NoSuchFieldException, NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
// information test = new information();
//Class fucker = information.class;
//Constructor constructor = fucker.getDeclaredConstructor();
//Field sum = fucker.getDeclaredField("num");
//information information = (information) constructor.newInstance();
//sum.set(information,122);
//ObjectOutputStream s1mple = new ObjectOutputStream(new FileOutputStream("fuck.ser"));
//s1mple.writeObject(test);
ObjectInputStream s2mple = new ObjectInputStream(new FileInputStream("test.ser"));
Object cc = s2mple.readObject();
//System.out.println(cc);
}
}

反序列化很类似于序列化的操作;主要操作都是在function0里;

首先来说也是预处理;

TQxu0U.png

先来判别继承权限(简单点片面说就是判别此类是不是ObjectInputSream,文章末尾会分析verifySubclass函数),然后new一个底层的输入流;值得注意的是readStreamHeader方法会去读取STREAM_MAGIC和STREAM_VERSION头,进行判别是否相等以检测流是否损坏;当然,如果两端使用的JDK中这个类版本号不一致就会出现异常;抛出错误invalid stream header;然后打开缓冲区和内部的通道,释放缓冲区;数据被读取到ObjectInputStream内部;

然后开始调用readObject方法处理序列化的数据

IRDQET.png

因为调用的是带有参数的ObjectInputStream方法,所以是需要有外界传入流;所以这里调用readObject0来进行处理;readObject0是ObjectInputStream的核心方法;

IRB11A.png

先判断一下整体的流当前是否处于块数据模式;然后拿到前数据块中剩余的未使用字节数;程序不允许有多余的未使用字节;所以如果有会抛出错误;然后关闭对内的通道;

接着读取标识;通过switch选择对应标识后数据的读取方式进行读取相应的数据;

TQxRHS.png

IIFsYD.png

case到object进行反序列化,整个object的反序化在readOrdinaryObject中;

这里程序写的很谨慎;先进行判断是不是Object的标识,如果不是抛出错误;反之继续向下进行;

TQzPu6.png

在下面调用readClassDesc方法进行元数据的读取;和序列化写入是刚好反过来;这里是和序列化相同,也会判断目标class是否属于四种类型;经过case;

IIEcpF.png

case到TC_CLASSDESC;处理并返回类描述符对象;说人话就是返回的是一个描述类的一个对象,主要包括类的名称,suid等各种域;也就是元数据;其实追溯一下不难发现,还是调用了readNonProxy方法;(readClassDescriptor方法下调用)

TQz7PH.png

在此方法下,拿到类名,序列化id和序列化中标识类版本(0x02);然后接着拿到了class的属性数量,并且用for循环对输入流向后读取readByte;拿到了所有属性名;记录一下:

ITEDtP.png

这里显然是拿到域的数量;information这个obj里有三个,这在序列化数据中有记录;读出域名和类型之后将其打包为ObjectStreamField;

I7u5id.png

这里在读取的时候会将读取的字符进行判断,代码很明显,这是因为在序列化写入的时候就是在非原生数据类型前加入相关字符;

然后将属性名和其类型一起打包;

ITeRne.png

然后在ObjectStreamField中case去修改ObjectStreamField对象下type的状态;

ITmU8P.png

然后一路return;回到ObjectInputStream这个class下;

ITmL26.png

然后打开内部通道;清空缓冲区;

resolveClass对类类型进行构建;追溯一下看下处理流程;

I7lETS.png

这里不对class进行实例化;

继续向下:

initNonProxy又构建了类的元信息。并且检查是否有重写readObject,writeObejct,readObjectNoData,writeReplace,readResolve这些方法;如果重写则记录下来;

I71Vnx.png

IIlX2q.png

在此判断是否有重写的readObject方法,如果有则调用invokeReadObject方法去调用相应class下的readObject方法;这也就解释了为什么所有的readObject二进制反序列化链的起点都是相应class下的readObject的原因;当然这是在实例化完相关的父类obj之后进行的操作;反序列化的时候会先去进行父类里的处理,对其进行newInstance然后进行相关Field的赋值;父类完了之后才是处理子类;所以父类obj里的相关属性已经被赋了值;然后进行子类入口readObject的触发进行攻击; 一个图就可理解;

bIBOZF.png

其实回去看一下主要的触发还是在如下:ObjectInputStream 下的readSerialData方法里;方法主要是对序列化的Field进行反序列化处理和反射赋值;

bID4eO.png

这里经过判断发现存在重写的readObject方法,所以直接进行调用;不会走原来的赋值操作(下图为原本正常逻辑赋值操作);

bIrouV.png

其实反序列化的漏洞也就是调用反射去给相应的class下的属性进行赋值;然后序列化成二进制文件之后,文件之中会保存相应的信息,然后触发readObject的时候就会碰到上述的也会去触发目标class下的readObject方法,然后经过其调用;我们规划好相应的链条属性的值;将其赋值为obj实例,就会链式反应最后进行到Runtime下或者Templateslmpl下,最后触发命令或者newInstance恶意class;或者触发jndi注入等等;当然还有更高级的攻击方法,比如攻击无文件落地攻击tomcat等等的操作,其实都是通过反序列化进行相关实现;