Java 序列化的兼容与安全边界
Java 原生序列化可以把对象图写入字节流,再按类信息恢复对象。它方便保存短期、受控的 JVM 内部状态,但格式与 Java 类型绑定紧密,反序列化还会执行类型相关逻辑,不适合作为默认的外部数据协议。
1. Serializable 怎样工作
类实现标记接口 Serializable 后,可以交给 ObjectOutputStream:
public final class UserProfile implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final long userId;
private final String displayName;
public UserProfile(long userId, String displayName) {
this.userId = userId;
this.displayName = displayName;
}
}
写入对象:
try (ObjectOutputStream output = new ObjectOutputStream(
Files.newOutputStream(path)
)) {
output.writeObject(profile);
}
读取对象:
try (ObjectInputStream input = new ObjectInputStream(
Files.newInputStream(path)
)) {
UserProfile profile = (UserProfile) input.readObject();
}
序列化会沿引用关系处理对象图。一个非 transient 实例字段指向不可序列化对象时,写入可能抛出 NotSerializableException。
1.1 哪些字段不会按默认规则写入
static字段属于类,不属于单个对象状态。transient字段被默认序列化机制忽略。
private transient String accessToken;
transient 可以避免写入临时缓存、文件句柄或敏感派生值,但不是完整安全策略。其他字段、嵌套对象和自定义写入方法仍可能泄漏信息。
1.2 反序列化不等于普通 new
恢复可序列化类时,不会按普通创建流程调用该类的构造方法。继承层次中第一个不可序列化父类的可访问无参构造方法会执行,然后序列化状态被恢复。
因此不能只依赖可序列化类构造器维护不变量。读取外部数据时,还需要在反序列化钩子中校验状态。
2. serialVersionUID 表示序列化版本身份
反序列化会比较流中的类版本标识与本地类的 serialVersionUID。不匹配时抛出 InvalidClassException。
@Serial
private static final long serialVersionUID = 1L;
没有显式声明时,JVM 会根据类的结构计算默认值。看似无关的编译器或类结构变化也可能改变结果,因此需要兼容已有数据时应显式声明。
2.1 UID 相同不保证语义兼容
serialVersionUID 只通过一个标识控制是否尝试反序列化,不会自动迁移业务语义:
- 字段类型改变后,数据可能无法恢复。
- 新增字段通常得到默认值,但默认值未必是有效业务状态。
- 删除字段会丢失旧数据中的信息。
- 类层次、枚举和自定义钩子的变化可能改变行为。
保持 UID 不变,意味着开发者承诺新类能够合理读取旧数据。做不到时应改变 UID 并明确拒绝,或者提供独立迁移工具。
2.2 长期数据需要显式模式版本
数据库记录、跨服务消息和长期文件格式通常需要字段编号、兼容规则、版本迁移和多语言支持。单个 serialVersionUID 无法承担这些职责。
JSON、Protocol Buffers、Avro 等格式也各有代价,但它们能把传输模式与 Java 对象实现分开。边界数据应根据协议需求选择,而不是因为类已经实现 Serializable 就直接发送对象。
3. 自定义序列化逻辑
类可以声明特定签名的私有方法参与读写:
@Serial
private void writeObject(ObjectOutputStream output) throws IOException {
output.defaultWriteObject();
}
@Serial
private void readObject(ObjectInputStream input)
throws IOException, ClassNotFoundException {
input.defaultReadObject();
if (userId <= 0 || displayName == null || displayName.isBlank()) {
throw new InvalidObjectException("invalid user profile");
}
}
defaultReadObject 恢复默认字段,后续校验防止构造器未执行时产生非法对象。
其他扩展点还包括:
writeReplace:写入前替换对象。readResolve:读取后替换恢复结果,常用于保持单例或规范对象。Externalizable:由类完全控制读写格式和对象恢复流程。
这些钩子增加了兼容和安全复杂度。只为减少几个字节而加入自定义协议,通常不值得。
4. 反序列化不可信数据的风险
ObjectInputStream 不只是解析字段。它会根据流中的类型信息构造对象图,并可能触发:
readObject、readResolve等自定义钩子。- 集合重建过程中的比较、哈希和其他类型行为。
- 大数组、深层对象图和大量引用分配。
- classpath 中可被组合利用的危险调用链。
攻击者不一定需要让目标业务类本身有漏洞,只要运行时依赖中存在可利用对象组合,就可能在校验业务字段前触发副作用。
来自网络、用户上传、消息队列或可被外部修改的存储数据,不应直接交给没有限制的 ObjectInputStream。优先使用数据模式明确、只构造预期 DTO 的格式,再执行字段校验。
5. 使用反序列化过滤器限制对象图
必须兼容原生序列化时,可以使用 ObjectInputFilter 检查每个候选类和对象图规模:
try (ObjectInputStream input = new ObjectInputStream(source)) {
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.example.UserProfile;java.base/*;maxdepth=10;maxrefs=1000;!*"
);
input.setObjectInputFilter(filter);
UserProfile profile = (UserProfile) input.readObject();
}
过滤器可以限制:
- 允许或拒绝的类与包。
- 对象图最大深度。
- 引用数量。
- 数组长度。
- 输入字节数量。
生产规则应按实际对象图建立小范围允许列表,并测试所有合法版本。宽泛允许整个第三方依赖包,仍然可能把危险类型带入边界。
过滤器是纵深防御,不会验证业务字段,也无法把一个本就不适合外部使用的对象协议变成稳定公共协议。
6. 原生序列化的适用范围
相对合理的场景通常同时满足:
- 写入方与读取方由同一应用和发布流程控制。
- 数据生命周期短,不需要多年兼容。
- 输入无法被不可信主体替换。
- 对象图类型范围小,并配置了过滤器。
- 性能、体积和跨语言不是主要约束。
常见的不适用场景:
- 对外 HTTP、RPC 或消息协议。
- 长期归档和数据库字段。
- 浏览器、移动端或其他语言需要读取的数据。
- 插件可任意扩展 classpath 的系统。
- 无法确认历史字节来源是否可信的缓存。
7. 常见问题
7.1 Serializable 为什么没有方法
它是标记接口,用类型关系告诉序列化机制该类允许进入默认对象序列化流程。实际读写由 ObjectOutputStream、ObjectInputStream 和类的可选钩子完成。
7.2 所有字段都 serializable,类就一定可以安全序列化吗
不一定。循环引用可以被协议处理,但对象图可能巨大;自定义钩子可能失败;类演进也可能不兼容。Serializable 只说明机制允许尝试,不是安全、稳定或高性能保证。
7.3 transient 字段反序列化后是什么值
默认序列化不会恢复它,字段得到对应 Java 默认值,例如对象引用为 null、整数为 0。如果它是可重新计算的派生状态,可以在读取钩子或首次使用时重建。
7.4 serialVersionUID 应该什么时候改变
能够保持反序列化兼容并正确处理新增默认值时可以保持不变;旧数据无法被新类可靠解释时应改变并拒绝读取。改变前还要决定历史数据是迁移、清除还是由旧版本读取。
8. 面试题
8.1 Java 原生序列化流程是什么,serialVersionUID 有什么作用
出现公司:网易、保利威、字节跳动
考察重点
Serializable、ObjectOutputStream和对象图。- UID 不一致时的结果,以及一致时仍需处理的语义兼容。
static、transient和构造过程的边界。
相关内容:第 1 节“Serializable 怎样工作”、第 2 节“serialVersionUID 表示序列化版本身份”。
参考回答
类实现 Serializable 后,ObjectOutputStream 可以沿引用关系把非静态、非 transient 的对象状态写入流,ObjectInputStream 再根据类信息恢复对象图。可序列化类的普通构造器不会按正常 new 流程执行,所以读取时仍要校验对象不变量。
serialVersionUID 用于判断流中类版本与本地版本是否允许兼容,不一致会抛 InvalidClassException。显式声明可以避免结构变化导致默认值意外改变,但 UID 相同也不会自动完成字段语义迁移。
8.2 为什么不应该反序列化不可信的 Java 对象流
出现公司:奇安信
考察重点
- 反序列化为什么会执行类型相关逻辑。
- 对象图资源消耗和 gadget chain 风险。
ObjectInputFilter能限制什么,不能替代什么。
相关内容:第 4 节“反序列化不可信数据的风险”、第 5 节“使用反序列化过滤器限制对象图”。
参考回答
Java 对象流包含类型和对象图信息,反序列化会实例化类并触发 readObject、readResolve 等逻辑,也可能分配很深或很大的对象图。攻击者可以利用 classpath 中的类型组合产生副作用或资源耗尽。
外部边界优先使用模式明确的数据格式,只构造预期 DTO 并校验字段。必须兼容原生序列化时,使用 ObjectInputFilter 建立类型允许列表,并限制深度、引用数、数组长度和字节数;过滤仍然只是纵深防御。