RMI原理与使用
何为RMI
RMI(Remote Method Invocation)即远程方法调用,是分布式编程中的一个基本思想。实现远程方法调用的技术有很多,比如CORBA、WebService,这两种都是独立于各个编程语言的。
而Java RMI是专为Java环境设计的远程方法调用机制,是一种用于实现远程调用(RPC,Remote Procedure Call)的Java API,能直接传输序列化后的Java对象和分布式垃圾收集。它的实现依赖于JVM,因此它支持从一个JVM到另一个JVM的调用。
在Java RMI中,远程服务器实现具体的Java方法并提供接口,客户端本地仅需根据接口类的定义,提供相应的参数即可调用远程方法,其中对象是通过序列化方式进行编码传输的。所以平时说的反序列化漏洞的利用经常是涉及到RMI,就是这个意思。
RMI依赖的通信协议为JRMP(Java Remote Message Protocol,Java远程消息交换协议),该协议是为Java定制的,要求服务端与客户端都必须是Java编写的。
RMI的模式与交互过程
设计模式
RMI的设计模式中,主要包括以下三个部分的角色:
- Registry:提供服务注册与服务获取。即Server端向Registry注册服务,比如地址、端口等一些信息,Client端从Registry获取远程对象的一些信息,如地址、端口等,然后进行远程调用。
- Server:远程方法的提供者,并向Registry注册自身提供的服务
- Client:远程方法的消费者,从Registry获取远程方法的相关信息并且调用
交互过程
RMI交互过程如图所示:
在设计模式中,3个角色是的交互过程可简单概述为:
- 首先,启动RMI Registry服务,启动时可以指定服务监听的端口,也可以使用默认的端口(1099);
- 其次,Server端在本地先实例化一个提供服务的实现类,然后通过RMI提供的Naming/Context/Registry等类的
bind
或rebind
方法将刚才实例化好的实现类注册到RMI Registry上并对外暴露一个名称; - 最后,Client端通过本地的接口和一个已知的名称(即RMI Registry暴露出的名称),使用RMI提供的Naming/Context/Registry等类的
lookup
方法从RMI Service那拿到实现类。这样虽然本地没有这个类的实现类,但所有的方法都在接口里了,便可以实现远程调用对象的方法了;
此外,我们可以看到,从逻辑上来看数据是在Client和Server之间横向流动的,但是实际上是从Client到Stub,然后从Skeleton到Server这样纵向流动的。
下面详细说下其中几个重要的概念。
远程对象
在RMI中的核心就是远程对象,一切都是围绕这个东西来进行的。
顾名思义,远程对象是存在于服务端以供客户端调用的对象。任何可以被远程调用的对象都必须实现 java.rmi.Remote
接口,远程对象的实现类必须继承UnicastRemoteObject类。如果不继承UnicastRemoteObject类,则需要手工初始化远程对象,在远程对象的构造方法的调用UnicastRemoteObject.exportObject()
静态方法。这个远程对象中可能有很多个函数,但是只有在远程接口中声明的函数才能被远程调用,其他的公共函数只能在本地的JVM中使用。
使用远程方法调用,必然会涉及参数的传递和执行结果的返回。参数或者返回值可以是基本数据类型,当然也有可能是对象的引用。所以这些需要被传输的对象必须可以被序列化,这要求相应的类必须实现 java.io.Serializable
接口,并且客户端的serialVersionUID字段要与服务器端保持一致。
Stub和Skeleton
RMI采用代理来负责客户与远程对象之间通过Socket进行通信的细节,主要是为远程对象分别生成了客户端代理和服务端代理,其中位于客户端的代理类称为Stub即存根,位于服务端的代理类称为Skeleton即骨干网。
Stub和Skeleton的具体通信过程如图:
方法调用从客户端对象经存根(Stub)、远程引用层(Remote Reference Layer)和传输层(Transport Layer)向下,传递给主机,然后再次经传输层,向上穿过远程调用层和骨干网(Skeleton),最终到达服务器对象。
Stub存根:扮演着远程服务器对象的代理的角色,使该对象可被客户激活。
远程引用层:处理语义、管理单一或多重对象的通信,决定调用是应发往一个服务器还是多个。
传输层:管理实际的连接,并且追踪可以接受方法调用的远程对象。
Skeleton骨干网:完成对服务器对象实际的方法调用,并获取返回值。
返回值向下经远程引用层、服务器端的传输层传递回客户端,再向上经传输层和远程调用层返回。最后,存根获得返回值。
RMI注册表
Stub的获取方式有很多,常见的方法是调用某个远程服务上的方法,向远程服务获取存根。但是调用远程方法又必须先有远程对象的Stub,所以这里有个死循环问题。JDK提供了一个RMI注册表(RMIRegistry)来解决这个问题。RMIRegistry也是一个远程对象,默认监听在传说中的1099端口上,可以使用代码启动RMIRegistry,也可以使用rmiregistry命令。
简单来讲注册表就是客户端表获取服务端实现了远程方法接口的对象的Stub存根的。
工厂模式
如图,先假设:
- 有两个远程服务接口可供Client调用,Factory和Product接口
- FactoryImpl类实现了Factory接口,ProductImpl类实现了Product接口
工厂模式的处理流程为:
- FactoryImpl被注册到了RMI Registry中;
- Client端请求一个Factory的引用;
- RMI Registry返回Client端一个FactoryImpl的引用;
- Client端调用FactoryImpl的远程方法请求一个ProductImpl的远程引用;
- FactoryImpl返回给Client端一个ProductImpl引用;
- Client通过ProductImpl引用调用远程方法;
可以看到,客户端向注册表请求获取到指定的FactoryImpl的引用后,再通过调用FactoryImpl的远程方法请求一个ProductImpl的远程引用,从而调用到ProductImpl引用指向的远程方法。
这种RMI+Reference的技术在JNDI注入中是单独作为一种利用方式。
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核心特点之一就是动态类加载,如果当前JVM中没有某个类的定义,它可以从远程URL去下载这个类的class,动态加载的对象class文件可以使用Web服务的方式进行托管。这可以动态的扩展远程应用的功能,RMI注册表上可以动态的加载绑定多个RMI应用。对于客户端而言,服务端返回值也可能是一些子类的对象实例,而客户端并没有这些子类的class文件,如果需要客户端正确调用这些子类中被重写的方法,则同样需要有运行时动态加载额外类的能力。客户端使用了与RMI注册表相同的机制。RMI服务端将URL传递给客户端,客户端通过HTTP请求下载这些类。
在JNDI注入和反序列化漏洞的利用中,正是涉及到了动态类加载。
RMI实现
RMI程序的编写主要分为以下几个步骤。
定义一个远程接口
public interface IRemoteHelloWorld extends Remote {
public String hello() throws RemoteException;
}
- 远程接口必须继承
java.rmi.Remote
接口 - 接口的方法需要声明java.rmi.RemoteException报错
- 使用public声明方法,否则客户端在尝试加载实现远程接口的远程对象时会出错。(如果客户端、服务端放一起没关系)
接口的实现类
public class RemoteHelloWorld extends UnicastRemoteObject implements IRemoteHelloWorld {
protected RemoteHelloWorld() throws RemoteException {
super();
}
public String hello() throws RemoteException {
System.out.println("call from");
return "Hello world";
}
}
- 继承UnicastRemoteObject类,用于生成 Stub(存根)和 Skeleton(骨架)。(如果不继承UnicastRemoteObject类,则需要手工初始化远程对象,在远程对象的构造方法的调用
UnicastRemoteObject.exportObject()
静态方法。) - 构造函数需要抛出一个RemoteException错误
- 实现类中使用的对象必须都可序列化,即都继承
java.io.Serializable
创建Server和Registry
其实Server和Registry可以单独运行创建,其中Registry可通过代码启动也可通过rmiregistry命令启动,这里只进行简单的演示,将Server和Registry的创建、对象绑定注册表等都写到一块,且Registry直接代码启动:
public class RMIServer {
public static void main(String[] args) throws Exception {
// 实例化远程对象
RemoteHelloWorld h = new RemoteHelloWorld();
// 创建注册中心
LocateRegistry.createRegistry(1099);
// 绑定对象示例到注册中心
Naming.rebind("rmi://127.0.0.1:1099/Hello", h);
}
}
- 绑定的地址协议
rmi:
写不写都行; - port如果默认是1099,不写会自动补上,其他端口必须写
客户端并查找调用远程方法
客户端通过Naming.lookup()
来查找RMI Server端的远程对象并获取到本地客户端环境中输出出来:
public class RMIClient {
public static void main(String[] args) throws RemoteException, NotBoundException {
RMIServer.IRemoteHelloWorld hello = (RMIServer.IRemoteHelloWorld) Naming.lookup("rmi://127.0.0.1:1099/Hello");
String ret = hello.hello("input!gogogogo");
System.out.println(ret);
}
}
- 需要使用远程接口(此处是直接引用服务端的类,客户端不知道这个类的源代码也是可以的,重点是包名,类名必须一致,serialVersionUID一致)
Naming.lookup
查找远程对象,rmi:
也可省略
几个函数
这里小结下几个函数:
bind(String name, Object obj)
:注册对象,把对象和一个名字name绑定,这里的name其实就是URL格式。如果改名字已经与其他对象绑定,则抛出NameAlreadyBoundException错误;rebind(String name, Object obj)
:注册对象,把对象和一个名字name绑定。如果改名字已经与其他对象绑定,不会抛出NameAlreadyBoundException错误,而是把当前参数obj指定的对象覆盖原先的对象;lookup(String name)
:查找对象,返回与参数name指定的名字所绑定的对象;unbind(String name)
:注销对象,取消对象与名字的绑定;list(String name)
:列出目标上所有绑定的对象。