熟悉的名字,在CVE学习中,其中Apache solr
的log4j
漏洞和weblogic
远程代码执行都提到了RMI
,作用如名, RMI(remote method invocation)
即远程方法调用
。
Java安全[RMI(1)]
RMI
的目标其实和RPC
类似,是让某个Java
虚拟机上的对象调用另一个Java
虚拟机上的方法,只不过RMI
是Java
中独有的一种机制。
RPC(Remote Procedure Call)
是远程过程调用协议
,是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的协议。在分布式计算中,RPC允许运行于一台计算机的程序调用另一个地址空间的子程序,而程序员就像调用本地程序一样,无需额外地为这个交互作用编程。RPC是一种
CS
模式,经典实现是一个通过发送请求-接受回应
进行信息交互的系统。
既然是远程调用,那么肯定是存在谁调用谁的关系,这就构成了RMI Server
和RMI Client
,在Server
中实现远程调用的函数和接口,而Client
需要知道想要调用方法的接口,然后访问执行即可。
RMIServer
⼀个RMI Server
分为三部分:
- ⼀个继承了
java.rmi.Remote
的接⼝,其中定义我们要远程调⽤的函数,⽐如这⾥的hello()
- ⼀个实现了此接⼝的类
- ⼀个主类,⽤来创建
Registry
,并将上⾯的类实例化后绑定到⼀个地址。这就是我们所谓的Server
了。
先编写一个RMI Server
1 | package RMI_1; |
分析一下代码,
- 接口定义
先定义一个名为IRemoteHelloWorld
的接口,它扩展了Remote
接口,这是RMI
中的一个标记接口。接口声明了一个hello()
方法,该方法可以抛出RemoteException
异常,这个接口定义了一个远程方法,客户端可以通过RMI
调用它。
1 | public interface IRemoteHelloWorld extends Remote { |
- 远程对象的实现
这里定义了一个名为RemoteHelloWorld
类,实现了IRemoteHelloWorld
接口并继承UnicastRemoteObject
类。这个类的构造函数调用父类,也就是UnicastRemoteObject
类的构造函数,用于创建一个远程对象。RemoteHelloWorld
还实现了hello()
方法,该方法奖打印一条消息call from
,并返回Hello World
给调用该方法的对象(如,客户端服务器)。
1 | public class RemoteHelloWorld extends UnicastRemoteObject implements |
- 启动RMI服务器方法定义和执行
start()
方法用于启动RMI
服务器。
- 创建一个
RemoteHelloWorld
实例h
,这个实例将充当远程对象。 - 通过
LocateRegistry.createRegistry(1099)
创建了一个RMI
注册表,并指定它监听在1099
端口上。 - 使用
Naming.rebind()
将远程对象h
绑定到了rmi://127.0.0.1:1099/Hello
这个名称下。
1 | public class RMIServer { |
- main方法
创建RMIServer
的实例,并调用start()
方法启动RMIServer
。
RMIClient
RMI客户端相比于RMI服务端的代码就简单一点,只需要访问调用服务端的远程方法即可
1 | package RMI_1; |
这里展示的只是本地进行调用RMIServer
,要实现真正的RMI
,客户端需要服务端提供接口实现方式,如上面代码所示的IRemoteHelloWorld
,一般不会把接口和实现都包含在一个类中,而是分开多个文件,这里是为了方便。
客户端只需要接口打包成jar
,这样就能知道RMI
可以调用的方法有哪些,并知道服务端的ip
和端口即可,然后使⽤ Naming.lookup
在Registry
中寻找到名字是Hello
的对象,后⾯的使⽤就和在本地使⽤⼀样了。
jar
打包命令,这就实现了把接口打包的结果
最后在IDEA中将包加到项目结构里即可。
RMI流量分析
用wireshark
抓包看看RMI的通讯数据原理
为了方便直观看出服务端和客户端,于是用虚拟机跑服务端,主机跑客户端,这里就不会两个ip都是一样的了
服务端ip:
192.168.169.131
客户端ip:
192.168.126.1
(实际ip为10.19.16.44
,但是由于主机是通过虚拟机网卡访问的所以,抓虚拟机网卡的流量包时,网卡ip就是主机的)
这里将IRemoteHelloWorld
独立为一个文件,所以客户端代码有点不一样,服务端删不删IRemoteHelloWorld
都一样
1 | package RMI_1; |
抓个流量包看看,
可以看到整体的过程中发生了两次tcp握手[灰色部分],也就是在实际情况下构成了两次tcp连接。
第一次是从客户端的19581
端口访问服务器的1099
端口,第二次是客户端的19584
端口访问55947
端口
其实第一次握手很容易理解,因为我们的客户端设置的就是访问服务端的1099端口,但是为什么后面会莫名其妙访问服务端的55947端口呢
在流量包的JRMI Return Data
中,也就是服务端向客户端发送的流量中可以看到,在最后的服务端ip后面的一个字节\x00\x00\xda\x8b
通过进制转化可以看到,这个字节正好是55947
的对应的网络序列,这也就是为什么客户端会向服务器端的55947
端口进行tcp握手。
但其实这段数据中,从\xAC\xED
开始后,后面的所有数据都属于Java序列化的内容,其中的ip和端口只是这个对象的一部分。
其实可以简单总结一下RMI的流程,
首先客户端访问连接Registry
,并在其中寻找Name名为Hello的对象,这个过程对应数据包中的JRMI,Call
。
而后Registry
向客户端发送一串反序列化字符串,代表找到了Name=Hello
的对象,这个过程对应数据包中的JRMI,ReturnData
。
客户端反序列化JRMI,ReturnData
,发现该对象是一个远程对象,地址是192.168.169.131:55947
,于是再与这个地址建立TCP连接,在这个新的连接中,才可以执行真正远程方法调用,也就是hello()
。
可以从下图直观的认识到RMI
中各个元素的关系。
(底下是RMI
,单词写错了应该是invocation
)
可以从先从RMI Server
开始看,服务端先到RMI Registry
上注册了一个Name的对象绑定关系;【如下代码,将RemoteHelloWorld
类实例化,然后将其绑定到Hello这个Name上,这就是绑定,然后告诉Registry
,这个对象能通过访问给定的名称进行访问】
1 | RemoteHelloWorld h = new RemoteHelloWorld(); |
RMI Registry
相当于一个网关,它本身虽然绑定了远程需要调用的对象,但是它自己是不会执行远程方法的。
而后是RMI Client
,当它知道Name
后,会向RMI Registry
发送查询请求【如下代码,客户端这里用服务器给的对应的调用的接口IRemoteHelloWorld
,创建对象hello
,然后向RMI Registry
发送想要调用的注册名字,RMI Registry
使用这个信息来查找并返回相应的远程对象引用】
1 | IRemoteHelloWorld hello = (IRemoteHelloWorld) Naming.lookup("rmi://192.168.169.131:1099/Hello"); |
得到远程方法的绑定关系,然后通过这个绑定关系再次连接RMI Server;
1 | String ret = hello.hello("Ttoc"); |
这里hello
就是获得了RemoteHelloWorld
类的远程对象引用,然后用hello.hello("Ttoc")
进行调用这个类中的hello
方法。而这个方法的调用会通过网络发送到服务器端,服务器端会执行对应方法,并将结果返回客户端。这个过程中RMI框架起到的作用就是处理网络通信,序列化,反序列化
等细节,使得客户端和服务器之间的通信就像是在本地方法执行一样,但是本质还是在服务器端进行的执行。