Drunkmars's Blog

RMI探究

字数统计: 2.4k阅读时长: 9 min
2022/01/15

RMI,是Remote Method Invocation(远程方法调用)的缩写,即在一个JVM中java程序调用在另一个远程JVM中运行的java程序,这个远程JVM既可以在同一台实体机上,也可以在不同的实体机上,两者之间通过网络进行通信。java RMI封装了远程调用的实现细节,进行简单的配置之后,就可以如同调用本地方法一样,比较透明地调用远端方法。

RMI

RMI包括以下三个部分:

  • Registry: 提供服务注册与服务获取。即Server端向Registry注册服务,比如地址、端口等一些信息,Client端从Registry获取远程对象的一些信息,如地址、端口等,然后进行远程调用。
  • Server: 远程方法的提供者,并向Registry注册自身提供的服务
  • Client: 远程方法的消费者,从Registry获取远程方法的相关信息并且调用

image-20220115093627888

RMI远程调用方法为:

  1. 客户调用客户端辅助对象stub上的方法
  2. 客户端辅助对象stub打包调用信息(变量,方法名),通过网络发送给服务端辅助对象skeleton
  3. 服务端辅助对象skeleton将客户端辅助对象发送来的信息解包,找出真正被调用的方法以及该方法所在对象
  4. 调用真正服务对象上的真正方法,并将结果返回给服务端辅助对象skeleton
  5. 服务端辅助对象将结果打包,发送给客户端辅助对象stub
  6. 客户端辅助对象将返回值解包,返回给调用者
  7. 客户获得返回值

image-20220115093648168

java.rmi

Remote

一个interface,这个interface中没有声明任何方法。只有定义在“remote interface”,即继承了Remote的接口中的方法,才可以被远程调用。

RemoteException

RemoteException是所有在远程调用中所抛出异常的超类,所有能够被远程调用的方法声明,都需要抛出此异常

Naming

提供向注册中心保存远程对象引用或者从注册中心获取远程对象引用的方法。这个类中的方法都是静态方法,每一个方法都包含了一个类型为String的name参数, 这个参数是URL格式,形如://host:port/name

Registry

一个interface, 其功能和Naming类似,每个方法都有一个String类型的name参数,但是这个name不是URL格式,是远程对象的一个命名。Registry的实例可以通过方法LocateRegistry.getRegistry()获得

LocateRegistry

用于获取到注册中心的一个连接,这个连接可以用于获取一个远程对象的引用。也可以创建一个注册中心。

RemoteObject

重新覆写了Object对象中的equals,hashCode,toString方法,从而可以用于远程调用

UnicastRemoteObject

用于RMI Server中导出一个远程对象并获得一个stub。这个stub封装了底层细节,用于和远程对象进行通信。

Unreferenced

一个interface, 声明了方法:void unreferenced()如果一个远程对象实现了此接口,则这个远程对象在没有任何客户端引用的时候,这个方法会被调用。

rmi使用

  1. 定义一个远程接口,这个接口需要继承Remote,并且接口中的每一个方法都需要抛出RemoteException异常
  2. 开发远程接口的实现类
  3. Registry的创建
  4. RMI Server的创建
  5. RMI Client的创建

首先Server段定义远程接口并抛出异常

1
2
3
public interface WorldClock extends Remote {
LocalDateTime getLocalDateTime(String zoneId) throws RemoteException;
}

Java的RMI规定此接口必须派生自java.rmi.Remote,并在每个方法声明抛出RemoteException。下一步是编写服务器的实现类,因为客户端请求的调用方法getLocalDateTime()最终会通过这个实现类返回结果

1
2
3
4
5
6
7
8
public class WorldClockService implements WorldClock
{

@Override
public LocalDateTime getLocalDateTime(String zoneId) throws RemoteException {
return LocalDateTime.now(ZoneId.of(zoneId)).withNano(0);
}
}

我们需要通过Java RMI提供的一系列底层支持接口,把上面编写的服务以RMI的形式暴露在网络上

1
2
3
4
5
6
7
8
9
10
11
public void start() throws Exception {
System.out.println("create World clock remote service...");
// 实例化一个WorldClock:
WorldClock worldClock = new WorldClockService();
// 将此服务转换为远程服务接口:
WorldClock skeleton = (WorldClock) UnicastRemoteObject.exportObject(worldClock, 0);
// 将RMI服务注册到1099端口:
Registry registry = LocateRegistry.createRegistry(1099);
// 注册此服务,服务名为"WorldClock":
registry.rebind("WorldClock", skeleton);
}

然后new RMIServer1().start()创建对象

1
2
3
public static void main(String[] args) throws Exception {
new RMIServer1().start();
}

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.UnicastRemoteObject;
import java.time.LocalDateTime;
import java.time.ZoneId;

/**
* @author Drunkmars
* @create 2022-01-14-18:45
*/
public class RMIServer1
{
public interface WorldClock extends Remote {
LocalDateTime getLocalDateTime(String zoneId) throws RemoteException;
}

public class WorldClockService implements WorldClock
{

@Override
public LocalDateTime getLocalDateTime(String zoneId) throws RemoteException {
return LocalDateTime.now(ZoneId.of(zoneId)).withNano(0);
}
}

public void start() throws Exception {
System.out.println("create World clock remote service...");
// 实例化一个WorldClock:
WorldClock worldClock = new WorldClockService();
// 将此服务转换为远程服务接口:
WorldClock skeleton = (WorldClock) UnicastRemoteObject.exportObject(worldClock, 0);
// 将RMI服务注册到1099端口:
Registry registry = LocateRegistry.createRegistry(1099);
// 注册此服务,服务名为"WorldClock":
registry.rebind("WorldClock", skeleton);
}

public static void main(String[] args) throws Exception {
new RMIServer1().start();
}


}

RMI要求服务器和客户端共享同一个接口,因此我们要把WorldClock.java这个接口文件复制到客户端,然后在客户端实现RMI调用

1
2
3
4
5
6
7
8
9
10
11
12
13
public class RMIClient1
{
public static void main(String[] args) throws NotBoundException, RemoteException {
// 连接到服务器localhost,端口1099:
Registry registry = LocateRegistry.getRegistry("localhost", 1099);
// 查找名称为"WorldClock"的服务并强制转型为WorldClock接口:
RMIServer1.WorldClock worldClock = (RMIServer1.WorldClock) registry.lookup("WorldClock");
// 正常调用接口方法:
LocalDateTime now = worldClock.getLocalDateTime("Asia/Shanghai");
// 打印调用结果:
System.out.println(now);
}
}

获取成功

image-20220114185955826

运行结果可知,因为客户端只有接口,并没有实现类,因此,客户端获得的接口方法返回值实际上是通过网络从服务器端获取的。整个过程实际上非常简单,对客户端来说,客户端持有的WorldClock接口实际上对应了一个“实现类”,它是由Registry内部动态生成的,并负责把方法调用通过网络传递到服务器端。而服务器端接收网络调用的服务并不是我们自己编写的WorldClockService,而是Registry自动生成的代码。我们把客户端的“实现类”称为stub,而服务器端的网络服务类称为skeleton,它会真正调用服务器端的WorldClockService,获取结果,然后把结果通过网络传递给客户端。整个过程由RMI底层负责实现序列化和反序列化,简略过程如下:

1
2
3
4
5
6
7
8
9
10
11
┌ ─ ─ ─ ─ ─ ─ ─ ─ ┐         ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
┌─────────────┐ ┌─────────────┐
│ │ Service │ │ │ │ Service │ │
└─────────────┘ └─────────────┘
│ ▲ │ │ ▲ │
│ │
│ │ │ │ │ │
┌─────────────┐ Network ┌───────────────┐ ┌─────────────┐
│ │ Client Stub ├─┼─────────┼>│Server Skeleton│──>│Service Impl │ │
└─────────────┘ └───────────────┘ └─────────────┘
└ ─ ─ ─ ─ ─ ─ ─ ─ ┘ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘

我们抓包看一下RMI的通信过程

image-20220115121312290

整个过程进行了两次TCP握手,也就是我们实际建立了两次TCP连接。第一次建⽴立TCP连接是连接远端 192.168.135.142 的1099端口,这也是我们在代码里看到的端口,二者进行沟通后,我向远端发送了一个“Call”消息,远端回复了一个“ReturnData”消息,然后我新建了一个TCP连接,连到远端的33769端口。

在“ReturnData”这个包中,返回了目标的IP地址 192.168.135.142 ,其后跟的⼀一个字节 \x00\x00\x83\xE9 ,刚好就是整数 33769 的网络序列列,aced是java序列化的标志,证明了通过序列化的方式进行传输。然后服务端(注册中心)给客户端发送了一个ReturnData,这个ReturnData就对应着Name为Hello的对象。然后与一个新的端口33769进行第二次的TCP握手连接。

image-20220115121544619

其实这段数据流中从 \xAC\xED 开始往后就是Java序列化数据了,IP和端口只是这个对象的一部分。

首先客户端连接Registry,并在其中寻找Name是Hello的对象,这个对应数据流中的Call消息;然后Registry返回一个序列列化的数据,这个就是找到的Name=Hello的对象,这个对应数据流中的ReturnData消息;客户端反序列化该对象,发现该对象是一个远程对象,地址在 192.168.135.142:33769 ,于是再与这个地址建立TCP连接;在这个新的连接中,才执行真正远程方法调用,也就是 hello()

新建一个RMI Registry的时候,都会直接绑定一个对象在上面,也就是说代码中的Server其实包含了Registry和Server两部分

1
2
LocateRegistry.createRegistry(1099);
Naming.bind("rmi://127.0.0.1:1099/Hello", new RemoteHelloWorld());

第一行创建并运行RMI Registry,第二行将RemoteHelloWorld对象绑定到Hello这个名字上。

Naming.bind 的第一个参数是一个URL,形如: rmi://host:port/name 。其中,host和port就是RMI Registry的地址和端口,name是远程对象的名字。

如果RMI Registry在本地运行,那么host和port是可以省略的,此时host默认是 localhost ,port默认是 1099

rmi攻击

RMI Registry是一个远程对象管理的地方,可以理解为一个远程对象的“后台”。尝试直接访问“后台”功能,比如修改远程服务器上Hello对应的对象,会报错java.rmi.ServerException

1
2
RemoteHelloWorld h = new RemoteHelloWorld();
Naming.rebind("rmi://192.168.1.5:1099/Hello", h);

Java对远程访问RMI Registry做了限制,只有来源地址是localhost的时候,才能调用rebind、bind、unbind等方法,而list和lookup方法可以远程调用,list方法可以列出目标上所有绑定的对象,lookup作用就是获得某个远程对象

1
String[] s = Naming.list("rmi://192.168.1.5:1099");
CATALOG
  1. 1. RMI
    1. 1.1. java.rmi
      1. 1.1.1. Remote
      2. 1.1.2. RemoteException
      3. 1.1.3. Naming
      4. 1.1.4. Registry
      5. 1.1.5. LocateRegistry
      6. 1.1.6. RemoteObject
      7. 1.1.7. UnicastRemoteObject
      8. 1.1.8. Unreferenced
    2. 1.2. rmi使用
    3. 1.3. rmi攻击