RMI基础

学习视频:Java反序列化RMI专题-没有人比我更懂RMI

文章:Java反序列化之RMI专题01-RMI基础

前言

避坑:RMI 攻击手法只能在 jdk8u121 之前可以使用,因为在 8u121 之后 bind、rebind、unbind 这三个方法只能对 localhost 进行攻击

环境搭建

还是和之前学习一样,用的 jdk8u65

RMI基础

RMI概念

官方文档:https://docs.oracle.com/javase/tutorial/rmi/overview.html

RMI(Remote Method Invocation,远程方法调用),在一个 JVM 中 Java 程序调用在另一个远程 JVM 中运行的 Java 程序,这个远程 JVM 既可以在同一台实体机上,也可以在不同的实体机上,两者之间通过网络进行通信,所以 RMI 需要一个 Server 服务端和一个 Client 客户端

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

RMI组成

image-20260212192128143

  • RMI Server:

    RMI 的服务端,服务端通常绑定远程对象,这个对象封装了很多的网络操作(Socket通信、序列化等),对外提供访问方法接口,可以被 Client 远程访问

  • RMI Client:

    RMI 的客户端,客户端可以访问调用 Server 端的远程对象,并调用其方法

  • RMI Registry:

    RMI 的注册端,提供服务注册和服务获取,因为 Client 要去访问 Server 肯定是去访问它的端口,但是这个端口肯定是动态的,Client不知道是哪个端口,这就需要 Registry 端,Server 端可以向 Registry 端注册服务(服务端的地址、端口等信息),Client 端可以从 Registry 端获取远程对象的信息(地址、端口等)

  • Web server:

    当 Client 想去调用 Server 上的一个对象,但是 Server 没有这个对象时,RMI 提供一个大胆的方法:允许 Server 进行远程远程类加载,这明显就很不安全,所以在 jdk121 之后就对其进行了限制,在 jdk9 之后默认禁止了远程类加载

Server 和 Client 两端想要能通信的话,就要实现同一个接口java.rmi.Remote,并且要抛出异常

image-20260207124310413

RMI实现

用 Java 代码来写一个 demo 实现一下 RMI,这里把 Server 端和 Client 端分别写在不同的项目中来进行模拟 RMI

服务端

新建个 RMIServer 项目

image-20260207131639299

  • 先写一个远程接口RemoteObj,其中定义sayHello()方法:

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

    public interface RemoteObj extends Remote {
    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
    import java.rmi.RemoteException;
    import java.rmi.server.UnicastRemoteObject;

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

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

    重写的syaHello()方法,实现转大写的方法,并打印出结果

    • 实现远程接口RemoteObj,重写其中的方法
    • 继承UnicastRemoteObject类,用于生成 Stub(存根)和 Skeleton(骨架)
    • 构造函数要抛出RemoteException错误
    • 使用的对象都需要可以序列化的
  • 然后是要注册远程对象的RMIServer

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

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

    • port默认是1099

服务端就写好了

客户端

然后是写 Client 端的,另外在新建一个 RMIClient 项目

image-20260207131851916

  • 首先是要定义一个和 Server 端一样的远程对象的接口RemoteObj

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

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

  • 然后就是客户端RMIClient的代码了,获取远程对象,并调用方法:

    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 RMIClient {
    public static void main(String[] args) throws Exception{
    // 获取远程对象
    Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
    RemoteObj remoteObj = (RemoteObj) registry.lookup("RemoteObj");
    // 调用远程方法
    remoteObj.sayHello("Suzen");
    }
    }

模拟测试

先运行 Server 端的代码

image-20260207132820113

然后是运行 Client 端的

image-20260207132858200

这里就是掉用一下sayHello()方法,没有打印出来,所以啥都没有,再去看 Server 端的,可以看到 Server 端的sayHello()方法成功被 Client 端远程调用了

image-20260207132955502

其实这整个过程中都是通过序列化和反序列化实现的

从Wireshark抓包分析RMI通信原理

准备工作

打开Wireshark,捕获 Adapter for loopback traffic capture(因为这是本地的)

image-20260207151559686

然后设置捕获过滤器筛选一下流量:

1
tcp.port == 1099

image-20260207151707319

然后先运行一下 Server 端,然后再运行 Client 端,就可以在 Wireshark 里捕获到流量了

image-20260207151855139

分析流量包

建立连接与RMI协议协商

首先是 TCP 三次握手建立起连接

image-20260207152405742

1
2
3
1084: 40315  1099 [SYN]      # 客户端发起连接
1085: 1099 40315 [SYN, ACK] # 服务端确认
1086: 40315 1099 [ACK] # 客户端确认,建立连接

然后接下来每一个 RMI 包后会跟一个 TCP 包,这个 TCP 包是一个确认包

然后是 RMI 协议协商

image-20260207152719411

1
2
3
1087: JRMI, Version: 2, StreamProtocol  # 客户端发送RMI版本信息
1089: JRMI, ProtocolAck # 服务端确认协议
1091: Continuation # 继续传输

客户端与注册中心交互

image-20260207152742472

1
2
1093: JRMI, Call        # 客户端发起远程方法调用
1095: JRMI, ReturnData # 服务端返回调用结果

注意看1093这个包:客户端(40315端口)发送 “Call” 消息,查找要调用的远程函数RemoteObj

image-20260207155135238

然后看1095这个包:注册中心(1099端口)回复一个 “ReturnData” 消息,带有 RMI Server 的 IP 地址和端口

image-20260207161330686

Server 端的端口是40311

image-20260207161457843

ac ed 00 05是常见的 Java 反序列化的 16 进制特征,这上面这里的两个步骤都使用了序列化语句

客户端另起一个端口与服务端交互

可以把tcp.port == 1099过滤给删去查看

从1097包到1109包就是客户端与服务端进行的交互,客户端发送远程引用给服务端,服务端返回函数唯一标识符,来确认可以被调用,可以看到也是使用了序列化进行传输

image-20260207162459801

对应的代码是:

image-20260207162810349

1
2
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
RemoteObj remoteObj = (RemoteObj) registry.lookup("RemoteObj");

这里会返回一个 Proxy 类型函数,这个 Proxy 类型函数会在我们后续的攻击中用到

客户端通过序列化将调用函数的输入参数传输给服务端

之前的包1110到包1115就是保持 TCP 连接活跃,防止超时断开

然后看包1116:

image-20260207163839266

也是使用的序列化进行的传输

对应的代码:

1
remoteObj.sayHello("Suzen");

所以所有的数据都是通过序列化进行传输的,那么在客户端喝服务端肯定是存在反序列化的语句

总结

整个过程一共建立了两次 TCP 连接:

  • 第一次是客户端去连注册中心:客户端会传入NameRemoteObj的对象,对应数据流中的 “Call” 消息,然后注册中心返回一个序列化的数据,就是找到了Name=RemoteObj的对象,对应数据流中的 “ReturnData” 消息
  • 第二次是服务端发送给客户端:服务端发送给客户端 Call 的消息。客户端反序列化该对象,发现该对象是⼀个远程对象,地址在40311端口,于是再与这个地址建⽴ TCP 连接;在这个新的连接中,才执⾏真正远程⽅法调⽤,也就是 sayHello()

其实就是,服务端创建远程对象,bind 远程对象到注册中心上时,会给一个 Name,然后客户端带着 Name 在注册中心上取得这个远程对象,建立连接,从而能够远程调用

原理图如图

img

那么我们可以确定 RMI 是一个基于序列化的 Java 远程方法调用机制

从代码分析RMI通信原理

这个部分有挺多挺复杂的,要有耐心的调,我也是断断续续的坚持过了一遍

流程分析总览

RMI 有三个部分:

  • RMI Registry
  • RMI Server
  • RMI Client

两两通信就是 2 * 3 = 6 个交互流程,再加上 3 个创建过程,一共是 9 个过程

RMI 的工作原理可以大致参考这张图

img

1. 创建远程服务

注:创建远程服务这部分是不存在漏洞的

在 RMIServer 创建远程对象这里打断点:

image-20260207194502374

发布远程对象

开始调试,首先是要实例化一个RemoteObjImpl对象,所以会到RemoteObjImpl类中的构造函数(前面的RemoteServer就直接跳过了)

image-20260207200932714

RemoteObjImpl类是继承的UnicastRemoteObject,所以会先到父类UnicastRemoteObject的构造函数:

image-20260207195409372

上面图中是传入的参数是 0,继续往下到下图是赋值给的port参数(相当于传了一个默认值),它代表发布远程服务是到了一个随机端口,是不同于注册中心的 1099 端口的

image-20260207195547626

exportObject()这里下个断点,F9 恢复程序走到这里

image-20260207200147777

然后 F7 走进这个方法里

image-20260207200209187

exportObject()是个静态函数,顾名思义,这个方法的作用就是将远程服务发布到网络上,这就是一个核心的函数

之前在写RemoteObjImpl类的代码的时候,有一句注释的地方:

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

就是如果RemoteObjImpl类没有继承UnicastRemoteObject类的话,就需要在构造函数中手动去调用UnicastRemoteObject.exportObject()方法

1
2
3
4
5
public static Remote exportObject(Remote obj, int port)
throws RemoteException
{
return exportObject(obj, new UnicastServerRef(port));
}

看一下这个静态函数:

  • 第一个参数是obj,就是传入的远程对象
  • 第二个参数是new UnicastServerRef(port),是用来处理网络请求的,这里传入了一个port

这里跟进UnicastServerRef()

image-20260207201654406

可以看到这里new了一个LiveRef(port),这是一个网络引用类,继续跟进看

image-20260207201900842

到了LiveRef的构造函数这里,继续跟进到this里,又到了另一个构造函数:

image-20260207201955025

这个里面又有一个this(),第一个参数是 ID,第三个参数是ture,重点看第二个参数TCPEndpoint.getLocalEndpoint(port)

TCPEndpoint是一个网络请求的类,跟进看它的构造函数

image-20260207202258827

要传入的参数就是一个 IP 和一个 端口,然后就可以进行网络请求了

回到LiveRef这里,跟进去看this

image-20260207202456726

image-20260207202553225

看赋值,hostport是赋值到endpoint里,endpoint是被封装在LiveRef

所以记住数据是在LiveRef里面即可,并且这一LiveRef至始至终只会存在一个

image-20260207202753441

这就是LiveRef创建的过程

然后回到LiveRef(port)那里,跟进到super()

image-20260207203041559

这里就表明整个创建远程服务的过程中只会存在一个 LiveRef

image-20260211110714226

继续往后走回到exportObject()方法这里,进入到前面的这个exportObject

image-20260211110943100

image-20260211111232590

这里面就是会先检查传入的对象obj是不是UnicastRemoteObject的子类,如果是就会将obj这个对象强转为UnicastRemoteObject类型,然后将传进来的第二个参数sref复制给这个对象中的ref字段,这里的sref就是刚才new UnicastServerRef,也就是那个唯一的 LiveRef

image-20260211112220679

到这个函数的最后又调用一次exportObject()方法

image-20260211112320991

跟进到里面,注意看下面这里创建了一个 stub,是用来存储客户端代理,这里是通过Util.createProxy创建动态代理作为stub

Stub(存根) 是分布式系统中一个重要的概念,特别是在RPC(远程过程调用)和RMI(远程方法调用)中

Stub 是客户端代理对象,它充当远程对象的本地代表,当客户端调用远程方法时,实际上是在调用 Stub 对象的方法。

image-20260211112458711

结合下面这个图来理解

image-20260211113014097

就是 RMI 会现在 Server 端创建一个 Stub,然后会将 Stub 传到 RMI Registry 中,就是注册一个服务,最后让 RMI Client 从注册中心中获取 Stub,因为 RMI Client 的运行需要服务端的 Stub

继续去看 Stub 是如何产生的,跟进到createProxy()方法中

image-20260211113749104

implClass就是远程对象的实现类,clientRef就是前面的 LiveRef

image-20260211114026552

继续往下到这里,很明显是存在类加载的

image-20260211114455753

这里是用Proxy.newProxyInstance创建动态代理对象,看一下这三个参数

image-20260211114937742

  • loader:就是AppClassLoader
  • interfaces:远程接口
  • handler:调用处理器,在进行实例化的时候传入的是clientRef,也就是前面的 LiveRef

最后回到exportObject()里,这个 Stub 就创建好了

image-20260211115837011

继续往后到target这是,是一个总封装,将所有东西放在里面

image-20260211132047070

进入到Target里看看,这里的dispstub分别是服务端和客户端的,这俩的 ref 是同一个,因为ID一样都是912,可以通过ID看出来

image-20260211132709554

出来Target后就是调用exportObject()方法将target发布出去

image-20260211134351078

跟进看其中的发布逻辑

image-20260211134829567

image-20260211134840739

image-20260211135038036

最终来到上面这里,到synchronized()方法,首先是第一个调用listen()方法,这是真正处理网络请求,跟进看

image-20260211143232826

前面这里就是获取 TCPEndpoint,还有监听的端口

image-20260211143247033

到上面这里就是创建一个 socket,等着别人来连接

运行逻辑:

image-20260211141831553

如果被连接就会到这个run()方法里去执行executeAcceptLoop()方法

image-20260211141938139

然后就是对连接的一些处理

回到newServerSocket()这里,在这个方法的最后,如果端口为0,就会给端口随机赋一个值

image-20260211143453314

最终回到listen中,整个流程最终就是令target中增加了一个随机分配的端口

image-20260211143656304

发布完成后的记录

就是记录远程服务被发到哪里去了

接着上面的继续往下到这里,使用exportObject()target发布出去

image-20260211144140041

跟进看,这里会先调用target.setExportedTransport()

image-20260211144223403

继续跟进是一个赋值操作

image-20260211144312275

然后回头再去看exportObject()里的另一个ObjectTable.putTarget(target);

image-20260211144418665

前面也都是一些赋值语句,直接到下面这里

image-20260211144722370

1
2
objTable.put(oe, target);
implTable.put(weakImpl, target);

image-20260211145823731

就是将 Target 存到两个表里,保存到静态的 HashMap 中,就是存储对象信息,类似于日志

小结

整个过程就是一直在使用exportObject()方法发布远程对象到指定的 IP 和端口,这里的端口是一个随机值,很多地方就是在进行赋值、封装,到最后就是会有一个类似于日志的记录操作,记录会被保存到静态的 HashMap 中

2. 创建注册中心并绑定

创建注册中心与服务端是独立的,所以谁先谁后无所谓,本质上是一整个东西

下面这里打断点

image-20260211151440962

创建注册中心

首先是来到createRegistry()这个静态方法

image-20260211152222688

这里实例化一个RegistryImpl()对象,传入的prot为1099

然后继续往下走,直接到RegistryImpl()这里

image-20260211153206697

这里新建一个RegistryImpl对象

image-20260211153849620

然后到122行这里就是检查传入的 port 是不是注册中心的 port 以及是否开启了 SecurityManager

image-20260211154952409

继续往下到136行,这里创建一个 LiveRef 对象,137行就是创建的一个UnicastServerRef对象

image-20260211155009286

跟进到setup方法中,先进行赋值,然后调用exportObject()

这里和前面的发布远程对象进行一下对比,下面这个图是前面发布远程对象时的

image-20260211155738941

这里对比出来的区别就是permanent的值不一样:

  • 前面创建远程对象时是false,表明是一个临时对象
  • 现在创建注册中心是true,表明是一个永久对象

回来setup,继续进入到exportObject()

image-20260211160549146

这里就和发布远程对象一样,到了创建 Stub 的地方

image-20260211160726843

不过创建的过程有些不同,跟进createProxy()方法中,直接来到下面这里一个判断

image-20260211161112435

跟进到stubClassExists()

image-20260211161236132

是判断是否能获取到 RegistryImpl_Stub 这个类

image-20260211161305771

最终是可以获取到的,返回的true

image-20260211161608663

然后就可以直接走进createStub()中,而前面发布远程对象是要到下面创建动态代理

image-20260211161648162

跟进到createStub()中,这里是直接通过反射来创建对象,传入的就是ref

继续往后,回到exportObject()

image-20260211162412896

如果服务端定义好 stub,就会继续调用setSkeleton()方法,跟进去

image-20260211162538755

这里有个createSkeleton()方法,顾名思义就是创建Skeleton的,在这个图里Skeleton就是服务端的代理

image-20260211162721785

继续跟进到createSkeleton()方法中,这里skelcl是用Class.forName()来创建的

image-20260211162909985

继续往后走,回到了exportObject()方法中,接下来就是到了创建 Target 了,这部分和前面一样,是用来封装数据的

image-20260211163220227

直接跟进到下面的exportObject()里,这里和前面一样

image-20260211163527387

image-20260211163536979

image-20260211163544330

image-20260211163614208

直接跳到上面这里,然后跟进这个exportObject()方法

image-20260211163716780

第一个setExportedTransport()方法是一个赋值操作,就直接跳过了,直接继续跟进到ObjectTable.putTarget(),这个方法会将封装的数据放进去

查看封装了哪些数据

点开static中的objTable,查看里面的三个Target

  • Target@859
  • Target@855
  • Target@895

image-20260211172228750

一个个看,先看最后一个 Target@895 的 value,这个是刚刚创建的,这里的 stub 是RegistryImpl_Stub的一个对象,主要是看ref,这里的端口都是 1099,也就是说 1099 注册中心的一些端口数据都有了,这俩ref是同一个

image-20260211172410492

然后看第一个 Target@859 的 value,这个主要看 stub 是$Proxy对象的

image-20260211211814066

最后再看第二个 Target@855 的 value,这里注意 stub 是 DGCImpl_Stub,是一个分布式垃圾回收的对象,也挺重要的

image-20260211211623181

这里起来三个远程服务,其中的 1099 端口是固定的,另外两个端口是随机产生的

绑定

最后一步绑定,就是bind操作,断点下在下面这里,然后就可以直接接着上面的操作 F9 恢复程序到这个断点处了

image-20260211212334690

来到bind()方法,传入的两个参数分别是名字RemoteObj和远程对象remoteObj,首先调用checkAccess()

image-20260211212438572

跟进到checkAccess()方法中,这个方法就是检查是不是本地绑定的,就是一种安全检查,防止未授权访问,getClientHost()是用来获取ClientHost,可以直接跳过这个方法

image-20260211213102462

继续回到bind()方法,下面到synchronized()方法,用来检查bindings里有没有东西,如果有的话就会抛出异常,这里的bindings是一个 HashTable 对象

image-20260211213412510

这里的bindings肯定是啥都没有的,继续往下走,到调用bindings.put()方法,刚才看了bindings是一个 HashTable 对象,这里就是将name和远程对象 put 进去,也就是绑定了

小结

注册中心的流程有很多地方和远程对象类似,不过注册中心是一个持久的对象

服务端的整体过程就是:

  • 创建远程对象
  • 创建注册中心
  • 绑定远程对象

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

从注册中心获取远程对象的代理

RMIServer就直接运行

image-20260211215254905

RMIClient的话就三句代码都下断点,开始调试

image-20260211215339876

首先来到getRegistry()方法,传入的参数就是注册中心的 IP 和 port,这里再调用另一个getRegistry()方法

image-20260211215432792

继续跟进,前面部分的就是一些 IP 和 port 的检查和赋值

image-20260211215621095

继续往后,下面的流程和之前的很像,就是一些new LiveRef,还有新建了一个ref,进行了一些封装的操作,然后也调用了createProxy()方法,这里的用的是false,是一个临时对象

image-20260211220133169

跟进到createProxy()方法,这里最终可以走到createStub()这里,然后通过反射来创建对象

image-20260211221120230

通常可能会觉得获取远程对象是通过序列化和反序列化来获取,但实际上就是客户端在本地新建一个对象,注册中心传入参数,最终客户端想要获取的就是注册中心的 Stub 对象

通过远程代理对服务端进行远程调用

继续往下看lookup方法

image-20260211222145027

跟进到lookup方法里,将想要的远程对象的名字RemoteObj传进去,这里因为这个版本的问题,打不了断点,会直接进入到newCall方法里,问题不大,看一下逻辑就行

image-20260211222355555

这里发现多了一个param_1参数,这就是传入的var1,也就是名字RemoteObj,这里是调用的writeObject(),也就是通过序列化传进去的,到了注册中心就会肯定会通过反序列化进行读取

image-20260211223208205

继续往下调用super.ref.invoke(var2);,这里的super就是UnicastRef

image-20260211223606794

这里手动找一下,在invoke()方法中会调用call.executeCall(),这是真正处理网络请求的方法,客户端的网络请求都是通过这个方法实现的

image-20260211223857171

这里可以下断点,直接断到这里看

image-20260211224756996

跟进去直接看到下面这245行,这里有个异常处理的地方,调用了in.readObject()方法,会进行反序列化,这就可以被 JRMP 攻击

image-20260211225400641

看一下这个in是什么

image-20260211225659293

不难理解,in 就是数据流里面的东西。这里获取异常的本意应该是在报错的时候把一整个信息都拿出来,这样会更清晰一点,但是这里就出问题了 ———— 如果一个注册中心返回一个恶意的对象,客户端进行反序列化,这就会导致漏洞。这里的漏洞相比于其他漏洞更为隐蔽

所以这里是第一个漏洞点,更加隐蔽的,也更容易被利用,因为所有的stub里,调用网络请求都会调用到这个方法

回到lookup()这里,继续往下看

image-20260211230401595

进行网络连接后,这里会获取到返回值,然后回进行一次反序列化,和前面一样,如果注册中心返回一个恶意的对象,这里进行反序列化,就会导致漏洞,这是第二个漏洞点

小结

这部分就是客户端去连接注册中心,客户端想要从注册中心获取到 Stub 对象,会先在本地创建一个对象,然后通过注册中心传参建立起连接来完成,这部分不是序列化和反序列化完成的

然后是客户端获取服务端的远程对象,这部分是通过序列化和反序列化来完成的,并且存在两个可能的漏洞点

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

然后就到了最后一句代码这里

image-20260211231852395

继续走会来到RemoteObjectInvocationHandler.invoke方法

remoteObj是一个动态代理,动态代理无论调用什么方法,都会先走到调用处理器的invoke方法

image-20260211232608112

image-20260211232059412

前面一堆if判断,都是关于抛出异常的,直接跳到最后这里

image-20260211232715363

跟进invokeRemoteMethod()方法,下面这里有个重载方法ref.invoke()

image-20260211232836776

跟进这个invoke方法,直接跳到下面这个for循环里,调用的marshalValue()方法

image-20260211233056410

跟进看,这里先判断一堆类型,因为传入的是String类型,所以都不满足,直接到最后的else里,就会对value进行一次序列化的操作,这里的value就是传入的参数Suzen

image-20260211233208319

出来再回到invoke方法中,再往后又看到了call.executeCall();,所以说客户端只要处理网络请求,就一定会执行这个方法,和上面一样,这里是存在漏洞点的,这里就是第一个漏洞点

image-20260211233556891

继续往后看,来到unmarshalValue()方法,注意这里传入的是String类型的

image-20260211233948479

跟进这个方法看,看起来和前面marshalValue()的样子差不多,因为传入的是String类型的,不满足前面一系列if判断,所以就直接执行到最后elsereadObject,对服务端传回来的数据流in进行反序列化的操作,将值读回来,这里就是第二个漏洞点

image-20260211234257033

image-20260211234456328

小结

这也还是有两个漏洞点,第一个地方就是call.executeCall();,客户端只要处理网络请求,就一定会执行这个方法,也就一定会存在漏洞,也就是 JRMP 攻击,第二个地方就是在unmarshalValueSee()方法里,服务端返回参数时进行反序列化攻击

image-20260211235650369

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

回头看一下这个图,想一下如何调试,断点应该下在哪

img

刚才是针对的客户端进行的调试分析,操作的都是 Stub,到了服务端这里就要换成 Skel,在前面的分析中可知,Skel 是被封装在 Target 中的,所以把断点打在 RMIServer 的处理 Target 的地方,也就是Transport类中的 173 行,注意这里是打在服务端的

image-20260212000554580

然后 Debug 运行服务端的

image-20260212001540857

再直接运行客户端的就可以断到了

image-20260212001641891

先看一下target中有什么

image-20260212001851373

target里的stub里是个ref,里面的端口是1099,也就是注册中心的

image-20260212002133637

继续往下走到 180 行,就是将skel的值放到disp

image-20260212002312310

再继续往下走到 200 行这里,会调用disp.dispatch(),跟进看看

image-20260212002424746

此时的skel不为null,可以走到oldDispatch()这里,继续跟进

image-20260212003708860

直接来到410行这里的skel.dispatch(),继续跟进到了RegistryImpl_Skel,这里也是不能下断点调试的

image-20260212004011599

和注册中心进行交互的方法有:list、bind、rebind、unbind、lookup

这里面有很多的case,对应的方法:

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

下面这几个都是带有readObject()可以反序列化,也就是可以攻击的

image-20260212004332271

image-20260212004353689

image-20260212004408166

image-20260212004423568

小结

当客户端请求注册中心的时候,注册中心就是处理 Target,还有生成了 Skel 并进行处理,漏洞点在skel.dispatch()里,有几个反序列化的入口类

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

断点还是打在 Target 这里

image-20260212100746340

然后服务端调试,客户端运行,这里有个注意点

image-20260212100855048

刚开始断到这里的时候是和刚才上面一样的,此时的target里的stubRegistryImpl_stub的,这个分析过了,直接 F9 跳过

image-20260212101107057

跳过一次后又回到这里,此时的target里的stubDGCImpl_Stub的,这个类是用来处理内存垃圾的,也不是我们想要的,所以再 F9 跳过一次

image-20260212101255397

这个时候target里的stub就是想要的$Proxy动态代理的了

image-20260212101431024

还是来到这个dispatch方法,跟进去

image-20260212101615045

这里的num=-1skel=null,所以就不会到oldDispatch()

image-20260212101812561

继续往下来到上图这里,获取输入流和 Method,这里的 Method 就是要调用的远程方法,也就是写的satHello()方法

image-20260212101956776

继续往下走,又来到了unmarshalValue()方法

image-20260212102031400

和上面一样,传入的typeString类型的,所以会走到else里的in.readObject(),存在漏洞点

这里就是通过反序列化获取到客户端传过来的参数值

image-20260212102912696

再往下到这里就是进行远程调用方法了

image-20260212102948634

然后到下面marshalValue()方法里进行序列化,传回给客户端再进行反序列化

image-20260212103033504

小结

客户端将调用远程方法的参数值传给服务端,服务端会通过反序列化来获取到参数值,这里就存在漏洞点,然后调用的方法执行完后会将返回值进行序列化并传回到客户端,客户端通过反序列化获取到返回值,客户端比服务端多了一个 JRMP 攻击

7. DGC的stub

DGC之前提到过,就是分布式垃圾回收,前面分析知道会有三个 Target,其中有一个是 DGC的,来分析一下这个是怎么创建出来的

image-20260212103719015

断点下在ObjectTable.java中的putTarget()里,可以直接下在 174 行,之前的断点可以删去了,直接调试服务端即可

image-20260212103906389

有一个点,第一次来到这里的targetstubProxy

image-20260212104238091

所以要按一次 F9 才行

image-20260212104402032

这里的dgcLog是一个静态变量,当调用一个静态变量是会完成一个类的初始化,初始化的时候实际上会调用它的静态代码块,跟进到DGCImpl类里,找到这个静态代码块,在创建对象这里打一个断点

image-20260212105721699

这里一直断不到,搞了半天进来发现是源码和字节码不匹配

image-20260212114303669

又重新搞了一个 sun 包

https://hg.openjdk.org/jdk8u/jdk8u/jdk/rev/af660750b2f4

image-20260212130345384

重新调,这里按一次 F9 就可以断到这里了

image-20260212130538961

继续往下会到createProxy()这个方法,

image-20260212130558064

跟进后是可以走到createStub()这里的,这就和前面注册中心创建 stub 一样

image-20260212130618465

image-20260212131013203

此时 DGCImpl_Stub 的服务就创建好了,类似于注册中心创建远程服务一样,但是注册中心是用来注册的,这里是用来内存回收的,并且这里的端口是随机的

image-20260212131139379

最后就是回到putTarget()方法里,put进表里,这和前面的都一样

image-20260212131405934

主要来看一下漏洞点,在DGCImpl_stub中有clean()dirty()两个方法,clean就是 ”强” 清除内存,dirty就是 ”弱” 清除内存

image-20260212131705605

dirty()里有调用readObject的地方能反序列化,这里是一个漏洞点,会被 JRMP 攻击

image-20260212132732701

还有在DGCImpl_Skel类里的dispatch()方法里的两个case也有漏洞点

image-20260212133011684

image-20260212133030908

总的来说 DGC 是自动创建用于清理内存的,在服务端和客户端都存在漏洞点,在SkelStub里,也就是 JRMP 攻击

总结

创建远程服务:

服务端通过exportObject()方法发布远程对象到随机端口,创建 Stub 作为客户端代理,并将服务信息记录在静态 HashMap 中

创建注册中心并绑定:

注册中心在 1099 端口启动,创建RegistryImpl_SkelRegistryImpl_Stub,服务端将远程对象通过bind()方法注册到注册中心的 HashTable 中

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

客户端通过getRegistry()本地创建Registry_Stub代理,通过lookup()方法序列化服务名请求注册中心,反序列化获取远程对象 Stub 引用

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

客户端通过获取的远程Stub调用方法,通过marshalValue()序列化参数,经JRMP协议传输,在executeCall()unmarshalValue()处存在反序列化漏洞点

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

注册中心通过RegistryImpl_Skel.dispatch()处理客户端请求,在lookupbind等操作的readObject()处存在反序列化入口,可被 JRMP 攻击利用

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

服务端 Skeleton 通过dispatch()解析方法调用,在unmarshalValue()中反序列化客户端参数,执行远程方法后序列化返回值返回客户端

DGC的stub:

系统自动创建DGCImpl_Stub处理分布式垃圾回收,在dirty()clean()方法和 Skeleton 的dispatch()中均存在反序列化漏洞点


RMI基础
https://yschen20.github.io/2026/02/12/RMI基础/
作者
Suzen
发布于
2026年2月12日
更新于
2026年2月12日
许可协议