Skip to content
📌知识点小结
  • 序列化把内存对象变成可存储、可传输的字节,反序列化把字节变回对象;典型场景是 RMI、Session 持久化、缓存、消息队列。
  • 类要实现标记接口 Serializable(接口里没有方法,只负责“盖章”)才能被序列化,否则运行时抛 NotSerializableException。
  • 序列化只保存非 static、非 transient 的实例字段,字段引用的对象会被递归保存。
  • 反序列化不调用构造器,而是直接按字节流把对象“拼”出来,是一条隐藏的造对象通道。
  • 序列化流以 AC ED 00 05(Base64 形式 rO0ABX)开头;serialVersionUID 用于反序列化时的版本校验。
自测一下

5.11 序列化:一个对象,是怎么变成一串字节的

这是「Java 反序列化深度」系列的 Part 1,免费。 学完你会明白:为什么 Java 要把对象"冻"成字节、Serializable 到底是个什么东西、序列化时哪些东西被保存、哪些被丢掉。 后面 Part 2 讲 readObject 反序列化的内部过程,Part 3 讲 gadget(利用链)的本质,Part 4 手搓第一条链。

一、先想清楚一个问题:对象为什么要"变身"

程序跑起来的时候,一个 User 对象是活的——它躺在 JVM 的堆内存里,有字段、有方法,能被程序调用。

但内存里的东西有两个致命问题:

  1. 程序一关,内存清空,对象就没了。 你没法把它存到磁盘上,下次开机接着用。
  2. 内存不能跨机器。 你没法把这台 JVM 堆里的对象,直接"搬"到另一台服务器的 JVM 里。

于是 Java 提供了一套机制:把内存里的对象,转换成一串可以保存、可以传输的字节。这个过程叫序列化(Serialization);反过来,把字节重新还原成内存里的对象,叫反序列化(Deserialization)

你可以把它理解成冻肉和解冻

  • 序列化 = 把活对象"冻"成一块能长期存放、能快递的肉;
  • 反序列化 = 收到后"解冻",对象重新活过来。

这套机制在 Java 世界里无处不在:RMI 远程调用、Session 持久化、缓存(Redis / 内存网格)、消息队列、某些 RPC 框架……底层都在做序列化。而反序列化漏洞,恰恰就藏在"解冻"这个动作里。 要理解漏洞,先得把序列化本身搞明白。

二、让一个类"可被序列化":实现 Serializable

Java 里不是所有对象都能被序列化的。一个类想获得这个能力,必须实现一个叫 Serializable 的接口:

java
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 长这样:

java
public interface Serializable {
    // 里面一个方法都没有!
}

它是一个标记接口(marker interface)——接口里一个方法都没有,它的唯一作用就是给类"盖个章",告诉 JVM:"这个类我设计过,可以被序列化。"

如果一个类没实现 Serializable 你就硬要序列化它,运行时会抛出 java.io.NotSerializableException

三、真正动手:序列化 + 反序列化

Java 用两个流来完成这件事:

  • ObjectOutputStreamwriteObject(obj):把对象写成字节;
  • ObjectInputStreamreadObject():从字节读回对象,返回值是 Object,需要强转。
java
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}
    }
}

运行输出:

text
构造器被调用了
User{name=小明, age=18}

"构造器被调用了"只打印了一次。 这是第一个反直觉、也是理解漏洞的关键点:

反序列化创建对象时,不会调用类的构造器

想想也合理:构造器往往负责初始化、校验、开资源,而反序列化是要"还原对象当时的状态",如果再跑一遍构造器,可能把还原好的字段冲掉。所以 JVM 绕过构造器,直接按照字节流里记录的字段值,把对象"拼"出来

记住这句话——反序列化是一条不经过构造器的"造对象"通道。既然它能造对象,就可能被人利用来造出"不该被造出的东西"。这是后话。

四、序列化到底保存了什么?(重点表格)

很多人以为序列化是把整个对象"拍照"存下来,其实不是。它只保存对象的非静态、非 transient 的实例字段,而且是递归保存的。

成员类型是否被序列化说明
普通实例字段(基本类型)✅ 是int age,直接存值
普通实例字段(引用类型)✅ 是(递归)字段指向的对象也会被一起序列化
static 静态字段❌ 否静态字段属于"类",不属于某个对象
transient 修饰的字段❌ 否主动告诉 JVM:这个字段别存
类的方法(代码)❌ 否方法在 .class 里,靠类定义恢复
serialVersionUID✅ 写入流头用于反序列化时校验版本

举个例子,给 User 加两个字段:

java
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;
    // ...
}

序列化再反序列化后:

  • nameage 正常恢复;
  • password 变成 null(引用类型默认值),没有被保存;
  • platform 不参与序列化,它的值来自当前 JVM 里加载的类。

**"递归保存引用对象"**这一点尤其重要。假设 User 里有个 Address addr 字段,序列化 User 时,addr 指向的 Address 对象也会被一起写进字节流;如果 Address 没实现 Serializable,同样会抛 NotSerializableException

这种"一个对象牵着一串对象,递归地全部序列化"的特性,正是后面 gadget 链能够一环扣一环触发的物理基础——字节流里可以嵌套非常深的对象结构。

五、那串字节长什么样?

用十六进制打开 user.bin,开头一定是这两个字节:

text
AC ED 00 05
  • AC EDSTREAM_MAGIC(魔数),相当于这个序列化流的"身份证",JVM 一看到它就知道"这是 Java 序列化数据";
  • 00 05STREAM_VERSION,序列化协议版本号。

后面依次是:类描述(类名、serialVersionUID、字段列表)、字段的值、引用对象的描述…… 现在不要求你看懂每一个字节,但要建立一个印象:这是一份有固定格式、JVM 能照着它一步步把对象拼回来的数据结构。

安全上的小习惯:在流量或文件里看到 AC ED 00 05,或者它的 Base64 形式 rO0ABXAC ED 00 05 做 Base64 后的开头),基本就能判断"这里有 Java 序列化数据",值得多看一眼。

六、serialVersionUID:版本"对不上"会怎样

前面代码里反复出现:

java
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 内部看清楚。