Java-RMI学习
Flow

我看后面挺多漏洞和这个RMI和JNDI有关,现在先学习一下

RMI介绍

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

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

RMI三个部分
  • 服务端:提供服务
  • 客户端:调用服务端端服务
  • 注册端:提供服务注册与服务获取,客户端可以在这里查询要调用的方法的引用

交互过程(图源 https://fynch3r.github.io/Java-RMI%E5%8E%9F%E7%90%86%E5%8F%8A%E5%AE%9E%E6%88%98/)

在Java RMI里

  • 远程服务器实现具体的Java方法并提供接口
  • 客户端本地仅需根据接口类的定义,提供相应的参数即可调用远程方法
  • 其中对象是通过序列化方式进行编码传输的
  • RMI全部的宗旨就是尽可能简化远程接口对象的使用

RMI实现

先写一个接口

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

public interface HelloService extends Remote {
String sayHello(String name) throws RemoteException;
}

写一个类继承这个接口

JAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;

public class HelloServiceImpl extends UnicastRemoteObject implements HelloService {

protected HelloServiceImpl() throws RemoteException {
super();
}

@Override
public String sayHello(String name) throws RemoteException {
return "你好, " + name + "! 这是来自RMI服务器的问候。";
}
}

服务端代码

JAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIServer {
public static void main(String[] args) {
try {
// 设置 RMI 服务器的 IP 地址
System.setProperty("java.rmi.server.hostname", "127.0.0.1");

// 创建服务实例
HelloService helloService = new HelloServiceImpl();

// 创建并启动RMI注册表,监听1099端口
Registry registry = LocateRegistry.createRegistry(1099);

// 将服务绑定到注册表
registry.bind("HelloService", helloService);

System.out.println("RMI服务器已启动在127.0.0.1:1099...");
} catch (Exception e) {
System.err.println("RMI服务器异常:" + e.toString());
e.printStackTrace();
}
}
}

客户端代码

JAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIClient {
public static void main(String[] args) {
try {
// 获取RMI注册表
Registry registry = LocateRegistry.getRegistry("localhost", 1099);

// 查找远程服务HelloService
HelloService helloService = (HelloService) registry.lookup("HelloService");

// 调用远程方法
String response = helloService.sayHello("FLOW");
System.out.println("服务器响应:" + response);

} catch (Exception e) {
System.err.println("客户端异常:" + e.toString());
e.printStackTrace();
}
}
}

然后就能运行起来了

这里有个特别坑的地方,在server的代码里面,绑定registry之前要设置好服务器ip,不然client连接的时候会报错,本身不是很熟悉这个东西,拷打gpt半天都没结局,最后换了cursor才解决的。。。

JAVA
1
System.setProperty("java.rmi.server.hostname", "127.0.0.1");

从WireShark观察RMI通信过程

wireshark开一个本地抓包,粗略看一眼就可以发现有很多RMI协议的包,当然前后的TCP包也要注意

可以看到这里25-27号包是在tcp三次握手建立连接的过程

建立连接之后数据端与注册中心建立通讯,1099是注册端,50140是客户端

建立TCP连接后开始请求调用,看到后面有一个CALL,数据包里面包含了要调用的远程函数名,

再往下看到ReturnData,返回了很多代理服务对象,是序列化的数据,可以看到序列化的魔术头,最后还看到一个新的端口

c3b0 50096,指的是提供服务的端口

后面客户端(50141)新起一个端口和服务端(50096)建立连接,会看到建立了第二次TCP连接,有三个握手包

建立连接后继续发送序列化数据和我们要打印的数据

红色和紫色分别代表客户端和服务端发送的数据,可以清晰地看到数据的来回传送

可以看到整个过程中数据流都是通过序列化传输的,那客户端和服务端必定有序列化反序列化的地方。

看完整个流量包过程再看会前面那张流程图就会清晰很多,推荐所有人都去动手做一下。

从源码观察调用过程

服务端

创建远程对象然后发布到网上,就已经可以通信了,我们要了解这是个什么样的过程

说是没什么server这个部分其实没什么漏洞,但是要了解一下过程

创建服务并发布

为了跟着教程,这里我用Drunk师傅的代码调试,不知道为什么自己的idea跳不进去,我这里断点打在RemoteObjectImpl

RemoteObjectImpl是继承UnicastRemoteObject类(继承这个类是硬性要求。另一种写法是在下面的代码块写一段直接调用),所以会先到父类的构造函数,f7步进

跳到下图地方,可以看到给了一个port端口,一开始是0

再继续往下看,来到 exportObject(),就是这个函数负责把服务发步到网络,跟进去

看到还套了一层,里面有一个UnicastServerRef(),这里在idea里直接点击UnicastServerRef,就会跳转到这个函数里面

有一个**LiveRef**,这个很重要,继续跟进

是一个构造函数,点this进去看看,构造函数如下

第一个参数是ID,第三个参数是true,主要是第二个参数,TCPEndpoint这个名字一听就是和网络请求有关的,构造参数是一个host一个port

LiveRef构造函数点击this进去

可以看到进行了赋值,host和port都在endpoint里面了,而这个endpoint在LiveRef里面,注意这些id,会发现整个过程处理的都是一个LiveRef

以上完成了对LiveRef的建立,代码会回到前面

一直走,其中这一步把之前的LiveRef给ref,操作对象一直都是同一个

跳到这里又调用了一个`exportObect,继续跟进

一直跟到这里出现了stub,这个是在整个RMI一个很重要的东西

在这里先放一张原理图

RMI在服务端建立的时候,现在服务端新建一个stub,再把stub传给RMI registry,最后让RMI Client去获取stub

这个stub在后面一个createProxy的地方被创建,看名字就知道是创建代理,跟进去看一下

可以看到这里有一个判断,这里不会进入,先不管,后面接触了再理解

然后到了后面就是创建动态代理,看一下里面的参数

第一个参数是一个AppClassLoader,第二个是个远程接口,第三个是个调用处理器,可以看到里面包裹的就是我们之前一直操作的Ref,始终处理的对象就是这一个。

创建完动态代理后出来,可以看到stub已经有内容,里面也带上了Ref

再继续往后,看到代码在创建一个Target,我们把taget理解成一个总封装,把我们之前创建的有用的参数包起来

看一下构造函数里面怎么操作的

看里面的值,一直都是一个LiveRef

一路步过跳出target,看到后面又进入一个exportObject()

一路跟进,到后面发现进入到TCPTransport里面的exportObject(),一进来就看到里面一个listen()函数,这里相当于开始处理真正的网络请求

跟到一定的地方会看到里面新建一个socket,等待连接

后面的thread里就去做完成连接之后的事,如果有连接,就会进入run

后面还有一个地方给port赋值

listen()主要就是开了一个socket,在整个流程里添加了port,这块内容记录在stub里面

发布服务后的记录

再继续一直跟,出现了两个put,RMI会把所有信息保存在两个table里,相当于一个记录作用吧,这块就不细看了

服务端创建小结

思路还是比较清晰的,主要就是一直利用exportObject()包装对象,一直处理的对象也就是那个LiveRef,进行各种各样的封装赋值。顺便给了端口,开了socket。

完成之后还有一个记录,是保存在静态的HashMap里面的,Drunk师傅说当日志理解就可以了。

服务端自己创建远程服务整个过程不存在漏洞。

创建注册端+绑定

创建注册端和服务端还有点像,本质上也是把服务发布到网上,这块过的快一点

创建注册中心

断点打在这里

然后进入到createRegistry()->RegistryImpl(),这里传入端口号1099

再跟进,中间有些别的代码是些安全检查,我们一直看到136行,这里新建了一个我们很熟悉的LiveRef和一个UnicastServerRef

和之前创建远程服务端流程很像,我们跟进到里面的setup看一下

里面也是用到exportObject(),对比一下之前创建远程对象的exportObject()

对比一下他们的参数,主要区别就是第三个参数,服务端是false,注册端是true,描述是permanent,很好理解,这代表我们注册端注册的服务是一个永久对象,服务端的服务是一个临时对象

继续跟进,也是来到创建stub的地方,但是这个和之前服务端的创建就不一样,进入到createProxy()

这次这里的判断就比较重要,我们跟进stubClassExists()判断

大概逻辑就是看能不能获取到RegistryImpl_Stub这个类,如果有就返回true,没有就false,我们这个版本的java是有RegistryImpl_Stub这个类的,所以返回true,我们会进入到if语句里

所以下一步是进入到createStub()里面,跟进看一下

就是通过反射创建这个对象,里面放的是ref

相比于之前发布远程对象中的 Stub,是一个动态代理,里面放的是一个 ref。
现在发布远程对象是用 forName 创建的,里面放的也是 ref,是一致的。

创建完回去再往下,我们会进入到setSkeleton()

进去里面有一个createSkeleton(),根据前面的流程图,我们知道skeleton是作为服务端的代理

skeleton是通过forname()的方法创建的

再往后,又来到target,作用也很前面服务端一样把数据包裹进去

此时看变量情况,ref里面多了skel对象

迅速跳过,到下面的exportObject(),流程和前面一样,直到一个super.exportObject(target);跟进去

有一个ObjectTable.putTarget(target) 又是一次封装

一路赋值完,我们关注一下控制台变量的情况,主要是点开objTable,可以看到有三个target

target@801里面disp和stub里面包的ref是同一个,端口号都是1099,stub是一个RegustryImpl_Stub

target@842里面的stub是$proxy对象的,也可以看到ref详情

再看target@840,里面的stub是一个DGCImpl_Stub,是分布式垃圾回收的一个对象,后面会再接触到

绑定

主要是这个bind()方法,比较简单,标注在图里了

小结

注册端创建和服务端创建总体还是差不多的,区别在于一个是持久的一个是临时的,还有stub的判断和创建方式也有些不同。关于为什么最后数据有三个target,且里面stub的类型还不一样,要到后面才能解答。

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

*这块存在漏洞

三个代码都打断点

进入getRegistry(),会看到很多之前熟悉的操作,赋值LiveRef,createProxy等

和之前一样新建一个ref,可以看到ip端口信息都在里面了,这里就算我们获取了注册中心等stub,后面去查找远程对象

代码第二句进来看到很多log相关的语句,无关紧要,跳了

直到这个lookup,这里因为java版本对不上,没办法打断点,直接看逻辑吧

此时看到变量情况,会看到有一个param_1="remoteObj",这个是传参的var1,这个参数是作为序列化数据传进去的,注册中心后续会通过反序列化读取。

接着有一个supre.ref.invoke(var2),查看详情可以知道是UnicastRef类,invoke()方法里面有 call.executeCall()

call.executeCall()里面处理真正的网络请求

进入到call.executeCall()里面有一个readObject()

看到in变量,不难理解就是数据流的东西

这里处理异常本意是好,但是如果注册中心返回的是一个恶意的流,会导致在这个地方反序列化。这个点更隐蔽,影响面更大,因为所有处理网络请求都会经过这个地方

以上就是客户端和注册中心之间的操作,f8回到主函数

可以看到我们已经获取了remoteObj这个动态代理,里面包含了一个ref

客户端请求服务端–客户端

这里断点打在第三句

因为此时helloService是一个动态代理类,所以一开始会进入到invoke

重点看到最底下的invokeRemoteMethod方法,跟进去,里面有一个ref.invoke(),是一个重载的方法,里面是用来创建一个连接,具体逻辑如下

继续走,有一个marshalValue方法,里面会做一堆判断,然后序列化我们调用远程函数时传进去的参数

再继续往前,又遇到call.executeCall(),前文也提到了,RMI执行网络请求的时候就会一定会走到这个地方,是存在危险的

后面就是unmarshalValue(),因为我们传入的参数类型是String,不符合前面一系列判断,这里会进行一次反序列化操作,把数据读回来,这里也是一个攻击点

然后一路跟进就走会主函数,代码完成远程函数调用

小结一下
  • 存在攻击的点,注册中心->服务端,查找远程对象的时候存在攻击点,具体表现为服务端打客户端,入口类是call.executeCall(),里面再抛出异常的时候存在反序列化点
  • 服务端->客户端两个攻击点,一个是call.executeCall(),另一个是unmarshalValue()
注册中心处理客户端请求

从注册中心角度看客户端请求,主要是处理skel,前面新建注册中心的过程有一个listen(),最后肯定会走到RegistryImpl_Skel里面(具体过程可以回顾白日梦组长的视频)

断点打在这里

RMIServer开启调试模式,RMIClient运行程序,调试会自动跳到这里

看一下此时target里面有一个stub,stub里面的ref指向的是1099端口

往下走到final Dispatcher disp = target.getDispatcher();,这一步是把skel的值放到disp中

再继续往下有一个disp.dispatch,跟进去

oldDispatch()

disp.dispatch()->oldDispatch()->skel.dispatch()

重点是skel.dispatch,这部分源码主要是case,以下这段引自

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

  • list
  • bind
  • rebind
  • unbind
  • lookup

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

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

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

只要中间是有反序列化就是可以攻击的,而且我们是从客户端打到注册中心,这其实是黑客们最喜欢的攻击方式。我们来看一看谁可以攻击。

比如case 0里面有readObject()

一圈看下来只有list没有

小结

客户端请求的时候,注册中心主要就是处理target,完成skel的生成和处理

漏洞点主要在dispatch,多数case里面都有readObject(),方便后续利用

服务端处理客户端请求

继续走一样的流程,但是这次的target里面stub是$proxy0,和注册中心的流程有些区别

来到这里,因为skel是null,所以不会进入oldDispatch()

后面可以看到一个method,参数是我们传入的参数sayhello(要调用的远程函数名)

继续往下走,重点是unmarshalValue(),这个部分可以理解成对称。前面客户发送请求的时候,把参数用marshalValue序列化,这里用unmarshalValue反序列化出来,还有其他地方有这样的对应。

DGC

前面创建注册中心的时候有一个DGCImpl_Stub,现在看一下是什么机制

断点打在这里

前面说会把taget放到一个静态表里,相当于日志记录作用,在put之前,我们可以看到变量里面就有这个DGC,而且还存放在static里面

现在来看DGC是怎么创建的,主要就是这两句,看似没什么操作

但是这里用了静态变量 调用静态变量会触发类的初始化

看到DGCImpl里面有一个static方法,作用是 class initializer,断点打在这里

后面又有createProxy(),进去里面有一个createstub

这里像注册中心创建远程服务一样,看是否可以获得这个类DGCImpl_Stub,是有的

整个过程就像注册中心创建远程服务,但是目的不一样,这里创建的是用于回收机制的,并且端口随机

DGC有漏洞的地方:看到这个类DGCImpl_Stub本身,里面有clean和dirty方法

DGCImpl_Skel也有

  • DGC是自动创建的一个过程,用于清理内存。客户端和服务端都有攻击点,而且这是自动生成的,只要创建了远程对象就会有DGC服务

参考

【Java反序列化RMI专题-没有人比我更懂RMI】https://www.bilibili.com/video/BV1L3411a7ax?p=10&vd_source=46e5237289ae6c1a3c7bcab6091e42a6

https://drun1baby.top/2022/07/19/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E4%B9%8BRMI%E4%B8%93%E9%A2%9801-RMI%E5%9F%BA%E7%A1%80/

碎碎念

这篇拖了好久好久好久,博客也没有保持更新频率,真是太拖拉了,战线拉太长现在结束了没有心情注意格式和内容正确了,后面有空再修一下吧。学这部分内容相对比较枯燥,总体还是好理解了,算是自己的java基础多添加了一块砖。希望后面赶上进度。

 评论
评论插件加载失败
Powered By Valine
v1.5.2