Macr0
Macr0
发布于 2025-08-22 / 14 阅读
0
0

Java 反序列化之 RMI 专题:RMI 基础原理解析

0x01 前言


在 C3P0 后面写这篇的目的是为了补一补之前的基础,突然发现前面的好多内容都忘得差不多了,现在就是遇到什么就好好补一下遇到的问题。

0x02 RMI 基础


RMI 介绍

RMI(Remote Method Invocation,远程方法调用)是 Java 编程语言中用于实现分布式对象通信的一种机制,它允许一个 Java 虚拟机(JVM)中的对象调用另一个远程 JVM 中的对象的方法,这个远程 JVM 既可以在同一台实体机上,也可以在不同的实体机上,就像调用本地对象的方法一样简单,无需显式处理网络通信细节。

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

RMI 包括以下三个部分

Server ———— 服务端:服务端通过绑定远程对象,这个对象可以封装很多网络操作,也就是 Socket。
Client ———— 客户端:客户端调用服务端的方法。

因为有了 C/S 的交互,而且 Socket 是对应端口的,这个端口是动态的,所以这里引进了第三个 RMI 的部分 ———— Registry 部分。

  • Registry ———— 注册端;提供服务注册与服务获取。即 Server 端向 Registry 注册服务,比如地址、端口等一些信息,Client 端从 Registry 获取远程对象的一些信息,如地址、端口等,然后进行远程调用。

RMI 实现

把客户端和服务端拆分成两个服务来做,会更有利于理解。

服务端

1. 先编写一个远程接口,其中定义了一个 sayHello() 的方法

package com.rmitest;

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

public interface IRemoteObj extends Remote {
    public String sayHello(String keywords) throws RemoteException;
}
  • 此远程接口要求作用域为 public。

  • 继承 Remote 接口。

  • 让其中的接口方法抛出异常。

2. 定义该接口的实现类 Impl

package com.rmitest;

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 keywords) throws RemoteException {
        String upKeywords = keywords.toUpperCase();
        System.out.println(upKeywords);
        return upKeywords;
    }
}
  • 实现远程接口。

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

  • 构造函数需要抛出一个RemoteException错误。

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

3. 注册远程对象

package com.rmitest;

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 {
        // 实例化远程对象  
        RemoteObj remoteObj = new RemoteObjImpl();
        // 创建注册中心  
        Registry registry = LocateRegistry.createRegistry(1099);
        // 绑定对象示例到注册中心  
        registry.bind("remoteObj", remoteObj);
    }
}
  • port 默认是 1099,不写会自动补上,其他端口必须写。

  • bind 的绑定这里,只要和客户端去查找的 registry 一致即可。

客户端

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

所以在客户端这里,也需要定义一个远程对象的接口:

package com.rmitest;

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

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

然后编写客户端的代码,获取远程对象,并调用方法:

package com.rmitest;

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

这样就能够从远端的服务端中调用 RemoteHelloWorld 对象的 sayHello() 方法了。

0x02 从 Wireshark 抓包分析 RMI 通信原理


  • 这里文章大部分是引用其他师傅的,我们可以先通过 Wireshark 的抓包心里有个底。

这边我遇到的一个问题是RemoteObj一定要在相同的包结构下,就比如现在都是package com.rmitest; ;而我一开始一个在package com.rmitest.rmiserver; ,另一个在package com.rmitest.rmiclient; ,就无法连通。

也可以设置安全管理器(SecurityManager)启用 RMI 的动态类加载。

之前第一次学得时候没有注意到这个事情,直接歪打正着了。

数据端与注册中心(1099 端口)建立通讯

数据端与注册中心(1099 端口)建立通讯完成后,RMI Server 向远端发送了⼀个 “Call” 消息,远端回复了⼀个 “ReturnData” 消息,然后 RMI Server 端新建了⼀个 TCP 连接,连到远端的 4560 端⼝。

AC ED 00 05是常见的 Java 反序列化 16 进制特征。
注意以上两个关键步骤都是使用序列化语句。

客户端新起一个端口与服务端建立 TCP 通讯


客户端发送远程引用给服务端,服务端返回函数唯一标识符,来确认可以被调用。

同样使用序列化的传输形式

以上两个过程对应的代码是这两句

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

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

客户端序列化传输调用函数的输入参数至服务端


  • 这一步的同时:服务端返回序列化的执行结果至客户端

以上调用通讯过程对应的代码是这一句

remoteObj.sayHello("hello");

可以看出所有的数据流都是使用序列化传输的,那必然在客户端和服务带都存在反序列化的语句。

总结一下 RMI 的通信原理

实际建⽴了两次 TCP 连接,第一次是去连 1099 端口的;第二次是由服务端发送给客户端的。

在第一次连接当中,是客户端连 Registry 的,在其中寻找 Name 为 hello 的对象,这个对应数据流中的 Call 消息;然后 Registry 返回⼀个序列化的数据,这个就是找到的 Name=hello 的对象,这个对应数据流中的ReturnData消息。

到了第二次连接,服务端发送给客户端 Call 的消息。客户端反序列化该对象,发现该对象是⼀个远程对象,地址在 192.168.41.1:4560,于是再与这个地址建⽴ TCP 连接;在这个新的连接中,才执⾏真正远程⽅法调⽤,也就是 sayHello()

RMI Registry 就像⼀个⽹关,他⾃⼰是不会执⾏远程⽅法的,但 RMI Server 可以在上⾯注册⼀个 Name 到对象的绑定关系;RMI Client 通过 Name 向 RMI Registry 查询,得到这个绑定关系,然后再连接 RMI Server;最后,远程⽅法实际上在 RMI Server 上调⽤。

原理图如图:

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

0x04 从 IDEA 断点分析 RMI 通信原理


1. 流程分析总览


首先 RMI 有三部分:

  • RMI Registry

  • RMI Server

  • RMI Client

如果两两通信就是 3+2+1 = 6 个交互流程,还有三个创建的过程,一共是九个过程。

RMI 的工作原理可以大致参考这张图,后续我会一一分析。

Routine-lbrt.png

2. 创建远程服务


> 先行说明,创建远程服务这一块是不存在漏洞的。

断点打在 RMIServer 的创建远程对象这里,如图:

发布远程对象

开始调试,首先是到远程对象的构造函数 RemoteObjImpl,现在我们要把它发布到网络上去,我们要分析的是它如何被发布到网络上去的。

RemoteObjImpl 这个类是继承于 UnicastRemoteObject 的,所以先会到父类的构造函数,父类的构造函数这里的 port 传入了 0,它代表一个随机端口。

port = 0 相当于传了默认值

远程服务这里如果传入的是 0,它会被发布到网络上的一个随机端口,我们可以继续往下看一看。先 f8 到 exportObject(),再 f7 跳进去看。

exportObject() 是一个静态函数,它就是主要负责将远程服务发布到网络上,如何更好理解 exportObject() 的作用呢?我们可以看到 RemoteObjImpl 这个实现类的构造函数里面,我注释了一句代码

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

如果不继承 UnicastRemoteObject 这个类的话,我们就需要手动调用这个函数。

我们来看这个静态函数,第一个参数是 obj 对象,第二个参数是 new UnicastServerRef(port),第二个参数是用来处理网络请求的。继续往下面跟,去到了 UnicastServerRef 的构造函数。这里跟的操作先 f7,然后点击 UnicastServerRef 跟进,这是 IDEA 的小技巧。

跟进去之后 UnicastServerRef 的构造函数,我们看到它 new 了一个 LiveRef(port),这个非常重要,它算是一个网络引用的类,跟进之后是一个构造函数,先跳进 this 看一看

第一个参数 ID,第三个参数为 true,所以我们重点关注一下第二个参数。

TCPEndpoint 是一个网络请求的类,我们可以去看一下它的构造函数,传参进去一个 IP 与一个端口,也就是说传进去一个 IP 和一个端口,就可以进行网络请求。

继续 f7 进到 LiveRef 的构造函数 this 里面

这时候我们可以看一下一些赋值,发现 host 和 port 是赋值到了 endpoint 里面,而 endpoint 又是被封装在 LiveRef 里面的,所以记住数据是在 LiveRef 里面即可,并且这一 LiveRef 至始至终只会存在一个。

上述是 LiveRef 创建的过程,然后我们再回到之前出现 LiveRef(port) 的地方


回到上文那个地方,继续 f7 进入 super 看一看它的父类 UnicastRef,这里就证明整个创建远程服务的过程只会存在一个 LiveRef。一路 f7 到一个静态函数 exportObject(),我们后续的操作过程都与 exportObject() 有关,基本都是在调用它,这一段不是很重要,一路 f7 就好了。直到此处出现 Stub。

这里在我们服务端创建远程服务这一步出现了 stub 的创建,原理如下:

  • RMI 先在 Service 的地方,也就是服务端创建一个 Stub,再把 Stub 传到 RMI Registry 中,最后让 RMI Client 去获取 Stub。

接着我们研究 Stub 产生的这一步,先进到 createProxy 这个方法里面

先进行了基本的赋值,然后我们继续 f8 往下看,去到判断的地方。

这个判断暂时不用管,后续我们会碰到,那个时候再讲。

再往下走,我们可以看到这是很明显的类加载的地方。

第一个参数是 AppClassLoader,第二个参数是一个远程接口,第三个参数是调用处理器,调用处理器里面只有一个 ref,它也是和之前我们看到的 ref 是同一个,创建远程服务当中永远只有一个 ref。

此处就把动态代理创建好了,如图 stub:

继续 f8,到 Target 这里,Target 这里相当于一个总的封装,将所有用的东西放到 Target 里面,我们可以进去看一看 Target 里面都放了什么。

并且这里的几个 ref 都是同一个,通过 ID 就可以查看到它们是同一个。比如比较 disp 和 stub 的。一个是服务端 ,一个是客户端的,ID 是一样的,都是 822

一路 f8,回到之前的 Target,下一条语句是 ref.exportObject(target),也就是把 target 这个封装好了的对象发布出去。

接着 f7 跟进到

synchronized (this) {
    listen();
    exportCount++;
}

从这里开始,第一句语句 listen,真正处理网络请求了跟进去。

获取 TCPEndpoint 和 port ,即服务端ip和端口

到了server = ep.newServerSocket();这里,它创建了一个新的 socket,已经准备好了,等别人来连接,下图对 port 进行随机赋值

所以之后在 Thread 里面去做完成连接之后的事儿,这里挂几张图展示一下运行的逻辑:

如果被连接,就执行executeAcceptLoop()

进行连接处理。

发布完成之后的记录


  • 也就是记录一下远程服务被发到哪里去了。

RMI 这里会把所有的信息保存到两个 table里面

小结一下创建远程服务


从思路来说是不难的,也就是发布远程对象,用 exportObject() 指定到发布的 IP 与端口,端口的话是一个随机值。至始至终复杂的地方其实都是在赋值,创建类,进行各种各样的封装,实际上并不复杂。

还有一个过程就是发布完成之后的记录,理解的话,类似于日志就可以了,这些记录是保存到静态的 HashMap 当中。

这一块是服务端自己创建远程服务的这么一个操作,所以这一块是不存在漏洞的。

3. 创建注册中心 + 绑定


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

断点打在此处,开始调试

创建注册中心


首先会经过一个静态方法 ———— createRegistry,继续往下,走到了 RegistryImpl 这个对象下,f8 进去,会发现新建了一个 RegistryImpl 对象。这里 122 行,判断 port 是否为注册中心的 port,以及是否开启了 SecurityManager,也就是一系列的安全检查,这部分不是很重要,继续 f8。

再往下走,它创建了一个 LiveRef,以及创建了一个新的 UnicastServerRef,这段代码就和我们上面讲的 创建远程对象 是很类似的,我们可以跟进 setup 看一下。


评论