跳到主要内容

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 不只是解析字段。它会根据流中的类型信息构造对象图,并可能触发:

  • readObjectreadResolve 等自定义钩子。
  • 集合重建过程中的比较、哈希和其他类型行为。
  • 大数组、深层对象图和大量引用分配。
  • classpath 中可被组合利用的危险调用链。

攻击者不一定需要让目标业务类本身有漏洞,只要运行时依赖中存在可利用对象组合,就可能在校验业务字段前触发副作用。

不要默认反序列化不可信 Java 对象流

来自网络、用户上传、消息队列或可被外部修改的存储数据,不应直接交给没有限制的 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 为什么没有方法

它是标记接口,用类型关系告诉序列化机制该类允许进入默认对象序列化流程。实际读写由 ObjectOutputStreamObjectInputStream 和类的可选钩子完成。

7.2 所有字段都 serializable,类就一定可以安全序列化吗

不一定。循环引用可以被协议处理,但对象图可能巨大;自定义钩子可能失败;类演进也可能不兼容。Serializable 只说明机制允许尝试,不是安全、稳定或高性能保证。

7.3 transient 字段反序列化后是什么值

默认序列化不会恢复它,字段得到对应 Java 默认值,例如对象引用为 null、整数为 0。如果它是可重新计算的派生状态,可以在读取钩子或首次使用时重建。

7.4 serialVersionUID 应该什么时候改变

能够保持反序列化兼容并正确处理新增默认值时可以保持不变;旧数据无法被新类可靠解释时应改变并拒绝读取。改变前还要决定历史数据是迁移、清除还是由旧版本读取。

8. 面试题

8.1 Java 原生序列化流程是什么,serialVersionUID 有什么作用

出现公司:网易、保利威、字节跳动

考察重点

  • SerializableObjectOutputStream 和对象图。
  • UID 不一致时的结果,以及一致时仍需处理的语义兼容。
  • statictransient 和构造过程的边界。

相关内容:第 1 节“Serializable 怎样工作”、第 2 节“serialVersionUID 表示序列化版本身份”。

参考回答

类实现 Serializable 后,ObjectOutputStream 可以沿引用关系把非静态、非 transient 的对象状态写入流,ObjectInputStream 再根据类信息恢复对象图。可序列化类的普通构造器不会按正常 new 流程执行,所以读取时仍要校验对象不变量。

serialVersionUID 用于判断流中类版本与本地版本是否允许兼容,不一致会抛 InvalidClassException。显式声明可以避免结构变化导致默认值意外改变,但 UID 相同也不会自动完成字段语义迁移。

8.2 为什么不应该反序列化不可信的 Java 对象流

出现公司:奇安信

考察重点

  • 反序列化为什么会执行类型相关逻辑。
  • 对象图资源消耗和 gadget chain 风险。
  • ObjectInputFilter 能限制什么,不能替代什么。

相关内容:第 4 节“反序列化不可信数据的风险”、第 5 节“使用反序列化过滤器限制对象图”。

参考回答

Java 对象流包含类型和对象图信息,反序列化会实例化类并触发 readObjectreadResolve 等逻辑,也可能分配很深或很大的对象图。攻击者可以利用 classpath 中的类型组合产生副作用或资源耗尽。

外部边界优先使用模式明确的数据格式,只构造预期 DTO 并校验字段。必须兼容原生序列化时,使用 ObjectInputFilter 建立类型允许列表,并限制深度、引用数、数组长度和字节数;过滤仍然只是纵深防御。