Effective Java Item89:readResolve序列化单例攻击问题咨询
问题源自《Effective Java》第89条:实例控制场景下优先选用枚举类型而非readResolve,核心疑问为为何攻击者可通过构造序列化流修改单例类的实例字段。
复现代码
存在漏洞的可序列化单例类
public class Elvis implements Serializable { public static final Elvis INSTANCE = new Elvis(); private Elvis() { } private String[] favoriteSongs ={ "Hound Dog", "Heartbreak Hotel" }; public void printFavorites() { System.out.println(Arrays.toString(favoriteSongs)); } private Object readResolve() { return INSTANCE; } }
盗用攻击类
public class ElvisStealer implements Serializable { static Elvis impersonator; private Elvis payload; private Object readResolve() { // 保存对“未完成解析”的Elvis实例的引用 impersonator = payload; // 返回类型匹配favoriteSongs字段的对象 return new String[] { "A Fool Such as I" }; } private static final long serialVersionUID =0; }
测试复现代码
public class ElvisImpersonator { // 该字节流不可能由真实的Elvis实例序列化生成! private static final byte[] serializedForm = { (byte)0xac, (byte)0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x05, 0x45, 0x6c, 0x76, 0x69, 0x73, (byte)0x84, (byte)0xe6, (byte)0x93, 0x33, (byte)0xc3, (byte)0xf4, (byte)0x8b, 0x32, 0x02, 0x00, 0x01, 0x4c, 0x00, 0x0d, 0x66, 0x61, 0x76, 0x6f, 0x72, 0x69, 0x74, 0x65, 0x53, 0x6f, 0x6e, 0x67, 0x73, 0x74, 0x00, 0x12, 0x4c, 0x6a, 0x61, 0x76, 0x61, 0x2f, 0x6c, 0x61, 0x6e, 0x67, 0x2f, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x3b, 0x78, 0x70, 0x73, 0x72, 0x00, 0x0c, 0x45, 0x6c, 0x76, 0x69, 0x73, 0x53, 0x74, 0x65, 0x61, 0x6c, 0x65, 0x72, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0x4c, 0x00, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x74, 0x00, 0x07, 0x4c, 0x45, 0x6c, 0x76, 0x69, 0x73, 0x3b, 0x78, 0x70, 0x71, 0x00, 0x7e, 0x00, 0x02 }; public static void main(String[] args) { // 初始化ElvisStealer.impersonator,返回真实的Elvis实例(即Elvis.INSTANCE) Elvis elvis = (Elvis) deserialize(serializedForm); Elvis impersonator = ElvisStealer.impersonator; elvis.printFavorites(); impersonator.printFavorites(); } }
程序运行输出
[Hound Dog, Heartbreak Hotel] [A Fool Such as I]
序列化字节流结构化解析结果

核心技术疑问
- 为何ElvisStealer的readResolve()方法返回值可修改impersonator实例的favoriteSongs字段?
- 为何字节序列
0x71, 0x00, 0x7e, 0x00, 0x02代表Elvis实例的引用? - 为何上述字节序列会被赋值给ElvisStealer实例的payload字段?
问题解答
1. 为什么ElvisStealer的readResolve返回值能修改实例字段?
根本原因是Java反序列化的执行顺序存在可被利用的空窗期:反序列化时,JVM会先按照字节流的内容把目标对象的所有实例字段全部填充完毕,才会调用对象的readResolve()方法,最后用该方法的返回值替换掉刚反序列化出来的对象,赋值给上层引用。
这段恶意字节流根本不是正常序列化Elvis实例生成的:它恶意把Elvis类的favoriteSongs字段值,写成了一个嵌套的ElvisStealer对象。反序列化走到给外层Elvis的favoriteSongs字段赋值这一步时,会先反序列化这个嵌套的ElvisStealer实例:
- 第一步先给ElvisStealer的
payload字段赋值,值就是当前还没走完反序列化流程、刚创建出来的外层Elvis对象的引用 - 第二步调用ElvisStealer的
readResolve()方法:方法先把payload存到静态变量impersonator里,再返回一个内容为{"A Fool Such as I"}的String数组 - JVM拿到这个返回值,直接把它赋值给外层Elvis的favoriteSongs字段,之后才会调用外层Elvis的readResolve方法,返回全局单例
Elvis.INSTANCE,最终外层反序列化拿到的引用确实是正常的单例对象
但这时候静态变量impersonator已经牢牢持有了那个没被替换的、反序列化生成的恶意Elvis实例的引用,这个实例的favoriteSongs已经被替换成了假数组,完全脱离了单例控制,自然能打印出和单例不一样的内容。
2. 为什么字节序列0x71, 0x00, 0x7e, 0x00, 0x02代表Elvis实例引用?
这完全是Java对象序列化协议的标准规定:
- 开头的
0x71是协议定义的TC_REFERENCE标记位,作用是表示当前位置是一个引用,指向本次序列化流中之前已经解析过的对象,用来解决循环引用、重复引用的问题 - 后面的字节是句柄值,序列化协议规定句柄基准值为
0x7E0000,每解析一个新对象句柄值递增1。这里的句柄值是0x7e000002,对应流里第二个被分配句柄的对象——第一个解析的对象就是外层的Elvis实例,句柄号正好是2,所以这个引用就指向那个还没初始化完的Elvis对象。
3. 为什么这个引用会被赋值给ElvisStealer的payload字段?
完全是序列化流的字段解析顺序决定的:
- 解析完外层Elvis的类元信息后,按类定义顺序,接下来要解析的第一个实例字段就是
favoriteSongs,类型是Object,对应的值就是流里嵌套写入的ElvisStealer对象 - 解析这个ElvisStealer对象的类元信息后,按它的类定义顺序,接下来要解析的第一个(也是唯一一个)实例字段就是
payload,类型是Elvis - 这时候流里接下来的内容就是刚才说的TC_REFERENCE引用,JVM自然就把这个引用指向的Elvis对象赋值给payload字段了。
整个攻击的核心漏洞点就是:依赖
readResolve做单例实例控制时,反序列化生成的临时对象会在字段填充阶段暴露,攻击者完全可以通过构造畸形流偷走这个临时对象的引用,甚至篡改它的字段值,单例约束直接失效。这也是为什么《Effective Java》推荐单例场景优先用枚举类型:JVM从底层保证枚举的反序列化不会生成额外的临时实例,不需要手写readResolve,从根源上堵死了这类攻击。
内容的提问来源于stack exchange,提问作者just_code_dog

