Featured image of post RMI基础

RMI基础

RMI基础

RMI的介绍文档:RMI应用概述(Java™教程 > RMI)

这里用到的环境是jdk8u65

RMI 当中的攻击手法只在 jdk8u121 之前才可以进行攻击,因为在 8u121 之后,bind rebind unbind 这三个方法只能对 localhost 进行攻击,后续我们会提到。

RMI介绍

RMI 全称 Remote Method Invocation(远程方法调用),即在一个 JVM 中 Java 程序调用在另一个远程 JVM 中运行的 Java 程序,这个远程 JVM 既可以在同一台实体机上,也可以在不同的实体机上,两者之间通过网络进行通信。

RMI 依赖的通信协议为 JRMP(Java Remote Message Protocol,Java 远程消息交换协议),该协议为 Java 定制,要求服务端与客户端都为 Java 编写。

  • 这个协议就像 HTTP 协议一样,规定了客户端和服务端通信要满足的规范。

所以我们等下起环境,尽量写两个项目,一个做服务端,一个做客户端

image-20250827153137769

由于每个服务对应端口是动态的,所以引入registry注册端,提供服务注册与服务获取。即 Server 端向 Registry 注册服务,比如地址、端口等一些信息,Client 端从 Registry 获取远程对象的一些信息,如地址、端口等,然后进行远程调用。

RMI实现

Server端

  1. 先写一个接口,定义一个方法
1
2
3
4
5
6
import java.rmi.Remote;
import java.rmi.RemoteException;

public interface RemoteObj extends Remote {
    public String sayHello(String words) throws RemoteException;
}

注意这里要继承Remote接口,然后抛出异常

  1. 然后写接口的实现类
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;

public class RemoteObjImpl extends UnicastRemoteObject implements RemoteObj {
    public RemoteObjImpl() throws RemoteException{
        //    UnicastRemoteObject.exportObject(this, 0); // 如果不能继承 UnicastRemoteObject 就需要手工导出
    }

    @Override
    public String sayHello(String words) throws RemoteException {
        String UpWords = words.toUpperCase();
        System.out.println(UpWords);
        return UpWords;
    }
}

继承 UnicastRemoteObject 类,用于生成 Stub(存根)和 Skeleton(骨架)。 这个在后续的通信原理当中会讲到

实现类中使用的对象必须都可序列化,即都继承java.io.Serializable

  1. 然后写注册远程对象
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIServer {
    public static void main(String[] args) throws Exception {
        //实例化远程对象
        RemoteObj obj = new RemoteObjImpl();
        //创建注册中心
        Registry registry = LocateRegistry.createRegistry(1099);
        //绑定对象到注册中心
        registry.bind("obj", obj);
    }
}

这里端口默认1099,bind的绑定这里需要跟后面客户端查找的registry一致

Client端

客户端只需从从注册器中获取远程对象,然后调用方法即可。当然客户端还需要一个远程对象的接口,不然不知道获取回来的对象是什么类型的。

所以同样要先写一个接口,注意要跟server端的名字一样

1
2
3
4
5
6
import java.rmi.Remote;
import java.rmi.RemoteException;

public interface RemoteObj extends Remote {
    public String sayHello(String words) throws RemoteException;
}

然后客户端获取远程对象,然后调用方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIClient {
    public static void main(String[] args) throws Exception {
        Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);
        RemoteObj obj = (RemoteObj) registry.lookup("RemoteObj");
        obj.sayHello("hello");
    }
}

然后可以测试一下,先运行server端代码,开启1099端口接收,然后client端运行代码,转换大写的函数被调用,server端返回大写的Hello

从IDEA断点调试分析RMI原理

具体可以看这张图,来源是JAVA安全基础(四)– RMI机制-先知社区

image-20250827163324203

创建远程服务

这里肯定是不存在漏洞的,存在漏洞的地方在端之间的交互,在前面的实例化这里下断点,把下面代码都注释了

image-20250827163849690

一直强制步入,到达我们前下写的接口实现类RemoteObjImpl的构造函数,现在我们要把它发布到网络上去,我们要分析的是它如何被发布到网络上去的

首先它是继承父类UnicastRemoteObject,所以先进到父类的构造函数里

image-20250827165141332

可以看到这里port值为0,也就是说他会把我们的服务发布到一个随机的端口上,不是前面说的默认端口1099,因为这个是注册中心的默认端口

然后一路F8步过到这里exportObject方法

image-20250827170128883

这个方法就是用来将远程服务发布到网络上的,之前我们在接口实现类的构造函数注释了一段代码

1
2
3
 public RemoteObjImpl() throws RemoteException{
        //    UnicastRemoteObject.exportObject(this, 0); // 如果不能继承 UnicastRemoteObject 就需要手工导出
    }

如果不能继承UnicastRemoteObject类的话就要手动调用exportObject函数

我们看看这个函数

image-20250827171124142

第一个参数接受一个对象不多说明,第二个参数是new UnicastServerRef(port),很明显是处理我们的网络请求的类,我们继续跟进

image-20250827171839824

可以发现它又新建了一个叫LiveRef的类,继续跟进

这里有个技巧,可以点F7步入后选择LiveRef跟进

image-20250827172435460

一个构造函数,跟进this

image-20250827173519832

这里来了三个参数,重点看第二个参数

TCPEndpoint类显然是一个网络请求相关的类,查看构造函数

1
2
3
public TCPEndpoint(String host, int port) {
        this(host, port, null, null);
    }

传入ip和端口,这里又调了this,我们回到前面继续跟进this

image-20250827174035460

这里之前全是封装,到这里才有ip和port,所以记住这些数据都封装在LiveRef里面

image-20250827174503042

回到前面的new LiveRef(port),进super看父类构造函数

image-20250827175509892

整个服务从始至终只会调用一个LiveRef,然后一路步过,走到前面exportObject函数

接下来就是一直在不同的类里面调用exportObject函数

image-20250827190232345

走到这里看到一个stub,由上面那个图,我们发现stub实际上是在客户端client的,这里服务端为什么用到了

其实是先在服务端创建stub传给注册中心,然后客户端获取stub,后面跟skeleton交互,传给服务端

我们继续跟进stub,步过走到createProxy函数,跟进去看看

image-20250827193743823

if判断这里是判断是否存在stub,后面会用到

我们继续看下面

image-20250827193910863

一个动态代理的创建,然后继续跟回到上面,有个target把前面东西做了一个封装

image-20250827200521478

然后跟进Target

image-20250827200535750

这里其实可以看到

image-20250827202000098

disp下面的是server端的LiveRef和stub下面是client的LiveRef是一样的,这样才能实现两个之间的通信

后面我们看看target封装之后的内容

image-20250827202542193

我们看到这里又调用了exportObject,把前面封装好的target传入

跟进这个函数,一路跟到这个部分

image-20250827203030816

这个listen说白了就是开端口监听,跟进去看看

image-20250827204026656

然后看到下面有个newServerSocket函数,显然是创建了新的socket

image-20250827205857958

然后开启一个新的线程,处理这些网络请求,回到上面我们查看target,发现port有数值了

image-20250827211101050

其实前面的TCPEndpoint类里面的newServerSocket方法就已经随机赋值了,如果前面listen的port为0,就随机赋值

image-20250827210946848

然后这里走完,下面又走到一个exportObject方法

image-20250827214029190

跟进去,然后是一堆赋值,最后跟到这里

image-20250827214738828

也是一个赋值,把前面存储在target里面的内容赋值给两个表

然后接下来一直步过,每个部分都返回值,整个流程就结束了

创建注册中心 + 绑定

这里在创建注册中心这里下断点,因为注册中心与服务端是独立的,谁先谁后无所谓,本质是一个东西

image-20250828180724978

直接跟进去

image-20250828180918190

这里调用了一个创建注册中心的方法,然后给默认端口传到一个类里面,我们继续跟

这里会走到奇怪的代码,我们先在RegistryImpl类里面下断点,然后上面F8之后点恢复就能跟进去了

image-20250828181418149

这部分if判断是检测端口是否为注册中心端口,然后做一个安全检查,这里直接步过,走到下面这里

image-20250828181824061

这里看到很熟悉的LiveRef和UnicastServerRef,跟前面创建远程服务一个逻辑,可以看到这里的端口已经设置为1099,如果跟进会发现跟前面流程完全一致

调用exportObject的参数略有不同

image-20250828182427929

这里是前面创建远程服务的

然后下面这个是注册中心的

image-20250828182552378

可以发现第三个bool值不一样了,跟进去看看这个是什么

image-20250828182632797

permanent,所以前面创建远程服务的是临时对象,而我们这里创建注册中心的是永久对象

接着流程相似,创建stub,这里的stub创建就与前面有不一样的地方了,首先还是走到createProxy里面

image-20250828183042184

前面我们这个stubClassExists没有分析,这里分析一下

image-20250828183745088

其实就是在类名后面加上_Stub,然后查看有没有这个类,有的话返回true

这里是RegistryImpl类,所以查找的就是RegistryImpl_Stub,显然这里是存在的

所以进了判断,之前创建远程服务,我们记得直接就没进判断,这里进createStub函数

image-20250828184602266

这里单纯就是获取了RegistryImpl_Stub类,然后实例化了一下

我们走出去,看看走完这个流程的stub现在是什么

image-20250828184909441

可以看到还是UnicastRef,跟前面创建远程服务的stub差不多,只不过前面的stub是先创建动态代理,然后把ref放进去,这里是直接反射,用forName方法,直接把ref传进去

然后下面这里stub是定义好的,走到setSkeleton函数这里,用来创建Skeleton的,前面那个图里面也很清楚

image-20250828195241519

然后跟进去

image-20250828195411144

这里会走到createSkeleton

image-20250828200008251

然后同样反射获取已有的RegistryImpl_Skel类,走出来,我们可以看到skel被赋值了

image-20250828200215341

然后走完这里,同样走到了target进行封装数据

image-20250828200417133

这里跟进流程跟前面服务端一样,我们直接跟到target把值赋值给表的地方,我们查看这次封装了什么

image-20250828201934792

跟到这里,然后查看static下的objTable,发现有三个表,至于为什么有三个表,后面会说

image-20250828202056665

可以看到这里的skel是叫DGIImpl_Skel,是一个垃圾回收机制有关的对象,这里的port为62173,是一个随机分配的端口

image-20250828202346458

第二个表这里的skel没有赋值,然后端口跟上面随机赋值的结果一样

image-20250828202437236

第三个表这里才是我们前下注册中心有关的,skel值为RegistryImpl_Skel,然后端口是1099

绑定

这里把前面代码的bind部分加上断点

image-20250828202746332

然后跟进

image-20250828202906042

本地进行一个检查,没必要跟了,全都通过,直接来到下一步,如果绑定了,就直接获取我们绑定的RemoteObj,没有就put进去

客户端-客户端请求注册中心

首先在所有代码部分都下断点

image-20250828204331977

首先是获取注册中心,我们跟进去看看,这里其实直接就是接收ip和端口新建了一个liveref,然后直接调动态代理,并没有通过序列化和反序列化的方式进行

image-20250828204705361

这里跟进去发现其实跟前面注册中心创建stub的流程一样,只是接受了注册中心给的几个参数,并没有用到序列化和反序列化

这里我发现其实shift+F7的智能步入挺好用的,后面都用这个步入,不会走到奇怪的地方

然后接着就走到第二行代码,这里实际上是在获取服务端前面创建的动态代理,但是这里调试不了

image-20250828205656655

因为这里是1.1版本的class文件,没办法下断点,我们直接看逻辑就行了

这里接着会走到newCall方法,也就是创建一个链接,没什么可看的

这里我们直接看lookup逻辑,这里的var1就是我们前面传入的Remoteobj,然后他newCall完,执行了writeObject,也就是序列化的过程,显然注册中心会有一个反序列化的过程

接着就是调用invoke方法,这里调用的是父类UnicastRef这个类的方法

image-20250828210824000

这里调用了一个executeCall方法,接着跟进去

image-20250828210930903

跟到这个StreamRemoteCall类,这里是真正实现网络请求的方法

处理完之后走出来

image-20250828211116296

这里来了个readObject,反序列化的地方,这里我们很明显知道,如果注册中心有恶意代码,这里经过反序列化就能攻击客户端

我们在前面的StreamRemoteCall类那里下断点就能走了,不过我怎么步入都走不到这里,还是静态分析吧

image-20250828211538426

往下看,看到这里又来了个readObject

image-20250828211949575

这里在异常发生时,会进行反序列化,获取详细的信息,如果我们注册中心有恶意流的话,同样也会攻击客户端

由于这里这个executeCall方法是由invoke方法调用的,也就是说,类里面所有调用invoke方法的地方,都可能被攻击

就比如下面这个bind方法

image-20250828212415769

虽然不像lookup一样直接反序列化,但是它调用了invoke方法,还是可能被攻击

客户端-客户端请求服务端

这里接着上面来调试,走到第三行代码,这里正常debug走不进去,要在RemoteObjectInvocationHandler 类下的 invoke() 方法的 if 判断里面打个断点,这样才能走进去。

后面我算是明白了,服务端的代码必须在后台运行着,这样才能走到里面去

image-20250828214132447

事实上,是由于前面的代码是起了一个动态代理来调用方法,然后会走到调用处理器的invoke方法

image-20250828214301461

然后走到

image-20250828214403739

然后我们再走到invoke里面看看

image-20250828214947311

走到这里调用了一个marshalValue方法,实际上就是序列化,参数就是我们前面传的hello

image-20250828215123142

前面的序列化走完,往下看

image-20250828215502687

又调用了executeCall方法,又是一个利用点,走完这个

image-20250828215711097

我们前面的函数定义有返回值就会进到这个if判断里,这里有一个unmarshalValue方法,跟前面反着来的,显然是一个反序列化的函数

image-20250828215840867

然后就走完了

注册中心-客户端请求注册中心

不多说直接上断点位置

image-20250828221629689

然后服务端开调试,客户端运行一遍走到断点这里

然后往下走,调用disp的dispatch方法

image-20250828222827062

跟进去

image-20250828222900690

skel不为null,走到oldDispatch里面

image-20250828223003563

然后走到skel的dispatch方法,这里我们看看Registry_Skel的源码

image-20250828223711994

这里有个switch选择

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

然后查看是否有readObject,最后只有list不行,其他都能反序列化,所以这里的利用点就是dispatch,用于客户端攻击注册中心

服务端-客户端请求服务端

这里接着上面来,然后按F9直到target这里的stub为proxy

image-20250828225945225

按F9的过程我们会发现stub有三个,对应了之前的三个表

这里跟进dispatch我们发现skel为null了,进不了if判断,也就是进不了oldDispatch

image-20250828230252666

然后一直步过

image-20250828230414831

我们看到了熟悉的unmarshalValue方法,用于反序列化的,用于客户端打服务端的

DGC-客户端请求服务端

跟上面那个一样,前面我们F9的时候stub是变了三次,其中一次就是DGC

同样的流程debug

然后断点下在ObjectTable类里面

image-20250828231500599

然后在这里下个断点

image-20250828232212921

然后走到createProxy里面

image-20250828232440945

然后就是跟前面动态代理一个流程,判断加上_Stub是否为true了,然后有这个类,接着创建stub,然后包装到target,实际上跟前面注册中心一样

接下来就是看看DGCImpl_StubDGCImpl_Skel

同样都有invoke或者直接就有readObject,能被反序列化攻击,而且好处在于他只要远程服务创建它就会创建,而且服务端和客户端都能被攻击,也就是JRMP绕过

总结

如果是漏洞利用的话,单纯攻击 RMI 意义是不大的,因为在 jdk8u121 之后都基本修复完毕了。

RMI 多数的利用还是在后续的 fastjson,strust2 这种类型的攻击组合拳比较多

使用 Hugo 构建
主题 StackJimmy 设计