Java安全学习笔记3--反序列化篇1

该篇笔记由Gemini3 pro preview模型总结而来。

1. 什么是反序列化?

在网络通信中,JSON 和 XML 只能传递基本数据类型(字符串、整型等)。如果想传输一个复杂的**Java 对象**(包含属性、状态甚至逻辑),就需要将其转换为二进制流(序列化),接收方再将其还原为对象(反序列化)。
  • 序列化 (Serialization): 对象 -> 二进制流 (writeObject)
  • 反序列化 (Deserialization): 二进制流 -> 对象 (readObject)

2. 跨语言反序列化对比 (核心理论)

理解不同语言的反序列化差异,是理解 Java 漏洞成因的关键。
语言 机制 核心特点 漏洞成因
Python (Pickle) 基于栈的虚拟机 序列化数据里直接包含了指令(Opcode)。 最危险。只要能传数据,就能直接让虚拟机执行任意指令/代码。
PHP (Unserialize) 属性还原 侧重于还原数据属性。依赖 __wakeup (醒来) 和 __destruct (销毁)。 漏洞通常由魔术方法触发,属于“反序列化”的初始化操作。
Java (readObject) 对象重建 侧重于自定义还原过程。允许开发者介入反序列化流程。 漏洞源于 readObject 方法。它不仅还原属性,还允许开发者在还原过程中执行自定义代码。

一句话总结: Java 的 readObject 倾向于解决“如何还原一个完整对象”的问题,给予了开发者极大的自由度,也埋下了隐患。


3. Java 序列化核心机制

3.1 两个关键方法

开发者可以在类中重写以下两个私有方法,来干预序列化过程:
  • writeObject(ObjectOutputStream s):
    • 决定如何把对象写入流。
    • 通常先调用 s.defaultWriteObject() 写入默认属性。
    • 关键点:可以调用 s.writeObject() 写入额外的数据(私货)。
  • readObject(ObjectInputStream s):
    • 决定如何从流里恢复对象。
    • 通常先调用 s.defaultReadObject() 恢复默认属性。
    • 关键点:必须按照写入顺序,调用 s.readObject() 读取那些额外的数据,并进行处理。

3.2 代码示例 (夹带私货)

Java

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

// 为了方便演示,把 Person 类和主类写在一个文件里
// Person 类去掉 public,只能在当前文件使用
class Person implements Serializable {
public String name;

// 自定义序列化
private void writeObject(ObjectOutputStream s) throws IOException {
s.defaultWriteObject(); // 写入默认属性 (name)
s.writeObject("This is extra data"); // 【私货】写入额外数据
}

// 自定义反序列化
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject(); // 读取默认属性
String msg = (String) s.readObject(); // 【私货】读取额外数据
System.out.println("反序列化成功,读到的私货是: " + msg);
}
}

public class SerializationTest {
public static void main(String[] args) throws Exception {
System.out.println("开始运行...");

// 1. 创建一个对象
Person p = new Person();
p.name = "Hacker";

// 2. 准备序列化流
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);

// 3. 开始序列化
oos.writeObject(p);
oos.close();

// 4. 获取序列化后的二进制数据
byte[] bytes = baos.toByteArray();

// 5. 打印 16 进制
System.out.println("=== 序列化数据的 16 进制展示 ===");
printHex(bytes);

// 6. 保存到文件 person.bin (会在项目根目录下生成)
try (FileOutputStream fos = new FileOutputStream("person.bin")) {
fos.write(bytes);
}
System.out.println("\n[+] 文件已保存为 person.bin");

// 7. 验证反序列化
System.out.println("\n=== 正在尝试反序列化... ===");
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes));
ois.readObject();
}

// 辅助工具:把字节数组打印成漂亮的 Hex 格式
public static void printHex(byte[] bytes) {
for (int i = 0; i < bytes.length; i++) {
System.out.printf("%02X", bytes[i]);
}
}
}

4. 序列化数据的内部结构 (底层协议)

通过 SerializationDumper 分析 16 进制数据,可以发现 Java 序列化流中有一个特殊的区域:

objectAnnotation (对象注解/附注)

  • 定义:当类实现了 writeObject 方法时,它写入的所有自定义数据(即上面的“私货”),都会被存储在 TC_OBJECT 下面的 objectAnnotation 块中。
  • 对比
    • classAnnotations: (RMI 篇提到) 存储类加载路径(Codebase URL),由 annotateClass 写入。
    • objectAnnotation: 存储对象自定义数据,由 writeObject 写入。

为什么它很重要?

  • HashMap 的例子HashMap 并没有把键值对(Key-Value)当成默认属性存储,而是全写进了 objectAnnotation 里。
  • 漏洞伏笔:反序列化 HashMap 时,readObject 会从这个区域读出 Key,并重新计算 Key 的 Hash 值。这一计算过程(hashCode() 方法的调用)是众多反序列化利用链(Gadget Chain)的第一张多米诺骨牌(如 URLDNS 链)。

5. 总结与审计思路

  • 攻击面:任何接收二进制流并执行 readObject 的地方(RMI, JMX, WebLogic, Jenkins 等)。
  • 利用核心
    • 攻击者构造恶意的序列化数据。
    • 服务端在 readObject 还原对象时,自动触发了恶意的代码逻辑。
  • 后续学习
    • 既然 HashMap 在反序列化时会重算 Hash,如果我把一个 URL 对象放进 HashMap 里,会发生什么?
    • 这就是下一篇要学的 URLDNS 利用链

复习小贴士
只要看到 implements Serializable,就要条件反射地去找有没有 readObject 方法。如果有,仔细看它在还原对象时,是不是处理了什么不受信任的“私货”。


Java安全学习笔记3--反序列化篇1
https://rightevil.github.io/Java安全学习笔记3-反序列化篇1/
作者
rightevil
发布于
2025年12月17日
许可协议