Skip to content
Go back

Java序列化——反序列化为什么用Unsafe.allocateInstance绕过构造器?

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,用于排除密码等敏感信息或可计算出的冗余字段。


Share this post on:

Previous Post
Java泛型类型擦除——编译器为什么要生成桥接方法?
Next Post
Java反射——Inflation机制让反射接近直接调用