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。

实际开发中这个报错还有一种更隐蔽的情况:类本身实现了 Serializable,但它的某个字段(或字段递归引用到的某个对象)没有实现 Serializable,序列化时一样会抛 NotSerializableException,异常信息里会点名是哪个类"不配合"。排查时顺着异常给出的类名往对象图里找,把那个字段标记为 transient、或让对应类也实现接口即可。这个小细节再次印证了前面的结论:序列化是沿着对象图递归进行的,任何一个环节的类"不可序列化",整条流程都会失败。

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

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

  • ObjectOutputStream 的 writeObject(obj):把对象写成字节;
  • ObjectInputStream 的 readObject():从字节读回对象,返回值是 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 绕过构造器,直接按照字节流里记录的字段值,把对象"拼"出来。

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

还有一个容易被忽略、但很能说明问题的现象:反序列化得到的,是一个"全新的对象",和原来那个对象不是同一个。 你可以在上面的例子里再加两行验证:

java
System.out.println(user == back);        // false
System.out.println(user.equals(back));   // 取决于有没有重写 equals

== 比较的是"是不是堆里同一个对象",结果是 false——哪怕两个对象的字段一模一样,它们也是内存里两个独立的实例。序列化保存的是对象的"状态(字段值)",而不是对象的"身份(内存地址)"。理解这一点,后面看到流里用"句柄(handle)"来表示"这里还是刚才那个对象"时就不会困惑:流能记录对象之间的引用关系,但还原后它们是一批新实例。

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

很多人以为序列化是把整个对象"拍照"存下来,其实不是。它只保存对象的非静态、非 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;
    // ...
}

序列化再反序列化后:

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

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

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

更准确地说,序列化保存的不是一棵简单的"树",而是一张对象图(Object Graph) ,它还能正确处理"共享"和"循环"两种特殊关系,这两点对理解反序列化机制很关键。

共享引用:假设两个字段 a 和 b 恰好指向同一个对象 X,序列化流不会傻乎乎地把 X 存两份,而是第一次存完整内容、第二次只写一个"句柄(handle)"表示"这里还是前面那个 X"。反序列化后,a 和 b 依然指向同一个还原对象,而不是两个副本。

循环引用:对象 A 里有字段指向 B,B 里又有字段指回 A,形成一个环。如果序列化只是无脑递归,遇到环就会无限递归、栈溢出;正是靠"句柄"机制——已经写过的对象再遇到时只写引用——循环才不会导致死循环。

你不用记住句柄的二进制编码,只要建立一个清晰的认识:序列化流有能力把结构复杂、相互引用、甚至成环的对象图完整地记录下来,并在反序列化时按同样的关系重建。 这意味着攻击者可以在字节流里精心布置一个"嵌套很多层、彼此引用"的对象结构——gadget 链正是这样被"摆"出来的。至于 JVM 在重建时按什么顺序处理这些对象、又会在哪些点上触发方法调用,那是 Part 2 钻进 readObject 时要讲的内容。

五、那串字节长什么样? ​

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

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

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

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

当序列化数据走 HTTP 传输时,因为原始字节里有大量不可打印字符,通常会先用 Base64 编码再放进 Cookie、请求参数或请求体里。这时你看不到原始的 AC ED,但会看到以 rO0ABX 开头的一大串字符(Base64 后前几个字节对应的特征)。做测试时,识别到这种特征,基本就能判断"这个位置在收发 Java 序列化对象",进而思考"我能不能控制这串内容、服务端读它时会不会反序列化"。

你可以用一个最小实验亲手验证魔数:把任意一个实现了 Serializable 的对象用 writeObject 写到文件,再用任意十六进制查看工具(或编辑器的二进制模式)打开,开头四个字节必然是 AC ED 00 05;把文件内容做一次 Base64,开头则是 rO0ABX...。这两个特征不需要背,多遇到两次自然就记住了,但它们是你在抓包、看日志时快速判断"这里是不是 Java 序列化"的第一线索。

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

前面代码里反复出现:

java
private static final long serialVersionUID = 1L;

它相当于类的版本号。序列化时这个号会写进字节流;反序列化时,JVM 拿流里的版本号和当前类的版本号比对:

  • 对得上:正常还原;
  • 对不上:抛 InvalidClassException,拒绝还原。

如果你不手写,JVM 会根据类的字段、方法等自动算一个。但自动算的结果对类的改动极其敏感——你只是加了个无关紧要的方法,版本号就变了,旧数据反序列化直接失败。所以工程上一般显式写死 serialVersionUID,由你自己控制什么时候算"不兼容"。

这一点对安全也有启发:反序列化是一个严格依赖"类定义"的过程。流里记录的类名、字段,必须在当前 classpath 上能找到、能对上,对象才拼得出来。这也解释了为什么利用链(gadget)必须是目标环境里真实存在的类。

最后补充几个工程上常踩的坑,帮你把这套机制理解得更完整:

1. 父类能不能序列化,会影响子类。 如果父类实现了 Serializable,子类自然也可序列化;反过来,子类自己实现了 Serializable、但父类没有,那么反序列化时父类那部分字段没法从流里恢复,JVM 会转而调用父类的无参构造器来初始化父类部分——这也是反序列化过程中少数会调用构造器的情况。排查"为什么某个字段还原后是默认值"时,别忘了往继承关系上想。

2. 类可以"自定义"序列化过程。 一个可序列化类里如果定义了符合特定签名的 private void writeObject(...) / readObject(...),JVM 在序列化/反序列化时会回调这些方法,让你自己控制怎么写、怎么读。这是 Part 2 的核心——因为这些自定义方法里可以写任意逻辑,它们正是反序列化能"执行代码"的第一批触发点,这里先留个印象。

3. 敏感字段别只靠 transient。 transient 能让字段不进流,但如果确实要传输敏感数据,更稳妥的做法是对整个字节流加密或做签名;否则一旦流被截获、被篡改,照样出问题。事实上"对不可信来源的字节流做反序列化"本身就是一切麻烦的根源——这个判断会在后面的防御篇反复出现。

七、最关键的安全直觉:流里没有"新代码" ​

学到这里,必须先纠正一个几乎所有新手都会有的误解。很多人一听"反序列化能 RCE(远程代码执行)",就以为攻击者把一段恶意的程序字节码塞进了序列化流、目标读流时就把这段新程序"装"进了自己的 JVM。事实并非如此。

回到前面的结论:序列化只保存字段的状态,不保存方法的字节码。方法的代码来自目标 classpath 上已经存在的那些 .class 文件,流里根本没有、也无法携带"新的方法实现"。所以反序列化本身不能凭空给目标注入一段它本来没有的代码。

那 RCE 又是怎么发生的?关键在于:反序列化在重建对象图的过程中,会"按部就班"地调用目标里已有类的已有方法(比如某些类在 readObject、在 getter、在 hashCode 里会执行动作)。攻击者做的事,不是发明新代码,而是像搭积木一样,把目标环境里本来就存在的、看似无害的类和方法,精心串成一条链,让重建对象时的正常调用,最终走到一个能执行命令的方法上。这些被借用的类,行话叫 gadget(小配件) ,串起来的路径就叫 gadget chain(利用链)。

常见误解实际情况
流里带着恶意的新字节码流里只有字段数据,没有方法代码
反序列化给目标"装"了新程序不会,代码全部来自目标已有的类
攻击者凭空创造了执行逻辑攻击者只是复用、拼接目标已有的方法调用
随便什么环境都能用同一条链链上的类必须在目标 classpath 真实存在且版本匹配

这也解释了两个贯穿全系列的根本约束:第一,gadget 必须是目标环境里真实存在的类(所以探测依赖、指纹识别很重要);第二,防御的一大方向就是"别让反序列化这个动作发生在不可信数据上",或减少 classpath 上可被串成链的类。从下一篇开始,我们就顺着"JVM 重建对象时到底会调用哪些方法"这条线,一步步看清这条链是怎么搭起来、又是怎么被拦住的。

八、威胁模型:不可信的字节流,从哪进来 ​

反序列化漏洞要成立,有一个不可缺少的前提:程序把"来自不可信来源的字节"直接交给了 ObjectInputStream.readObject()(或等价的反序列化入口)。 那么在真实系统里,这些可能被攻击者控制的序列化数据,通常从哪些位置进来?认识这些入口,既能帮你理解攻击面,也是后面做代码审计时要重点搜的地方。

入口数据怎么被攻击者控制
RMI / 自定义 RPC远程调用在网络上传输序列化对象,暴露的服务会读取调用方发来的字节
JMX / 管理端口管理协议同样基于序列化,端口暴露在外时可被直接投递
自研 TCP / Socket 协议程序自己约定"前几个字节是长度、后面是序列化对象",读取客户端数据
Session / 缓存 / 会话存储若序列化后的会话或缓存可被写入、污染,再被服务端读回
消息队列 / 任务队列消息体使用 Java 序列化,能投递消息者即可控制字节
文件 / 上传 / 数据库字段程序从文件、上传内容或数据库里取出序列化字节再还原

这里有个关键的安全判断:只要反序列化的数据可能被外部影响,无论它是直接从网络读、还是先存进缓存/文件再读回,都应视为"不可信数据"。 很多真实漏洞恰恰出在"开发者以为这个缓存/内部端口只有自己人能碰到",结果该位置可以被间接写入或越权访问。

简单交代一下背景,帮你理解为什么这类问题在安全史上分量这么重:Java 生态庞大,大量第三方基础库(最典型的是 Apache Commons Collections)里存在"可被串成链"的类;当这些库几乎出现在每个 Java 应用的 classpath 上、而有人把现成的利用链用工具(ysoserial 是最知名的代表)整理出来后,"反序列化"就从理论风险变成了可以批量复现的高危问题,影响面极广。本系列后面的每一条链,本质上都是在带你看懂:攻击者用了哪些"看似无害"的现成类、按什么顺序把它们串起来、最终如何走到执行命令那一步。

九、延伸对比:原生序列化 vs JSON(Fastjson)序列化 ​

这个系列后半会专门讲 Fastjson,这里先把两种"序列化"的根本区别讲清楚,等你学到 Fastjson 时会轻松很多。

原生 Java 序列化(本篇讲的 writeObject)产出的是二进制字节流,里面天然带着完整的类型信息——类名、字段名、字段类型一应俱全,反序列化时 JVM 照着这些信息把对象拼回来。它的问题是"天生携带类型",一旦对不可信数据反序列化,就可能触发前面说的 gadget 链。

JSON 序列化(Fastjson、Jackson 等)产出的是人能读懂的文本,形如 {"name":"小明","age":18}。标准 JSON 只记录数据、不记录类型——你拿到这段文本,并不知道它原本该还原成 User 还是别的类。为了"自动把 JSON 还原回具体类型的对象",Fastjson 提供了一个特殊字段 @type,用来在 JSON 里写明"这段数据是什么类"。

对比项原生 Java 序列化JSON(Fastjson)序列化
数据形态二进制(AC ED 00 05)文本({ ... })
是否带类型信息天然带完整类型默认不带,靠 @type 指定
还原方式JVM 按类型信息重建解析器按 @type 找类、填字段
风险根源重建时触发已有方法、串成 gadget 链攻击者指定 @type 让程序加载/实例化危险类

请记住这个关键对应:原生反序列化的风险,在于"重建对象图时会触发已有方法";Fastjson 的风险,在于"@type 让攻击者能指定要实例化哪个类"。 两者表现不同,但根子上是同一句话——不可信数据被程序"自动地、带着类型地"还原成了对象。理解了这层,你就抓住了整个系列(从原生链到 Fastjson)共同的主线。

十、本篇小结 ​

  • 序列化是把内存对象变成可存储、可传输的字节;反序列化是把字节变回对象,典型场景是 RMI、Session、缓存、消息队列。
  • 类要实现标记接口 Serializable 才能被序列化。
  • 序列化只保存非 static、非 transient 的实例字段,并对引用对象递归保存。
  • 反序列化不走构造器,而是按字节流直接把对象拼出来——这是一条隐藏的"造对象"通道。
  • 序列化流以 AC ED 00 05(Base64:rO0ABX)开头;serialVersionUID 用于版本校验。

到这里你已经知道"对象怎么变成字节"。但反序列化具体是怎么一步步把对象拼回来的?为什么说这个过程里会"执行代码"?下一篇 5.12 readObject、钩子与递归还原 我们钻进 readObject 内部看清楚。

学习这一篇时,不必纠结序列化协议里每个字节的含义,那是写底层框架才需要的细节;对做安全的人来说,真正要带走的是三条"直觉",后面每一篇都会反复用到:其一,反序列化是一条不走构造器、却能创建对象的隐藏通道;其二,流里没有新代码,能被利用的只有目标 classpath 上已经存在的类和方法;其三,只要程序会对"可能被外部影响的字节"做反序列化,攻击面就存在。建议你动手把本文的最小例子敲一遍、亲眼看到 AC ED 00 05 和"构造器只打印一次",这种亲手验证得到的体感,比读十遍结论都牢靠。