Java 序列化:反序列化为什么绕过构造器?
一句话结论(30s)
反序列化用 Unsafe.allocateInstance 直接分配内存绕过所有构造器,因为它的目的是”恢复对象到写入时的状态”而非”创建一个新对象”,走构造器会让参数校验因零值字段而失败。关键设计是用反射直接恢复字段值;代价是构造器安全检查被跳过,带来 Gadget Chain 反序列化 RCE 风险,需用 ObjectInputFilter 白名单防御。
核心原理(2min)
readObject() 走 ReflectionFactory.newConstructorForSerialization() → Unsafe.allocateInstance(clazz) 绕过构造器,再反射恢复字段;serialVersionUID 手动声明保证类结构调整后旧数据仍可反序列化,transient 排除敏感/冗余字段,readResolve() 返回已有实例保护单例。
底层深入(5-10min)
基本流程
// 序列化
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("obj.bin"));
oos.writeObject(myObject);
// 反序列化
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("obj.bin"));
MyObject obj = (MyObject) ois.readObject();
反序列化的”魔法”
readObject() 的不是调 new MyObject()——而是调 ReflectionFactory.newConstructorForSerialization(),底层走 Unsafe.allocateInstance(clazz)——直接分配 JVM 托管内存,完全绕过所有构造器。
💭 思考:反序列化为什么不走 new,非要 Unsafe.allocateInstance?因为反序列化的目标是「恢复对象到写入时的状态」,而不是「新造一个对象」——走构造器会执行初始化逻辑和校验,可能和恢复状态冲突。所以直接用 native 分配内存、绕开所有构造器。
为什么绕过构造器?
反序列化的目的是”恢复对象到写入时的状态”——不是”创建一个新对象”。如果走构造器,构造器中的参数校验(if (age < 0) throw)会因字段为零值而失败。绕过构造器后通过反射直接恢复字段值,不经过任何 Java 代码。
💭 思考:绕过构造器会带来什么隐患?构造器里的参数校验、不变量保证全被跳过了。对正常序列化这没问题(数据是自己写的),但对反序列化「不可信数据」就是灾难——攻击者可以构造字节流触发任意代码执行,这就是反序列化 RCE 的根源。
安全隐患:反序列化 RCE
绕过构造器意味着构造器中的安全检查被跳过。更危险的是:攻击者可以构造恶意字节流,反序列化时触发链式方法调用(Gadget Chain)执行任意代码。
Apache Commons Collections 的经典反序列化 RCE——攻击者在字节流中嵌入 InvokerTransformer 链,反序列化过程中自动执行 Runtime.getRuntime().exec("恶意命令")。
防御
java -Djava.security.properties=jdk.serialFilter=...
# 或用 ObjectInputFilter (JDK 9+) 设置白名单
serialVersionUID
private static final long serialVersionUID = 1L;
没有手动声明 → JVM 按类结构(字段名+类型+方法签名等)自动计算哈希 → 类改一个字段 → UID 变 → 旧数据反序列化失败 → InvalidClassException。
💭 思考:为什么没声明 serialVersionUID 时改个字段就会 InvalidClassException?因为 JVM 按类结构(字段名+类型+方法签名)算哈希当 UID,结构一变哈希就变,旧数据和新类的 UID 对不上就拒绝反序列化。手动声明 1L 等于你承诺「我会自己维护兼容性」。
人手动声明保证类结构调整(加字段、删方法)后旧数据仍可反序列化。
transient
private transient String password; // 不序列化
反序列化后 password 为 null。适合排除敏感字段或可以从其他字段计算出的冗余字段。
readResolve():单例保护
private Object readResolve() {
return INSTANCE; // 反序列化后替换为已有实例
}
防止反序列化创建第二个”单例”——readObject 返回的对象被 readResolve 的返回值替换。
💭 思考:readResolve 怎么保护单例?反序列化本身会 new 出一个新实例,但 readResolve 的返回值会替换 readObject 的结果——只要返回 INSTANCE,那个新造的对象就被丢弃,最终你拿到的还是原来的单例。注意枚举是另一种更省心的单例保护方案。
总结
| 特性 | 作用 |
|---|---|
| Unsafe.allocateInstance | 反序列化绕过构造器 |
| serialVersionUID | 版本兼容 |
| transient | 排除字段 |
| readResolve | 保护单例 |
| ObjectInputFilter | 反序列化安全白名单 |
章末提问
追问 1:反序列化为什么要用 Unsafe.allocateInstance 绕过构造器?
结论先行:因为反序列化的语义是「恢复对象到写入时的状态」,不是「创建新对象」,走构造器会因字段还是零值/默认值而导致参数校验失败。
因为:反序列化时字段是逐步恢复的,构造器里的 if (age < 0) throw 等校验在字段未恢复前必然失败;直接分配内存 + 反射恢复字段,可以精确还原写入时的状态而不经过任何 Java 构造逻辑。
追问 2:为什么说绕过构造器带来了反序列化 RCE 风险?
结论先行:因为构造器里的安全检查和初始化被跳过,且反序列化过程会触发链式方法调用(Gadget Chain),攻击者可构造恶意字节流执行任意代码。
因为:攻击者把 InvokerTransformer 之类的调用链编码进字节流,反序列化时这些「看似正常」的对象图在 readObject/hashCode/equals 等环节触发一连串方法调用,最终执行 Runtime.exec。防御靠 ObjectInputFilter 白名单限制可反序列化的类。
追问 3:serialVersionUID 和 transient 各自解决什么问题?
结论先行:serialVersionUID 解决版本兼容(类结构调整后旧数据仍可反序列化),transient 解决字段排除(敏感/冗余字段不序列化)。
因为:未声明 serialVersionUID 时 JVM 按类结构自动算哈希,结构一变 UID 变就 InvalidClassException;手动声明固定 UID 后加字段/删方法不影响兼容。transient 标记的字段反序列化后为 null,用于排除密码等敏感信息或可计算出的冗余字段。