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。
实际开发中这个报错还有一种更隐蔽的情况:类本身实现了 Serializable,但它的某个字段(或字段递归引用到的某个对象)没有实现 Serializable,序列化时一样会抛 NotSerializableException,异常信息里会点名是哪个类"不配合"。排查时顺着异常给出的类名往对象图里找,把那个字段标记为 transient、或让对应类也实现接口即可。这个小细节再次印证了前面的结论:序列化是沿着对象图递归进行的,任何一个环节的类"不可序列化",整条流程都会失败。
三、真正动手:序列化 + 反序列化
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 绕过构造器,直接按照字节流里记录的字段值,把对象"拼"出来。
记住这句话——反序列化是一条不经过构造器的"造对象"通道。既然它能造对象,就可能被人利用来造出"不该被造出的东西"。这是后话。
还有一个容易被忽略、但很能说明问题的现象:反序列化得到的,是一个"全新的对象",和原来那个对象不是同一个。 你可以在上面的例子里再加两行验证:
System.out.println(user == back); // false
System.out.println(user.equals(back)); // 取决于有没有重写 equals== 比较的是"是不是堆里同一个对象",结果是 false——哪怕两个对象的字段一模一样,它们也是内存里两个独立的实例。序列化保存的是对象的"状态(字段值)",而不是对象的"身份(内存地址)"。理解这一点,后面看到流里用"句柄(handle)"来表示"这里还是刚才那个对象"时就不会困惑:流能记录对象之间的引用关系,但还原后它们是一批新实例。
四、序列化到底保存了什么?(重点表格)
很多人以为序列化是把整个对象"拍照"存下来,其实不是。它只保存对象的非静态、非 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 链能够一环扣一环触发的物理基础——字节流里可以嵌套非常深的对象结构。
更准确地说,序列化保存的不是一棵简单的"树",而是一张对象图(Object Graph) ,它还能正确处理"共享"和"循环"两种特殊关系,这两点对理解反序列化机制很关键。
共享引用:假设两个字段 a 和 b 恰好指向同一个对象 X,序列化流不会傻乎乎地把 X 存两份,而是第一次存完整内容、第二次只写一个"句柄(handle)"表示"这里还是前面那个 X"。反序列化后,a 和 b 依然指向同一个还原对象,而不是两个副本。
循环引用:对象 A 里有字段指向 B,B 里又有字段指回 A,形成一个环。如果序列化只是无脑递归,遇到环就会无限递归、栈溢出;正是靠"句柄"机制——已经写过的对象再遇到时只写引用——循环才不会导致死循环。
你不用记住句柄的二进制编码,只要建立一个清晰的认识:序列化流有能力把结构复杂、相互引用、甚至成环的对象图完整地记录下来,并在反序列化时按同样的关系重建。 这意味着攻击者可以在字节流里精心布置一个"嵌套很多层、彼此引用"的对象结构——gadget 链正是这样被"摆"出来的。至于 JVM 在重建时按什么顺序处理这些对象、又会在哪些点上触发方法调用,那是 Part 2 钻进 readObject 时要讲的内容。
五、那串字节长什么样?
用十六进制打开 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 序列化数据",值得多看一眼。
当序列化数据走 HTTP 传输时,因为原始字节里有大量不可打印字符,通常会先用 Base64 编码再放进 Cookie、请求参数或请求体里。这时你看不到原始的 AC ED,但会看到以 rO0ABX 开头的一大串字符(Base64 后前几个字节对应的特征)。做测试时,识别到这种特征,基本就能判断"这个位置在收发 Java 序列化对象",进而思考"我能不能控制这串内容、服务端读它时会不会反序列化"。
你可以用一个最小实验亲手验证魔数:把任意一个实现了 Serializable 的对象用 writeObject 写到文件,再用任意十六进制查看工具(或编辑器的二进制模式)打开,开头四个字节必然是 AC ED 00 05;把文件内容做一次 Base64,开头则是 rO0ABX...。这两个特征不需要背,多遇到两次自然就记住了,但它们是你在抓包、看日志时快速判断"这里是不是 Java 序列化"的第一线索。
六、serialVersionUID:版本"对不上"会怎样
前面代码里反复出现:
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 和"构造器只打印一次",这种亲手验证得到的体感,比读十遍结论都牢靠。