0x00 RMI简述

RMI是什么

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

JRMP是什么

RMI里两个JVM通信所采用的协议正是JRMP(Java Remote Method Protocol, Java远程方法协议)。RMI用JRMP协议去组织数据格式然后通过TCP进行传输,从而实现远程方法调用。

RMI时序图

RMI的流程较为复杂,不妨先从下图入手:

image-20250215153920844

服务端先创建远程对象并向注册中心注册该远程对象,客户端向注册中心查找该远程对象,注册中心会返回该远程对象的存根(Stub),客户端对存根调用service进而与服务器骨架(Skeleton)通信,服务器对骨架调用service并返回给本地存根,本地存根返回给客户端。

0x01 基础环境

本文有相当一部分的调试内容,但在此之前需要准备RMI工作的基本代码。

起两个项目:RMIServer、RMIClient

服务端 RMIServer

接口 IRemoteObj

1
2
3
4
5
6
7
8
9
package TestOne;

import java.rmi.Remote;
import java.rmi.RemoteException;

public interface IRemoteObj extends Remote {
//sayHello就是客户端要调用的方法,需要抛出RemoteException异常
public String sayHello(String keywords) throws RemoteException;
}

此远程接口要求作用域为 public;
继承 Remote 接口;
让其中的接口方法抛出异常

实现类 RemoteObjImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package TestOne;

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

public class RemoteObjImpl extends UnicastRemoteObject implements IRemoteObj {

public RemoteObjImpl() throws RemoteException {
// UnicastRemoteObject.exportObject(this, 0);//如果不继承UnicastRemoteObject就需要手工导出
}
@Override
public String sayHello(String keywords) {
String upkeywords = keywords.toUpperCase();
System.out.println(upkeywords);
return upkeywords;
}
}
  • 实现远程接口
  • 继承 UnicastRemoteObject 类,用于生成 Stub(存根)和 Skeleton(骨架)。 这个在后续的通信原理当中会讲到
  • 构造函数需要抛出一个RemoteException错误
  • 实现类中使用的对象必须都可序列化,即都继承java.io.Serializable

注册远程对象 RMIServer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package TestOne;

import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIServer {
public static void main(String[] args) throws RemoteException, AlreadyBoundException {
// 实例化远程对象
RemoteObjImpl remoteObj = new RemoteObjImpl();
// 创建注册中心
Registry r = LocateRegistry.createRegistry(1099);
// 绑定对象示例到注册中心
r.bind("remoteObj", remoteObj);
}
}
  • port 默认是 1099,不写会自动补上,其他端口必须写
  • bind 的绑定这里,只要和客户端去查找的 registry 一致即可。

服务端就写好了。

客户端 RMIClient

同样的接口 IRemoteObj

1
2
3
4
5
6
7
8
9
package TestOne;

import java.rmi.Remote;
import java.rmi.RemoteException;

public interface IRemoteObj extends Remote {
//sayHello就是客户端要调用的方法,需要抛出RemoteException异常
public String sayHello(String keywords) throws RemoteException;
}

客户端操作 RMIClient

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package TestOne;

import java.rmi.NotBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIClient {
public static void main(String[] args) throws RemoteException, NotBoundException {
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
IRemoteObj remoteObj = (IRemoteObj) registry.lookup("remoteObj");
remoteObj.sayHello("hello");

}
}

客户端代码也写好了。

0x02 调试分析

0. 流程总览

RMI 有三部分:RMI Registry、RMI Server、RMI Client,两两通信就有6个交互过程,加上3个创建过程,一共是9个观察。

再给一张RMI的工作原理流程图:

image-20250215160753832

1. 创建远程服务

一共有两大要点:

  1. 发布远程服务
  2. 发布完成之后的记录

发布远程服务

创建远程服务属于服务端的工作,且不存在漏洞。为了方便大家理解该工作各个类的关系,先给出一张图。初学者会觉得这张图很难懂,跟着调试几遍就能理解。

image-20250215163513147

开始调试,断点如下图:

image-20250215162357447

步入:image-20250215162742583

强制步入到父类构造方法,步入到exportObject :

image-20250215162905250

image-20250215163037244

在最开始我们可以想到,我们需要把远程对象发布到注册中心,这里就有两个重要的点:

  1. 用什么方式发布对象
  2. 怎么保证通信的一致性(处理网络请求)

exportObject方法是一个静态函数,它主要负责将远程服务发布到网络上。所以我们下一步的重点就在该方法上,同时要有意关注Java如何通过层层封装实现上述的两个重点。

接着调试,步入exportObject方法:

image-20250215164520629

不难知晓,第一个参数Obj是远程对象,第二个参数是UnicastServerRef构造方法的返回值,也就是UnicastServerRef类实例,自然与第二个要点(通信一致性/处理网络请求)有关——事实上也正是如此。

UnicastRef对应客户端,UnicastServerRef对应服务端,二者是神奇的继承的关系,读者不妨看一下上文的导图,得知UnicastServerRef的作用是创建服务端引用,通俗来说就是处理网络请求。

可以猜想,UnicastServerRef类肯定是层层封装过的,当前的任务是找到真正处理网络请求的底层逻辑

接着调试,步入到UnicastServerRef构造方法:

image-20250215165615101

不知道是super方法还是LiveRef类处理网络请求,都看看:

image-20250215165706020

很明显是LiveRef类处理网络请求,步入到关键处:

image-20250215165758986

image-20250215165829685

可以看出,想要知道处理网络请求的底层逻辑还需要深入到TCPEndpoint类。

但是需要重视,LiveRef类在之后的调试流程里始终是同一个对象,这正是通信一致性的体现——LiveRef对象的功能正是处理网络请求。

接着调试,调试getLocalEndpoint方法到关键处:

image-20250215170214510

image-20250215170353626

image-20250215170436539

综上,我们解决了第一个小问题:处理网络请求/通信一致性。但是流程尚未结束,我们的主线并不只是处理网络请求,而是发布远程服务。

不断步过,回归此处:

image-20250215170736625

接着调试,进入到exportObject方法:

image-20250215171157919

这里的sref就是上文谈的包含LiveRef的UnicastServerRef。

不难得知,sref的exportObject方法会承接发布远程服务这个工作,步入:

image-20250215171703580

看到了熟悉的存根(Stub)及其构造,步入creatProxy方法:

image-20250215172015855

可见存根的本质就是动态代理。

接着走,看到了骨架(Skeleton)的创建过程:

image-20250215172115362

但是当前的Stub不是系统内置的Stub(RemoteStub及其子类),不进该分支。

接着走,到了下一个重点:

image-20250215172358792

targret可以理解为一个总封装,把目前我们创建的零件封装到一起,最后再把target发布出去。步入target看看属性:

image-20250216102300078

disp:UnicastServerRef 服务端引用

stub:代理(动态代理)

impl:远程服务实现类(后面的注册中心实现类也是这个参数)

id:内部liveRef的编号

关键是二者都封装了同一个liveRef,并且该liveRef的id就是整体target的id,可见其重要性。(不要小看这个id,后面会发挥大作用)

回归下图:

image-20250216101627288

一直步入ref.exportObject方法到下图:

image-20250216102457479

listen方法开启了一个新的ServerSocket,开启了一个新的线程,监听客户端请求。

image-20250216103125039

newServerSocket方法将创建随机端口,步入:

image-20250216103309139

回到listen方法:

image-20250216103505860

网络请求的线程和代码逻辑已经分开,我们代码可以继续走,处理网络请求的线程将持续等待。

这时,远程对象已经在服务端上的随机端口发布出去了

发布完成之后的记录

先步过到这里:

image-20250216103901435

步入:

image-20250216103941943

步入+步过:这两张表

image-20250216104231576

其实就是把一些琐碎对象存储到ObjectTable类的两个静态Map中:

image-20250216104414043

创建远程服务 · 小结

主线一直都是层层封装的类的exportObject方法,多调试就不难想到。

2. 创建注册中心 + 绑定

创建注册中心

与创建远程服务有相似处,给出调试入口:

image-20250216145501548

强制步入:

image-20250216145556769

注册中心的端口必须是1099。

智能步入:

image-20250216145922713

不管是否进行安全检查,这两行代码是肯定会执行的。

建一个新的LiveRef(这次端口强制是1099),作为注册中心,id默认为0,套在UnicastServerRef里面。

熟悉的步骤,我们直接进入到setup方法:

image-20250216151528795

重点来了,我们先对比一下前文exportObject方法的三个参数与此有何异同,功能上又有何异同:

image-20250216151725240

exportObject方法参数对比图:

对象 impl data permanent
sref:UnicastServerRef RemoteObjImpl(远程对象) null false
uref:UnicastServerRef RegistryImpl(注册中心) null true
  • 第一个参数代表远程对象,创建远程对象就是自己实现的Impl,创建注册中心就是RegistryImpl
  • 第三个参数代表时效选项,上次是false,这次变成了true

第三个参数反映:

  1. 远程对象的创建依赖于我们自定义的实现类,是一个临时的;
  2. 注册中心是JDK原生支持的,所以是永久的;

言归正传,再往下走。步入exportObject方法:

image-20250216152504826

进去观察stub是如何构造的:

image-20250216152614173

这个判断提炼一下:如果需要stub的类的类名+_stub,比如RegistryImpl_Stub是Java内置的类,则判断正确进入该分支;试想,如果是我们自己构造的远程对象类,其类名+_Stub肯定不属于Java内置类。其具体细节如下:

image-20250216153009192

所以在我们创建远程服务时,我们不会进入if分支,也就是不执行如下代码:

1
createStub(remoteClass, clientRef);

我们当前创建的是注册中心:RegistryImpl,系统存在对应的Stub类,进入该分支也就是creatStub方法。我们也进去看看:

image-20250216153307463

反射构造RegistryImpl_Stub类并返回该类。细心的读者不难发现,创建远程服务创建注册中心下创建对应Stub的方法和结果是不太一样的:

  • 创建远程对象时,stub是动态代理,Proxy对象,内部封装了RemoteObjectInvocationHandler(clientRef);
  • 创建注册中心时,stub是RegistryImpl_Stub对象,内部也需要clientRef参与构造;

二者创建代理时,都将clientRef包含进去了。clientRef是UnicastRef,内部封装了LiveRef属性,liveRef在创建远程对象时id是随机的,创建注册中心时是0。

言归正传,接着往下调试到这里:

image-20250216153658495

RegistryImpl_Stub继承RemoteStub,进入该分支,步入:

image-20250216153820835

步入createSkeleton方法:

image-20250216153916016

创建好的skeleton,会被放在注册中心实现类中。具体来说,创建好的skeleton其实会存储在UnicastServerRef的skel属性中:

image-20250216154047204

先梳理一下注册中心/RegistryImpl的结构图:(图篇源于大佬,在参考教程)

image-20250216154154127

调试到这一步:

image-20250216154235718

观察一下各个属性,再给出大佬的更加详细的图

image-20250216154616800

现在我们又回到了主线:exportObject方法,步入到这里:

image-20250216154717454

很熟悉吧。接着步过到这里:

image-20250216154749389

步入exportObject方法:

image-20250216154824043

步入putTarget方法,观察objTable中的具体内容:

image-20250216155031593

第一个是分布式垃圾回收的,第二个是远程对象的,第三个是注册中心的。

具体细节请读者自行观察对比,或者参考文末的教程。下面给出具体的流程导图:

image-20250216155328322

此时注册中心打开了1099端口,等待客户端(或服务端)发送lookup、bind等请求。

绑定

Register+Name方式

调试入口:

image-20250216160006729

强制步入:

image-20250216160139036

bindings本质上就是一个哈希表:

image-20250216160237909

这一步的逻辑也不难,如果需要绑定的对象已经在哈希表里,则抛出异常;如果其不在表哈希里,则put到哈希表里。

给出注册中心的示意图:

image-20250216160633293

Naming+url方式

也看看这种绑定方式吧。

1
Naming.bind("rmi://localhost:1099/Hello", remoteObject);

绑定需要两个参数,前者是url,后者是远程对象。文末参考教程里有详细的调试过程,这里就不赘叙了。

3. 客户端请求注册中心

先看代码:

image-20250220132552562

关注前两行代码:

  1. 客户端创建注册中心(RegistryImpl_Stub)
  2. 客户端查找远程对象(remoteObj)/ 获得远程对象代理(Proxy)

客户端创建注册中心

先进入第一行代码,调试到这里:image-20250220133043973

返回值是RegistryImpl_Stub,即注册中心。这里要关心一点,RegistryImpl_Stub的ref是UnicastRef类型:

image-20250220133231934

接着进入creatProxy方法:

image-20250220133433029

不难想到,客户端其实是利用注册中心给的参数自己本地创建了一个RegistryImpl_Stub,结构图如下:

image-20250220133800063

创建了注册中心,下一步就是查找远程对象。

客户端查找远程对象

进入第二行代码,强制步入后是这样的情况:

image-20250220133920011

回溯一层,到lookup方法:

image-20250220134004488

上文谈到的ref在这里调用了newCall方法,其作用是建立连接,这里不叙。

走到这里······走不了的,有一些版本上的差异导致该类(RegistryImpl_Stub)无法进行调试。往下看:

image-20250220134532093

var1是参数“remoteObj”, 最后是作为序列化的数据传进去的。注册中心后续会通过反序列化读取。再往下走:

image-20250220134855202

到重点了。这里的 invoke方法是类似于激活的方法,进去看看:

image-20250220135016849

接着进去,到这里:

image-20250220135114897

in是数据流里的东西,我们只要制造出预期的错误,就可以实现反序列化攻击,这种攻击更加隐秘。事实上,RegistryImpl_Stub里的bind方法、list方法、lookup方法等等需要网络通信的方法,都有用到invoke方法,都可以在此处尝试Java反序列化攻击。

上述就是注册中心与客户端进行交互时会产生的攻击。

回到lookup方法,后续将进行反序列化,给出大佬的图:

image-20250220135904371

远程对象会以动态代理的形式返回,里面包含了liveref,需要连接的ip:port等等信息:

image-20250220140120770

远程对象查找成功。

4. 客户端请求服务端

对应这一行代码:

image-20250220142428771

自行调试到这里:(学这么久了调试应该有点思路了)

image-20250220142535703

都是些判断,走到这里

image-20250220142732852

进去看看,再进到ref.invoke看看:

image-20250220142935566

该方法会序列化一个值,这个值是远程服务调用的参数,也就是我们传进去的参数 “hello”,就不展开讲了。

接着走,嘿,好熟悉:

image-20250220143156284

不就是前文的这里吗:

image-20250220143250672

就目前而言,只要客户端有通信——RMI 处理网络请求,就会调用executeCall方法,就有可能于此处进行Java反序列化攻击。

接着往下走:

image-20250220143608150

image-20250220143728202

这里有一个 unmarshalValueSee 的方法,因为现在我们传进去的类型是 String,不符合上面的一系列类型,这里会进行反序列化的操作,把这个数据读回来,这里是存在入口类的攻击点的。

把“hello”反序列化回来了:

image-20250220143930068

客户端小结

  • 简单梳理一下流程:

分为三步走,先获取注册中心,再查找远程对象,查找远程对象这里获取到了一个 ref,最后客户端发出请求,与服务端建立连接,进行通信。

  • 用我的话讲:

先得到注册中心的代理(Stub),再得到服务端的代理(Stub),最后调用远程服务。

  • 再谈谈这一部分所展露的反序列化攻击点:
    • 注册中心可以in.readObject操作一下客户端
    • 服务端不仅可以如此,还可以unmarshalValue一下。

下面我们要谈谈,在前面客户端做交互的时候,对应的注册中心和服务端是如何回应的。

5. 注册中心响应客户端

调试的入口在哪里呢?

先前在走到listen方法的时候,注册中心单开了一个线程来监听网络。很明显,客户端的请求会被该线程监听到,那我们就从这里入手。

具体过程不赘叙,给出流程图:

1
listen->AcceptLoop.run()->executeAcceptLoop()->ConnectionHandler.run()->run0()->handleMessages()->serviceCall(call)

给出断点位置:image-20250221115738218

注册中心里面的objTable存放着两个Target,其中我们根据ObjectEndpoint在表中寻找具有和客户端相同RegistryImpl_Stub的Target对象,因为它的disp属性(UnicastServerRef)里面,我们当时存放了skel属性,现在需要用了。

ObjectEndpoint中的id应该是由ip和端口号生成的,注册中心创建和客户端的是相同的,所以可以准确找到包含skel属性的Target。

如何开启调试就不说了,想通关节处自然明白。

走到这里:

image-20250221120842460

注意此时的端口是1099:

image-20250221171131876

此时的disp就有了skel:

image-20250221171246964

再进入下面的disp.dispatch方法看看:

image-20250221171719027

进去看看:

image-20250221171813957

再进去,直到RegisterImpl_Skel.class:

  • 先介绍一下这段源码吧,很长,基本都是在做 case 的工作。

我们与注册中心进行交互可以使用如下几种方式:

  • list
  • bind
  • rebind
  • unbind
  • lookup

这几种方法位于 RegistryImpl_Skel#dispatch 中,也就是我们现在 dispatch 这个方法的地方。

如果存在对传入的对象调用 readObject 方法,则可以利用,dispatch 里面对应关系如下:

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

事实上,除了list方法都可以。

简单,注册中心就是处理 Target,进行 Skel 的生成与处理。

漏洞点是在 dispatch 这里,存在反序列化的入口类。这里可以结合 CC 链子打

6. 服务端响应客户端

动态代理的Stub

两个断点:

image-20250223101845165

不断F9,直到Stub是Proxy:

image-20250223102104008

步入:

image-20250223102131082

走到这里:

image-20250223102432398

步入,里面就是把客户端传进来的远程服务的参数反序列化,可以利用:

image-20250223102637910

客户段序列化参数,服务端反序列化参数;服务端序列化结果,客户端反序列化结果。

DGC的Stub

断点需要下在 ObjectTable类的 putTarget 方法里面。并且将前面两个断点去掉,直接调试即可。

image-20250223122208465

回溯到这边:

image-20250223122231614

往上翻,打断点:

image-20250223122257390

想想DGC这个类是怎么创建的:

在 DGC 这个类在调用静态变量的时候,就会完成类的初始化。

类的初始化是由 DGCImpl 这个类完成的,我们跟到 DGCImpl 中去看,发现里面有一个 static 方法,作用是 class initializer,也就是上图那边。

与前文类似,此处会创建Stub和Skel,我们重点关注DGCImpl_Stub:

DGCImpl_Stub 这个类下,它有两个方法,一个是 clean,另外一个是 dirty。clean 就是”强”清除内存,dirty 就是”弱”清除内存。

无论是clean还是dirty方法都可以反序列化,对应的Skel中也有(分case0和case1与之对应)。

小结一下 DGC 的过程

DGC是自动创建的一个过程,用于清理内存。

漏洞点在客户端与服务端都存在,存在于 Skel 与 Stub 当中,这也就是所谓的 JRMP 绕过。

7. 高版本绕过

这里先不讲(现在讲不合适),等到用到的时候再讲。

0x0x 参考教程

源码层面梳理Java RMI交互流程 - 跳跳糖

Java反序列化之RMI专题01-RMI基础 | Drunkbaby’s Blog

Java反序列化RMI专题-没有人比我更懂RMI_哔哩哔哩_bilibili

文章 - 基于Java反序列化RCE - 搞懂RMI、JRMP、JNDI - 先知社区