5.11 序列化:一个对象,是怎么变成一串字节的
这是「Java 反序列化深度」系列的 Part 1,免费。 学完你会明白:为什么 Java 要把对象"冻"成字节、
Serializable到底是个什么东西、序列化时哪些东西被保存、哪些被丢掉。 后面 Part 2 讲readObject反序列化的内部过程,Part 3 讲 gadget(利用链)的本质,Part 4 手搓第一条链。
一、先想清楚一个问题:对象为什么要"变身"
程序跑起来的时候,一个 User 对象是活的——它躺在 JVM 的堆内存里,有字段、有方法,能被程序调用。
但内存里的东西有两个致命问题:
- 程序一关,内存清空,对象就没了。 你没法把它存到磁盘上,下次开机接着用。
- 内存不能跨机器。 你没法把这台 JVM 堆里的对象,直接"搬"到另一台服务器的 JVM 里。
于是 Java 提供了一套机制:把内存里的对象,转换成一串可以保存、可以传输的字节。这个过程叫序列化(Serialization);反过来,把字节重新还原成内存里的对象,叫反序列化(Deserialization)。
你可以把它理解成冻肉和解冻:
- 序列化 = 把活对象"冻"成一块能长期存放、能快递的肉;
- 反序列化 = 收到后"解冻",对象重新活过来。
这套机制在 Java 世界里无处不在:RMI 远程调用、Session 持久化、缓存(Redis / 内存网格)、消息队列、某些 RPC 框架……底层都在做序列化。而反序列化漏洞,恰恰就藏在"解冻"这个动作里。 要理解漏洞,先得把序列化本身搞明白。
二、让一个类"可被序列化":实现 Serializable
Java 里不是所有对象都能被序列化的。一个类想获得这个能力,必须实现一个叫 Serializable 的接口:
import java.io.Serializable;
public class User implements Serializable {
private static final long serialVersionUID = 1L; // 作用后面讲
private String name;
private int age;
public User(String name, int age) {
this.name = name;
this.age = age;
System.out.println("构造器被调用了");
}
public String toString() {
return "User{name=" + name + ", age=" + age + "}";
}
}注意 Serializable 长这样:
public interface Serializable {
// 里面一个方法都没有!
}它是一个标记接口(marker interface)——接口里一个方法都没有,它的唯一作用就是给类"盖个章",告诉 JVM:"这个类我设计过,可以被序列化。"
如果一个类没实现 Serializable 你就硬要序列化它,运行时会抛出 java.io.NotSerializableException。
三、真正动手:序列化 + 反序列化
Java 用两个流来完成这件事:
ObjectOutputStream的writeObject(obj):把对象写成字节;ObjectInputStream的readObject():从字节读回对象,返回值是Object,需要强转。
import java.io.*;
public class SerialDemo {
public static void main(String[] args) throws Exception {
User user = new User("小明", 18);
// 1. 序列化:对象 → 文件 user.bin
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("user.bin"));
oos.writeObject(user);
oos.close();
// 2. 反序列化:文件 → 对象
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("user.bin"));
User back = (User) ois.readObject();
ois.close();
System.out.println(back); // User{name=小明, age=18}
}
}运行输出:
构造器被调用了
User{name=小明, age=18}"构造器被调用了"只打印了一次。 这是第一个反直觉、也是理解漏洞的关键点:
反序列化创建对象时,不会调用类的构造器。
想想也合理:构造器往往负责初始化、校验、开资源,而反序列化是要"还原对象当时的状态",如果再跑一遍构造器,可能把还原好的字段冲掉。所以 JVM 绕过构造器,直接按照字节流里记录的字段值,把对象"拼"出来。
记住这句话——反序列化是一条不经过构造器的"造对象"通道。既然它能造对象,就可能被人利用来造出"不该被造出的东西"。这是后话。
四、序列化到底保存了什么?(重点表格)
很多人以为序列化是把整个对象"拍照"存下来,其实不是。它只保存对象的非静态、非 transient 的实例字段,而且是递归保存的。
| 成员类型 | 是否被序列化 | 说明 |
|---|---|---|
| 普通实例字段(基本类型) | ✅ 是 | 如 int age,直接存值 |
| 普通实例字段(引用类型) | ✅ 是(递归) | 字段指向的对象也会被一起序列化 |
static 静态字段 | ❌ 否 | 静态字段属于"类",不属于某个对象 |
transient 修饰的字段 | ❌ 否 | 主动告诉 JVM:这个字段别存 |
| 类的方法(代码) | ❌ 否 | 方法在 .class 里,靠类定义恢复 |
serialVersionUID | ✅ 写入流头 | 用于反序列化时校验版本 |
举个例子,给 User 加两个字段:
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private transient String password; // 敏感信息,不序列化
private static String platform = "WEB"; // 静态,不序列化
private int age;
// ...
}序列化再反序列化后:
name、age正常恢复;password变成null(引用类型默认值),没有被保存;platform不参与序列化,它的值来自当前 JVM 里加载的类。
**"递归保存引用对象"**这一点尤其重要。假设 User 里有个 Address addr 字段,序列化 User 时,addr 指向的 Address 对象也会被一起写进字节流;如果 Address 没实现 Serializable,同样会抛 NotSerializableException。
这种"一个对象牵着一串对象,递归地全部序列化"的特性,正是后面 gadget 链能够一环扣一环触发的物理基础——字节流里可以嵌套非常深的对象结构。
五、那串字节长什么样?
用十六进制打开 user.bin,开头一定是这两个字节:
AC ED 00 05AC ED叫 STREAM_MAGIC(魔数),相当于这个序列化流的"身份证",JVM 一看到它就知道"这是 Java 序列化数据";00 05是 STREAM_VERSION,序列化协议版本号。
后面依次是:类描述(类名、serialVersionUID、字段列表)、字段的值、引用对象的描述…… 现在不要求你看懂每一个字节,但要建立一个印象:这是一份有固定格式、JVM 能照着它一步步把对象拼回来的数据结构。
安全上的小习惯:在流量或文件里看到
AC ED 00 05,或者它的 Base64 形式rO0ABX(AC ED 00 05做 Base64 后的开头),基本就能判断"这里有 Java 序列化数据",值得多看一眼。
六、serialVersionUID:版本"对不上"会怎样
前面代码里反复出现:
private static final long serialVersionUID = 1L;它相当于类的版本号。序列化时这个号会写进字节流;反序列化时,JVM 拿流里的版本号和当前类的版本号比对:
- 对得上:正常还原;
- 对不上:抛
InvalidClassException,拒绝还原。
如果你不手写,JVM 会根据类的字段、方法等自动算一个。但自动算的结果对类的改动极其敏感——你只是加了个无关紧要的方法,版本号就变了,旧数据反序列化直接失败。所以工程上一般显式写死 serialVersionUID,由你自己控制什么时候算"不兼容"。
这一点对安全也有启发:反序列化是一个严格依赖"类定义"的过程。流里记录的类名、字段,必须在当前 classpath 上能找到、能对上,对象才拼得出来。这也解释了为什么利用链(gadget)必须是目标环境里真实存在的类。
七、本篇小结
- 序列化是把内存对象变成可存储、可传输的字节;反序列化是把字节变回对象,典型场景是 RMI、Session、缓存、消息队列。
- 类要实现标记接口
Serializable才能被序列化。 - 序列化只保存非 static、非 transient 的实例字段,并对引用对象递归保存。
- 反序列化不走构造器,而是按字节流直接把对象拼出来——这是一条隐藏的"造对象"通道。
- 序列化流以
AC ED 00 05(Base64:rO0ABX)开头;serialVersionUID用于版本校验。
到这里你已经知道"对象怎么变成字节"。但反序列化具体是怎么一步步把对象拼回来的?为什么说这个过程里会"执行代码"?下一篇 5.12 readObject、钩子与递归还原 我们钻进 readObject 内部看清楚。