Java安全[RMI(1)]

熟悉的名字,在CVE学习中,其中Apache solrlog4j漏洞和weblogic远程代码执行都提到了RMI,作用如名, RMI(remote method invocation)远程方法调用

Java安全[RMI(1)]

RMI的目标其实和RPC类似,是让某个Java虚拟机上的对象调用另一个Java虚拟机上的方法,只不过RMIJava中独有的一种机制。

RPC(Remote Procedure Call)远程过程调用协议,是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的协议。

在分布式计算中,RPC允许运行于一台计算机的程序调用另一个地址空间的子程序,而程序员就像调用本地程序一样,无需额外地为这个交互作用编程。RPC是一种CS模式,经典实现是一个通过发送请求-接受回应进行信息交互的系统。

既然是远程调用,那么肯定是存在谁调用谁的关系,这就构成了RMI ServerRMI Client,在Server中实现远程调用的函数和接口,而Client需要知道想要调用方法的接口,然后访问执行即可。

RMIServer

⼀个RMI Server分为三部分:

  1. ⼀个继承了 java.rmi.Remote 的接⼝,其中定义我们要远程调⽤的函数,⽐如这⾥的 hello()
  2. ⼀个实现了此接⼝的类
  3. ⼀个主类,⽤来创建Registry,并将上⾯的类实例化后绑定到⼀个地址。这就是我们所谓的Server 了。

先编写一个RMI Server

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
26
27
28
29
package RMI_1;
import java.rmi.Naming;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.server.UnicastRemoteObject;
public class RMIServer {
public interface IRemoteHelloWorld extends Remote {
String hello(String s) throws RemoteException;
}
public class RemoteHelloWorld extends UnicastRemoteObject implements
IRemoteHelloWorld {
protected RemoteHelloWorld() throws RemoteException {
super();
}
public String hello(String s) throws RemoteException {
System.out.println("call from");
return "Hello"+s;
}
}
private void start() throws Exception {
RemoteHelloWorld h = new RemoteHelloWorld();
LocateRegistry.createRegistry(1099);
Naming.rebind("rmi://127.0.0.1:1099/Hello", h);
}
public static void main(String[] args) throws Exception {
new RMIServer().start();
}
}

分析一下代码,

  1. 接口定义

先定义一个名为IRemoteHelloWorld的接口,它扩展了Remote接口,这是RMI中的一个标记接口。接口声明了一个hello()方法,该方法可以抛出RemoteException异常,这个接口定义了一个远程方法,客户端可以通过RMI调用它。

1
2
3
public interface IRemoteHelloWorld extends Remote {
public String hello(String s) throws RemoteException;
}
  1. 远程对象的实现

这里定义了一个名为RemoteHelloWorld类,实现了IRemoteHelloWorld接口并继承UnicastRemoteObject类。这个类的构造函数调用父类,也就是UnicastRemoteObject类的构造函数,用于创建一个远程对象。RemoteHelloWorld还实现了hello()方法,该方法奖打印一条消息call from,并返回Hello World给调用该方法的对象(如,客户端服务器)。

1
2
3
4
5
6
7
8
9
10
public class RemoteHelloWorld extends UnicastRemoteObject implements
IRemoteHelloWorld {
protected RemoteHelloWorld() throws RemoteException {
super();
}
public String hello(String s) throws RemoteException {
System.out.println("call from");
return "Hello"+s;
}
}
  1. 启动RMI服务器方法定义和执行

start() 方法用于启动RMI服务器。

  • 创建一个 RemoteHelloWorld 实例 h,这个实例将充当远程对象。
  • 通过 LocateRegistry.createRegistry(1099) 创建了一个RMI注册表,并指定它监听在1099端口上。
  • 使用 Naming.rebind() 将远程对象 h 绑定到了 rmi://127.0.0.1:1099/Hello 这个名称下。
1
2
3
4
5
6
7
8
9
10
public class RMIServer {
private void start() throws Exception {
RemoteHelloWorld h = new RemoteHelloWorld();
LocateRegistry.createRegistry(1099);
Naming.rebind("rmi://127.0.0.1:1099/Hello", h);
}
public static void main(String[] args) throws Exception {
new RMIServer().start();
}
}
  1. main方法

创建RMIServer的实例,并调用start()方法启动RMIServer

RMIClient

RMI客户端相比于RMI服务端的代码就简单一点,只需要访问调用服务端的远程方法即可

1
2
3
4
5
6
7
8
9
10
package RMI_1;
import java.rmi.Naming;
public class TrainMain {
public static void main(String[] args) throws Exception {
RMIServer.IRemoteHelloWorld hello = (RMIServer.IRemoteHelloWorld)
Naming.lookup("rmi://10.19.16.44:1099/Hello");
String ret = hello.hello("Ttoc");
System.out.println(ret);
}
}

这里展示的只是本地进行调用RMIServer,要实现真正的RMI,客户端需要服务端提供接口实现方式,如上面代码所示的IRemoteHelloWorld,一般不会把接口和实现都包含在一个类中,而是分开多个文件,这里是为了方便。

客户端只需要接口打包成jar,这样就能知道RMI可以调用的方法有哪些,并知道服务端的ip和端口即可,然后使⽤ Naming.lookupRegistry中寻找到名字是Hello的对象,后⾯的使⽤就和在本地使⽤⼀样了。

image-20230913101103425

jar打包命令,这就实现了把接口打包的结果

image-20230913103004923

最后在IDEA中将包加到项目结构里即可。

image-20230917111005900

image-20230917110908280

RMI流量分析

wireshark抓包看看RMI的通讯数据原理

为了方便直观看出服务端和客户端,于是用虚拟机跑服务端,主机跑客户端,这里就不会两个ip都是一样的了

服务端ip:192.168.169.131

客户端ip:192.168.126.1(实际ip为 10.19.16.44,但是由于主机是通过虚拟机网卡访问的所以,抓虚拟机网卡的流量包时,网卡ip就是主机的)

这里将IRemoteHelloWorld独立为一个文件,所以客户端代码有点不一样,服务端删不删IRemoteHelloWorld都一样

1
2
3
4
5
6
7
8
9
10
11
package RMI_1;
import java.rmi.Naming;

public class RMI_Client {
public static void main(String[] args) throws Exception {
IRemoteHelloWorld hello = (IRemoteHelloWorld)
Naming.lookup("rmi://192.168.169.131:1099/Hello");
String ret = hello.hello("Ttoc");
System.out.println(ret);
}
}

image-20230913132813979

抓个流量包看看,

image-20230914162413296

可以看到整体的过程中发生了两次tcp握手[灰色部分],也就是在实际情况下构成了两次tcp连接。

第一次是从客户端的19581端口访问服务器的1099端口,第二次是客户端的19584端口访问55947端口

其实第一次握手很容易理解,因为我们的客户端设置的就是访问服务端的1099端口,但是为什么后面会莫名其妙访问服务端的55947端口呢

在流量包的JRMI Return Data中,也就是服务端向客户端发送的流量中可以看到,在最后的服务端ip后面的一个字节\x00\x00\xda\x8b

image-20230914163214005

通过进制转化可以看到,这个字节正好是55947的对应的网络序列,这也就是为什么客户端会向服务器端的55947端口进行tcp握手。

image-20230914163037413

但其实这段数据中,从\xAC\xED开始后,后面的所有数据都属于Java序列化的内容,其中的ip和端口只是这个对象的一部分。

image-20230914164341663

其实可以简单总结一下RMI的流程,

首先客户端访问连接Registry,并在其中寻找Name名为Hello的对象,这个过程对应数据包中的JRMI,Call

而后Registry向客户端发送一串反序列化字符串,代表找到了Name=Hello的对象,这个过程对应数据包中的JRMI,ReturnData

客户端反序列化JRMI,ReturnData,发现该对象是一个远程对象,地址是192.168.169.131:55947,于是再与这个地址建立TCP连接,在这个新的连接中,才可以执行真正远程方法调用,也就是hello()

可以从下图直观的认识到RMI中各个元素的关系。

image-20230917102629249

(底下是RMI,单词写错了应该是invocation)

可以从先从RMI Server开始看,服务端先到RMI Registry上注册了一个Name的对象绑定关系;【如下代码,将RemoteHelloWorld类实例化,然后将其绑定到Hello这个Name上,这就是绑定,然后告诉Registry,这个对象能通过访问给定的名称进行访问】

1
2
3
RemoteHelloWorld h = new RemoteHelloWorld();
LocateRegistry.createRegistry(1099);
Naming.rebind("rmi://127.0.0.1:1099/Hello", h);

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框架起到的作用就是处理网络通信,序列化,反序列化等细节,使得客户端和服务器之间的通信就像是在本地方法执行一样,但是本质还是在服务器端进行的执行。