说明

在阅读后面的内容时,这里先要提前准备好两个工具:

  1. 下载 https://github.com/NickstaDB/SerializationDumper 工具来可视化二进制的序列化数据的结构。
    不过这个工具的运行需要传入二进制文件的十六进制表示。因此使用前我们需要用下面的 python 脚本处理一下,将二进制文件的内容读取出来,并以十六进制的格式输出。
1
2
3
4
5
6
import binascii

path = "demo.ser"
with open(path, "rb") as f:
content = f.read()
print(binascii.b2a_hex(content).decode())

使用方式: java -jar SerializationDumper.jar <十六进制字符串>

  1. idea 下载 BinEd - Binary/Hex Editor 插件来查看和编辑二进制文件。这里使用 010 editor 也是可以的。

此外, java 序列化使用到的常数定义在 ObjectStreamConstants 接口中,后面会用到。

示例分析

示例一

1
2
3
4
5
6
7
8
9
10
11
12
package com.just.demo1;

import java.io.Serializable;

public class User implements Serializable {
public String name;
public int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.just.demo1;

import java.io.*;

public class Test {
public static void main(String[] args) throws IOException, ClassNotFoundException {
User user = new User("tom", 21);

String path = "demo1.ser";
FileOutputStream fos = new FileOutputStream(path);
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(user);
}
}

序列化得到的二进制文件用上面提到的 idea 插件打开样式如下:

再用上面的第一个工具分析其结构:

接下来我们逐字节详细的分析序列化得到的二进制内容。

刚开始的两个字节 ACEDjdk 原生序列化后二进制流固定的魔数,用于判断这个二进制流是否是 jdk 原生的序列化数据。对应 STREAM_MAGIC

接下来的两个字节 0005 用于表示流协议的版本。对应 STREAM_VERSION 。大多数情况都是 0005

再接下来的一个字节 73 表示序列化的是一个对象。对应 TC_OBJECT 。举个其它的例子,如果序列化的是一个字符串类型,则这个字节应为 TC_STRING ,对应 74

后面的一个字节 72 标识对象的类描述信息的开始,就是说下一个二进制块的内容是类描述信息。对应 TC_CLASSDESC

再后面的两个字节 0013 表示对象类全名的长度为 19 ,然后再后面就是类全名 com.just.demo1.User

再后面的八个字节表示的是当前对象中定义的 serialVersionUID 值。代码中的定义如下:

1
private static final long serialVersionUID = 3208092597671621268L;

其值 3208092597671621268 转换成十六进制的值就是:2C 85 6F 38 6A C6 F2 94 ;需要注意的是 如果一个类中没有定义该值系统会自动生成一个新的值 ,在二进制序列中追加在此处,因为 serialVersionUID 的类型是 long 类型的,所以它占用了 8 个字节,所以系统自动生成的时候也会自动创建一个 long 类型的数据【 8 个字节的二进制序列】。

接下来的一个字节 02 表示这个对象是实现了 Serializable 接口的。

再接下来的两个字节 0002 表示这个对象序列化了的属性的数量。也就是没有被 transient 关键字标识了的属性的数量。

再后面就是这两个属性的信息:

1
2
3
4
// 第一个字段age的信息
49 00 03 61 67 65
// 第二个字段name的信息
4C 00 04 6E 61 6D 65

第一个字段的第一个字节转换会字符是 I ,表示这个字段是 int 类型的,然后后面的两个字节 00 03 表示字段名的长度为 3 ,然后 61 67 65 就是字段名的 ASCII 码。

第二个字段同理,4C 转化为字符是 L ,表示这个字段是引用类型(非基本类型)的,然后后面的两个字节 00 04 表示字段名的长度为 4 ,然后 6E 61 6D 65 就是字段名的 ASCII 码。由于这个字段是引用类型的(被标识了 L ),那么后面还需要一块内容来标识这个引用类型的类。也就对应后面的 74 00 12 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E 67 3B 。开头的 74TC_STRING ,也就是说后面的内容是字符串。然后 00 12 标识字符串的长度,然后一直到最后就都是字符串本身。这里的字符串就是前面引用类型的类全名 Ljava/lang/String; 。记得注意结尾的分号。

到这里就结束了类字段信息的部分。后面的 78TC_ENDBLOCKDATA ,标识这段内容的结束。70TC_NULL ,标识这个类没有父类。这里是不考虑 Object 类的。

最后一块内容就是类字段的具体值。第一个字段是 int 类型的,占四个字节,就是 00 00 00 15 表示 age 字段的值为 21 。第二个字段是 String 类型的,就需要开头用 74 来标识,然后是字符串的长度和字符串的具体值。

示例二

再看一个稍微复杂一点的案例。

1
2
3
4
5
6
7
8
9
10
package com.just.demo2;

import java.io.Serializable;

public class Address implements Serializable {
public String path;
public Address(String path) {
this.path = path;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.just.demo2;

import java.io.*;

public class User implements Serializable {
public String name;
public int age;
public Address address;
public User(String name, int age, Address address) {
this.name = name;
this.age = age;
this.address = address;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.just.demo2;

import java.io.*;

public class Test {
public static void main(String[] args) throws IOException, ClassNotFoundException {
Address address = new Address("wuhan");
User user = new User("tom", 21, address);

String path = "demo2.ser";
FileOutputStream fos = new FileOutputStream(path);
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(user);

FileInputStream fis = new FileInputStream(path);
ObjectInputStream ois = new ObjectInputStream(fis);
System.out.println(ois.readObject());
}
}

这里开头固定的结构和前面一样就不细说了,直接到类字段结构的一部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 序列化二进制流的魔数
AC ED
// 流协议的版本
00 05
// 代表后面的一块序列化的是对象
73
// 标识对象类描述信息的开始
72
// 类全名的长度:19
00 13
// 类全名:com.just.demo2.User
62 6F 6D 2E 6A 75 73 74 2E 64 65 6D 6F 32 55 73 65 72
// serialVersionUID值
<八个字节>
// 表示该对象实现的是Serializable接口
02

类字段结构关键的部分就是要注意基本类型的结构不需要后面标识类名的一部分。

基本类型字段的结构:

1
<标识类型的一个字节> <字段名字的长度> <字段名字>

而引用类型会多后面一部分:

1
<标识引用类型的一个字节L: 0x4C> <字段名字的长度> <字段名字> <TC_STRING: 0x74> <字段类型全类名的长度> <字段类型全类名>

示例二的具体分析如下:

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
// 表示这个对象序列化了三个字段
00 03
===第一个字段的信息===
// 对应的字符是I,表示第一个字段是int类型的。
49
// 表示这个字段的名字长度:3
00 03
// 这个字段的名字:age
61 67 65
===第二个字段的信息===
// 对应的字符是L,表示第二个字段是引用类型的
4C
// 这个字段名字的长度:7
00 07
// 这个字段的名字:address
61 64 64 72 73 73
// 下一部分是字符串:TC_STRING
// 这里的字符串标识的是这个字段的类名
74
// 字符串的长度: 24
00 18
// 字符串:Lcom/just/demo2/Address;
4C 63 ...... 73 73 3B
===第三个字段的信息===
// 对应的字符是L,表示第三个字段是引用类型的,和上一个字段结构是一样的
4C
// 这个字段名字的长度为4
00 04
// 这个字段的名字:name
6E 61 6D 65
// TC_STRING
74
// 字符串的长度
00 12
// 字符串
Ljava/lang/String;
===结束部分===
// TC_ENDBLOCKDATA,表示这一块内容的结束
78
// TC_NULL,表示这个类没有父类
70
===字段对应的值===
// 第一个字段是int类型的,占四个字节,这里值为21
00 00 00 15
// 第二个字段是引用类型的,需要用一个对象描述块,因此需要用TC_OBJECT来标识其开始
73
// TC_CLASSDESC:类描述信息的开始
72
// 类全名的长度: 24
00 16
// 类全名: com.just.demo2.Address
63 6F ...... 73 73
// 8个字节的serialVersionUID
......
// SC_SERIALIZABLE: 标识这个类实现了Serializable接口
02
// 属性的数量
00 01
// 引用类型L
4C
// 属性名字的长度
00 04
// 属性名字:name
70 61 74 68

接下来的是一个关键。

71 表示 TC_REFERENCE

根据注释我们可以知道,这个标识的作用是引用已经写入序列化流中的对象类名,以免多次写入同一个类的类名导致序列化的结构的内容有没必要的部分。

TC_REFERENCE 标记之后,是一个整数 Int 类型的数据,也就是说它占四个字节,它生成的基数是00 7E 00 00baseWireHandle 常量)。


这个数据减去 baseWireHandle 常量的值再加一表示的是这个引用是在序列化流中的第几个声明过的。比如这里是 00 7E 00 02 ,就说明这个引用的是第三个声明的类( java.lang.String )。可以在使用第一个工具的时候发现其在声明每个类的时候已经标注了其的 handle 信息。

关于这里 handle 的定义说的更清楚一些:一个写入字节流的对象都会被赋予引用 Handle,并且这个引用 Handle 可以反向引用该对象(使用 TC_REFERENCE 结构,引用前面 handle 的值),引用 Handle 会从 0x7E0000 开始进行顺序赋值并且自动自增,一旦字节流发生了重置则该引用 Handle 会重新从 0x7E0000 开始。

然后就是常规的 78TC_ENDBLOCKDATA ),70TC_NULL )。最后就是两个字段的值。

1
2
3
4
5
6
7
8
9
10
11
12
// TC_STRING
74
// 字符串的长度
00 05
// 字符串: wuhan
77 75 68 61 74
// TC_STRING
74
// 字符串的长度
00 03
// 字符串: tom
74 6F 6D

示例三

最后看个 java 反序列化漏洞中常用的 TemplatesImpl 类来序列化分析分析。

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.demo3;

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;

public class Test {
public static void main(String[] args) throws IOException, ClassNotFoundException, NoSuchFieldException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
String TemplatesImpl = "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl";
Object templatesImpl = Class.forName(TemplatesImpl).getDeclaredConstructor(new Class[]{}).newInstance();

Field field = templatesImpl.getClass().getDeclaredField("_bytecodes");
field.setAccessible(true);
field.set(templatesImpl, new byte[][]{"demo".getBytes()});

Field field1 = templatesImpl.getClass().getDeclaredField("_name");
field1.setAccessible(true);
field1.set(templatesImpl, "test");

String path = "demo3.ser";
FileOutputStream fos = new FileOutputStream(path);
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(templatesImpl);
}
}

这里比较长,就只分析和前面不同的地方。

首先看到 serialVersionUID 的下一个部分,这里是 03

03ObjectStreamConstants 中找不到直接的对应,它其实是 SC_WRITE_METHOD | SC_SERIALIZABLE 的结果,表示这个类重写了 writeObject() 并且实现了 Serializable 接口。这也就是说,如果一个类满足多个 SC_XXXX ,那这一位应该是这些 | 后的结果。这在使用 SerializationDumper 工具的时候也可以看出来。


然后就是 9 个序列化了的字段:

1
2
3
4
5
6
7
8
9
10
11
int(49) _indentNumber
int(49) _transletIndex
boolean(5A) _useServicesMechanism
Ljava/lang/String; _accessExternalStylesheet
Lcom/sun/org/apache/xalan/internal/xsltc/runtime/Hashtable; _auxClasses
// 这个后面单独讲
[[B _bytecodes
[Ljava/lang/Class; _class
// 这个也单独后面讲
Ljava/lang/String; _name
Ljava/util/Properties; _outputProperties

第六个字段 _bytecodes 比较特殊,是 byte[][] 类型的。

关注蓝色这个部分。

首先是 5B 表示这个字段是数组类型的。然后是末尾的 5B 5B 42 ,其中两个 5B 表示这是二维数组,42 表示是 byte 类型的。

第七个字段 _class 也是类似的。开头的 5B 表示这个字段是数组类型的,末尾是一个 5B 开头的,说明其是一维数组,然后后面跟的就是其数组存放的元素类型。

这里可能会感觉有点问题,当字段是数组时,开头的标识都是 5B ,那怎么区分末尾的元素类型是基本类型还是引用类型的呢。这里我猜可能是通过根据引用类型的开头是 L 来区分的,这可能就是引用类型需要 L 开头的原因。

倒数第二个字段也比较特殊,不过前面在示例二中提到了。这里用到了反向引用 TC_REFERENCE0x71 ),后面跟的 00 7e 00 01 相对 baseWireHandle 的偏移是 1 ,说明其引用的是第二个流中声明过的引用类型,也就对应的是 Ljava/lang/String;

然后依次是 9 个字段的值:

1
2
3
4
5
6
7
8
9
10
11
// int: 0
00 00 00 00
// int: -1
FF FF FF FF
// boolean: false
00
// String: "all"
74 00 03 61 6C 6C
// Hashtable: null
70(TC_NULL)
// 后面的比较特殊单独讲

然后就是一个新的标识: TC_ARRAY0x75 )。表示后面需要序列化的是一个数组。然后是紧跟着 TC_CLASSDESC

然后 00 03 是类名的长度, 5B 5B 42 是类名 [[B ,表示 byte[][] 。然后是八个字节的 serialVersionUID ,然后是 SC_SERIALIZABLE ,然后是 00 00 表示这个类有 0 个字段(如果是 TC_ARRAY 数组类型,貌似这里都是 00 00 ,毕竟数组也不是个真的类,没有验证过,感兴趣的可以试试)。由于没有字段,然后就是 TC_ENDBLOCKDATATC_NULL ,然后 00 00 00 01 表示二维数组的第二维的长度为 1

然后继续是开启内层的一维数组描述字节 TC_ARRAYTC_CLASSDESC 。然后 00 02 表示类名长度,5B 42 表示类名 [B ,即一维字节数组。然后继续是八个字节的 serialVersionUID ,然后是 SC_SERIALIZABLE 。依旧是 00 00 表示这个类有 0 个字段。然后是 TC_ENDBLOCKDATATC_NULL ,然后 00 00 00 04 表示一维数组的长度为 4 ,也就对应示例代码中的 "demo" 字符串 。然后就是 "demo" 字符串本身了( 64 65 6D 6F )。

这里就结束了这个二维字节数组字段( _bytecodes )的序列化。然后是下一个字段的值,是 TC_NULL70 ),说明下个字段( _class )为 null

然后是 _name 字段的值 "test"

然后最后一个字段 _outputProperties 的值还是 TC_NULL

后面又是一个新的点,77 表示 TC_BLOCKDATA ,后面跟着的第一个字节表示再后一部分的长度,这里是 01 。然后再后一部分 00 表示 false 。最后以 TC_ENDBLOCKDATA 结尾。这一块其实按照常理是不存在的,这里的存在是因为 TemplatesImpl 类重写了 writeObject() 方法。在默认的序列化流程之后还调用了 writeBoolean() 方法。这一块的格式就是序列化 Boolean 数据时的格式。

总结

这里只是抛砖引玉来说明序列化的大致结构。更加细节的需要结合参考官方文档,源码和 SerializationDumper 工具来分析。这里基本上就能看得懂七七八八的了。后面给出一些标识的参考。

参考文章

https://blog.csdn.net/silentbalanceyh/article/details/8183849
https://docs.oracle.com/javase/8/docs/platform/serialization/spec/protocol.html