Java 序列化:协议、兼容与安全边界
结论基线:API 语义以 Java SE 17 为准,实现分析以 OpenJDK 17u
jdk-17.0.12+7为准。实验在本机 Oracle JDK 17.0.12(x86_64)实际运行。序列化是有状态协议 + 类形状耦合 + 任意代码触面三件事叠加:回引句柄解释"为什么两个引用读回还是同一个",serialVersionUID 解释"为什么加个字段就 InvalidClassException",ObjectInputFilter 解释"为什么反序列化是不可信输入的代码执行面"。
协议分层与句柄回引
ObjectOutputStream 写一个普通对象时做了四件事:写协议字节 TC_OBJECT(0x73)→ 写类描述符 → 把对象登记进句柄表 → 按类型(record/Externalizable/普通 Serializable)写数据。流的开头固定是 magic AC ED + 版本 00 05:
原码连续摘录(仅统一公共缩进,截取方法局部):ObjectOutputStream.java,jdk-17.0.12+7,L1426–L1445。
try {
desc.checkSerialize();
bout.writeByte(TC_OBJECT);
writeClassDesc(desc, false);
handles.assign(unshared ? null : obj);
if (desc.isRecord()) {
writeRecordData(obj, desc);
} else if (desc.isExternalizable() && !desc.isProxy()) {
writeExternalData((Externalizable) obj);
} else {
writeSerialData(obj, desc);
}
} finally {
if (extendedDebugInfo) {
debugInfoStack.pop();
}
}
}handles.assign 是回引机制的核心:每个已写出的对象获得一个递增句柄,之后再遇到同一对象不重复写数据,只写 TC_REFERENCE + 句柄号。读侧对称:
原码连续摘录(仅统一公共缩进,截取方法局部):ObjectInputStream.java,jdk-17.0.12+7,L1835–L1856。
private Object readHandle(boolean unshared) throws IOException {
if (bin.readByte() != TC_REFERENCE) {
throw new InternalError();
}
passHandle = bin.readInt() - baseWireHandle;
if (passHandle < 0 || passHandle >= handles.size()) {
throw new StreamCorruptedException(
String.format("invalid handle value: %08X", passHandle +
baseWireHandle));
}
if (unshared) {
// REMIND: what type of exception to throw here?
throw new InvalidObjectException(
"cannot read back reference as unshared");
}
Object obj = handles.lookupObject(passHandle);
if (obj == unsharedMarker) {
// REMIND: what type of exception to throw here?
throw new InvalidObjectException(
"cannot read back reference to unshared object");
}三个可观察后果(全部实测):自引用 node.next = node 序列化再读回,back.next == back 还是同一对象——不会无限递归,因为写第二个引用时走 TC_REFERENCE;共享引用同一 list 放进集合两处,读回 get(0) == get(1) 仍成立——序列化保持对象图身份,不是深拷贝;越界句柄抛 StreamCorruptedException("invalid handle value")。两类损坏流要区分处置:截断的流(字节不够)抛 EOFException,改坏的流抛 StreamCorruptedException(magic 不对、tag 非法、句柄越界等);恢复路径都是丢弃坏流、从可靠来源重新序列化,不要试图在半读状态上继续。
serialVersionUID:形状哈希与兼容矩阵
每个 Serializable 类在流里带一个 serialVersionUID。不显式声明时,JDK 按类形状现算:
原码连续摘录(仅统一公共缩进,截取方法局部):ObjectStreamClass.java,jdk-17.0.12+7,L1723–L1749。
private static long computeDefaultSUID(Class<?> cl) {
if (!Serializable.class.isAssignableFrom(cl) || Proxy.isProxyClass(cl))
{
return 0L;
}
try {
ByteArrayOutputStream bout = new ByteArrayOutputStream();
DataOutputStream dout = new DataOutputStream(bout);
dout.writeUTF(cl.getName());
int classMods = cl.getModifiers() &
(Modifier.PUBLIC | Modifier.FINAL |
Modifier.INTERFACE | Modifier.ABSTRACT);
/*
* compensate for javac bug in which ABSTRACT bit was set for an
* interface only if the interface declared methods
*/
Method[] methods = cl.getDeclaredMethods();
if ((classMods & Modifier.INTERFACE) != 0) {
classMods = (methods.length > 0) ?
(classMods | Modifier.ABSTRACT) :
(classMods & ~Modifier.ABSTRACT);
}
dout.writeInt(classMods);默认 SUID 是类名、修饰符、接口、字段、构造器、方法逐项哈希的结果——实验用真实 javac 编译了六个 Compat 版本:只加一个字段 int b,默认 SUID 从 -1087132073137349234 变成 1049006845098732798。也就是说任何无关于序列化数据的改动(加个私有工具方法也算)都会让旧流读不进新类。
读侧在绑定本地类描述符时做硬校验:
原码连续摘录(仅统一公共缩进,截取方法局部):ObjectStreamClass.java,jdk-17.0.12+7,L590–L598。
if (model.serializable == osc.serializable &&
!cl.isArray() && !cl.isRecord() &&
suid != osc.getSerialVersionUID()) {
throw new InvalidClassException(osc.name,
"local class incompatible: " +
"stream classdesc serialVersionUID = " + suid +
", local class serialVersionUID = " +
osc.getSerialVersionUID());
}注意豁免:数组与 record 不做 SUID 校验(它们有自己的演化规则)。实验的六版本矩阵给出完整结论:
| 变更 | 显式 SUID | 结果 |
|---|---|---|
新增字段 int b | 两侧 1L | 兼容:读回 b 取默认值 0 |
同名字段 int a 改 long a | 两侧 1L | InvalidClassException: incompatible types for field a |
| 显式 SUID 1L → 2L | 不同 | InvalidClassException: local class incompatible(消息带两侧 SUID) |
| 加字段但不显式声明 SUID | 默认计算 | 默认 SUID 变化,等效于上一行 |
工程结论:跨版本存活的序列化类必须显式声明 serialVersionUID,且把"兼容"理解准确——显式 SUID 只跳过 SUID 校验,字段层面的不兼容(改名、改类型、改非瞬态语义)仍会在读侧以异常或默认值暴露。删字段是"宽容"的(流里多余字段被忽略),改类型是"硬失败",改语义(同类型不同含义)是最危险的静默错:SUID 与字段都对得上,读回的对象含义已变,只有上层不变量校验能拦住。
定制钩子:writeObject、writeReplace、readResolve、Externalizable
普通路径之外有四个定制点,触发时点都在源码 writeOrdinaryObject 的分支里可见:
writeObject/readObject私有方法对:流头与字段数据之间插入自定义数据。典型用法是敏感字段加密或字段裁剪:先defaultWriteObject()写默认部分再追加;读侧对称。实验里Secrets把salt:payload合并成一个字符串写出,读回拆开——transient 字段不参与默认协议,但钩子里可以自己搬。writeReplace:写之前把对象整个换成另一个对象。实验里Carrier("data-42")序列化后流里实际是String,读回就是 String——跨类瘦身、序列化视图都靠它。readResolve:读回后把新实例替换成既有对象。单例防反序列化打穿就靠它:实验deserialize(serialize(Singleton.INSTANCE)) == Singleton.INSTANCE成立。Externalizable:完全接管协议(writeExternal/readExternal),要求 public 无参构造器,写什么读什么全凭手写——writeOrdinaryObject的writeExternalData分支。自由度最高,字段落一个字段或顺序错一位就是坏流。
钩子的顺序语义:writeReplace 先于 writeObject(替换后才谈得上写字段),readResolve 晚于 readObject(先读出新对象才能替换)。继承体系里每个类的钩子按声明顺序沿层次执行,父类子类都定义钩子时两层都会跑。
反序列化安全边界:不可信输入与 ObjectInputFilter
反序列化不只是"读数据":readObject 会创建对象并执行类定义的钩子代码(readObject/readResolve),历史漏洞(各类 gadget chain)都建立在"流里可以点名任意 classpath 上的类并触发其副作用"之上。所以边界规则是:Java 序列化流只能来自可信信道;对不可信来源,要么换格式(JSON/protobuf 等数据格式不点名执行代码),要么用过滤器硬限制。
JDK 9+ 的标准过滤机制是 JEP 290 ObjectInputFilter,可以在类、深度、引用数、字节数四个维度上裁决。实验验证了三条关键行为(均在 JDK 17 实测):
- 深度限制生效:
maxdepth=5的过滤器读 10 层链表抛InvalidClassException("filter status: REJECTED"),2 层通过。深度从根对象算起,中间的数组/字段回调class=null。 - 白名单不等于拒绝名单:
"MyApp.*;maxdepth=5"这种"纯白名单"对流里java.util.ArrayList返回 UNDECIDED 而不是 REJECTED——对象照常创建。要让未知类被拒绝,必须加全量否定项:"MyApp.*;!*;maxdepth=5"。这是过滤器最常被误解的语义(也是本轮实验的一次真实红转绿)。 - 过滤器必须设在读取之前:
setObjectInputFilter在第一次 readObject 之后调用抛IllegalStateException。
全局兜底:-Djdk.serialFilter=...(进程级)与进程内 ObjectInputFilter.Config.createFilter(...) 构造模式过滤器。防护结论的边界:本节实验只验证所列过滤器行为,不构成漏洞审计,也没有演示任何真实攻击载荷;生产过滤策略要按业务对象图实测深度/引用上界再收紧。
设计案例:会话缓存的序列化演进(算例,非实测)
负载假设:登录会话对象(用户 id、角色集、过期时间、灰度标记)序列化后存 Redis,峰值 5 万会话、TTL 30 分钟;滚动升级窗口内新旧版本应用并存 10 分钟,旧实例可能写、新实例读,反之亦然。不变量:任一版本写入的会话,两个版本都能读出或明确拒绝,绝不读出错误语义。
选择 1:显式 SUID + 字段只增不改语义 + 读侧校验。 每次加字段显式保持 SUID,新增字段读侧容忍默认值并在使用前校验(角色集为空视为降级登录)。成本:每次演进要写兼容矩阵测试(旧字节流 fixture + 新类);恢复责任:读侧拒绝时引导重新登录,语义可解释。10 分钟双版本窗口天然被覆盖。
选择 2:换 JSON 载荷 + 版本字段。 会话改写为带 v 字段的 JSON;序列化格式与类形状解耦,字段增删自由,跨语言可读。成本:一次迁移(新旧格式并存期的双读)、对象图身份语义丢失(共享引用变两份——会话场景无此需求);恢复责任同上。算例不是实测吞吐,用于说明选择差别在演进成本付在哪里:选择 1 每次演进付,选择 2 一次性付迁移。
两条都成立;不成立的是第三条暗路——不声明 SUID 依赖默认值。实验已证默认 SUID 对类形状敏感,滚动升级窗口内直接 InvalidClassException,会话全量失效。
条件变化与边界
| 条件变化 | 需要复核的点 |
|---|---|
| 类演进(加/删/改字段、改层次) | 兼容矩阵:显式 SUID 只解决 SUID 校验;改类型硬失败、改语义静默错;数组/record 豁免 SUID |
| 换 JDK 大版本 | record 序列化走 writeRecordData 独立分支(17 摘录可见);JDK 各版本过滤默认值与协议细节需按版本核对 |
| 不可信来源 | Java 序列化流不能来自不可信信道;过滤器要 !* 收口并实测深度/引用上界 |
| 对象图身份 | 回引保持 == 身份;改用 JSON 等格式后共享引用语义消失,依赖身份的逻辑要重新设计 |
| 流损坏 | 截断抛 EOFException、改坏抛 StreamCorruptedException;恢复 = 重新序列化,不在半读状态续读 |
| 跨类加载器 | 类按流中名字经调用方类加载器解析;容器/插件场景需覆盖 resolveClass 指向目标加载器(实验已用 URLClassLoader 验证) |
实验:输入、步骤、预期与边界
实验入口:labs/java-senior/run.sh io(EncodingIoProbe.java,与编码/IO 共用入口,各节输出可区分)。
U06 关键实测(Oracle JDK 17.0.12,x86_64;完整命令、stdout/stderr、退出状态与红绿记录见本批证据):
PASS protocol: magic ACED0005, self/next handle identity, shared ref identity, truncated EOF vs garbage StreamCorrupted, recovery via fresh bytes
PASS suid: default shape hash differs (-1087132073137349234 vs 1049006845098732798), explicit 1L stable, added field default 0, changed type incompatible, SUID mismatch InvalidClassException
PASS hooks: writeObject/readObject pair, readResolve singleton, writeReplace type swap, Externalizable manual protocol
PASS filter: depth 10 rejected, depth 2 allowed, class outside allowlist rejected via !* (bare allowlist leaves unknown classes UNDECIDED; JEP 290)场景与预期:magic 前四字节 AC ED 00 05;自引用/共享引用读回保持 ==;截断 12 字节抛 EOFException、{1,2,3,4} 抛 StreamCorruptedException、重新序列化恢复;六个真实 javac 编译的 Compat 版本(经 URLClassLoader 加载、覆盖 resolveClass 解析)验证 SUID 矩阵;四类钩子各自往返;过滤器 10 层拒绝/2 层允许/白名单外加 !* 才拒绝 ArrayList。负例(未知模式/多余参数/坏 JAVA_HOME/JDK21)统一退出 2。
边界:兼容矩阵只覆盖所列六种变更方向;record 演化、serialPersistentFields、readObjectNoData 未实验(正文未展开);过滤器实验不含任何真实攻击载荷,防护结论限所测行为;跨类加载器解析只验证了 URLClassLoader 场景。双写双读的滚动升级窗口是算例推导,未起双进程实测。
追问链
第一层:为什么自引用不会栈溢出?
写出侧第二次遇到同一对象走 TC_REFERENCE(句柄查表),不递归进字段;读侧 readHandle 从句柄表取回同一个半成品对象填 next。代价是循环引用合法且保持身份——如果业务把环当成异常,要在写入前自己检测,序列化层不会替你发现。
第二层:显式声明了 serialVersionUID 就一定兼容吗?
不。它只让 SUID 校验通过;字段层面的匹配另算:同名字段改类型直接 InvalidClassException,删字段宽容忽略,加字段取默认值,改语义则静默通过——这是唯一没有任何异常信号的不兼容。所以兼容性要靠 fixture 回放测试(旧流字节存进测试资源,新类读回断言语义),不能靠"没报错"。
第三层:writeReplace 和 readResolve 能做序列化代理,还缺什么?
缺不可变性验证。经典序列化代理模式(Effective Java)用 writeReplace 换成代理类、代理类 readResolve 换回正式类,正式类从此不直接出现在流里,防字段伪造。但代理的 readResolve 里必须重新执行构造约束(如范围检查),否则流伪造者绕过构造器直接获得非法状态。
第四层:评审一个仍在用 Java 序列化的接口,按什么顺序问?
- 流的来源可信吗?不可信信道上的 Java 序列化先判死刑。2. 有过滤器吗?白名单有没有
!*收口?深度/引用上界是实测的还是拍的?3. 类有显式 SUID 吗?兼容矩阵测试在哪里?改语义的演进走什么流程?4. 对象图有环/共享引用/身份依赖吗?换格式时谁来承接?5. 钩子(readObject/readResolve)里有没有做校验?反序列化出来的对象先过不变量再入业务。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| Java Serializable 规范(序列化方法/字段/演进规则) | Java SE 17 | 钩子协议、writeObject/readObject 契约、兼容指引 |
| Java SE 17 ObjectOutputStream | Java SE 17 | writeReplace/defaultWriteObject 契约 |
| Java SE 17 ObjectInputStream | Java SE 17 | readResolve/readObjectNoData/setObjectInputFilter 时点 |
| Java SE 17 ObjectStreamClass | Java SE 17 | serialVersionUID 与兼容语义 |
| JEP 290 Filter Incoming Serialization Data | JDK 9+ | 过滤器维度、UNDECIDED 语义、jdk.serialFilter |
| OpenJDK 17u ObjectOutputStream 源码 | jdk-17.0.12+7 | writeOrdinaryObject 分支与 handles.assign(L1426) |
| OpenJDK 17u ObjectInputStream 源码 | jdk-17.0.12+7 | readHandle 句柄校验(L1835) |
| OpenJDK 17u ObjectStreamClass 源码 | jdk-17.0.12+7 | computeDefaultSUID 与 initNonProxy 校验(L1723、L590) |
本地 EncodingIoProbe 实验 | Oracle JDK 17.0.12 | 协议/兼容矩阵/钩子/过滤器全部实测 |
访问日期:2026-10-25。源码行号以 jdk-17.0.12+7 标签为准。六版本兼容矩阵由真实 javax.tools 编译与 URLClassLoader 隔离加载产生,跨加载器 resolveClass 行为只在实验所述场景验证。
闭卷验收
本页 10 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。