0x01 何为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编写的。

0x02 RMI的模式与交互过程

设计模式

RMI的设计模式中,主要包括以下三个部分的角色:

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

交互过程

RMI交互过程如图所示:

在设计模式中,3个角色是的交互过程可简单概述为:

  1. 首先,启动RMI Registry服务,启动时可以指定服务监听的端口,也可以使用默认的端口(1099);
  2. 其次,Server端在本地先实例化一个提供服务的实现类,然后通过RMI提供的Naming/Context/Registry等类的bind或rebind方法将刚才实例化好的实现类注册到RMI Registry上并对外暴露一个名称;
  3. 最后,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字段要与服务器端保持一致。

RMI注册表

Stub的获取方式有很多,常见的方法是调用某个远程服务上的方法,向远程服务获取存根。但是调用远程方法又必须先有远程对象的Stub,所以这里有个死循环问题。JDK提供了一个RMI注册表(RMIRegistry)来解决这个问题。RMIRegistry也是一个远程对象,默认监听在传说中的1099端口上,可以使用代码启动RMIRegistry,也可以使用rmiregistry命令。

Stub和Skeleton

RMI采用代理来负责客户与远程对象之间通过Socket进行通信的细节,主要是为远程对象分别生成了客户端代理和服务端代理,其中位于客户端的代理类称为Stub即存根,位于服务端的代理类称为Skeleton即骨干网。

Stub和Skeleton的具体通信过程如图:

方法调用从客户端对象经存根(Stub)、远程引用层(Remote Reference Layer)和传输层(Transport Layer)向下,传递给主机,然后再次经传输层,向上穿过远程调用层和骨干网(Skeleton),最终到达服务器对象。

Stub存根:扮演着远程服务器对象的代理的角色,使该对象可被客户激活。

远程引用层:处理语义、管理单一或多重对象的通信,决定调用是应发往一个服务器还是多个。

传输层:管理实际的连接,并且追踪可以接受方法调用的远程对象。

Skeleton骨干网:完成对服务器对象实际的方法调用,并获取返回值。

返回值向下经远程引用层、服务器端的传输层传递回客户端,再向上经传输层和远程调用层返回。最后,存根获得返回值。

工厂模式和代理模式

代理模式前面已经说了,这里主要是工厂模式。

如图,先假设:

  • 有两个远程服务接口可供Client调用,Factory和Product接口
  • FactoryImpl类实现了Factory接口,ProductImpl类实现了Product接口

工厂模式的处理流程为:

  1. FactoryImpl被注册到了RMI Registry中;
  2. Client端请求一个Factory的引用;
  3. RMI Registry返回Client端一个FactoryImpl的引用;
  4. Client端调用FactoryImpl的远程方法请求一个ProductImpl的远程引用;
  5. FactoryImpl返回给Client端一个ProductImpl引用;
  6. Client通过ProductImpl引用调用远程方法;

可以看到,客户端向注册表请求获取到指定的FactoryImpl的引用后,再通过调用FactoryImpl的远程方法请求一个ProductImpl的远程引用,从而调用到ProductImpl引用指向的远程方法。

这种RMI+Reference的技术在JNDI注入中是单独作为一种利用方式。

0x03 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()如果一个远程队形实现了此接口,则这个远程对象在没有任何客户端引用的时候,这个方法会被调用。

0x04 动态类加载

RMI核心特点之一就是动态类加载,如果当前JVM中没有某个类的定义,它可以从远程URL去下载这个类的class,动态加载的对象class文件可以使用Web服务的方式进行托管。这可以动态的扩展远程应用的功能,RMI注册表上可以动态的加载绑定多个RMI应用。对于客户端而言,服务端返回值也可能是一些子类的对象实例,而客户端并没有这些子类的class文件,如果需要客户端正确调用这些子类中被重写的方法,则同样需要有运行时动态加载额外类的能力。客户端使用了与RMI注册表相同的机制。RMI服务端将URL传递给客户端,客户端通过HTTP请求下载这些类。

在JNDI注入和反序列化漏洞的利用中,正是涉及到了动态类加载。

0x05 编写RMI的步骤

RMI程序的编写主要分为以下几个步骤。

定义服务端供远程调用的类

在此之前先定义一个可序列化的Model层的用户类,其实例可放置于服务端进行远程调用:

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
import java.io.Serializable;

public class PersonEntity implements Serializable {
private int id;
private String name;
private int age;

public void setId(int id) {
this.id = id;
}

public int getId() {
return id;
}

public void setName(String name) {
this.name = name;
}

public String getName() {
return name;
}

public void setAge(int age) {
this.age = age;
}

public int getAge() {
return age;
}
}

定义一个远程接口

远程接口必须继承java.rmi.Remote接口,且抛出RemoteException错误:

1
2
3
4
5
6
7
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.util.List;

public interface PersonService extends Remote {
public List<PersonEntity> GetList() throws RemoteException;
}

开发接口的实现类

建立PersonServiceImpl实现远程接口,注意此为远程对象实现类,需要继承UnicastRemoteObject(如果不继承UnicastRemoteObject类,则需要手工初始化远程对象,在远程对象的构造方法的调用UnicastRemoteObject.exportObject()静态方法):

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
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
import java.util.LinkedList;
import java.util.List;

public class PersonServiceImpl extends UnicastRemoteObject implements PersonService {
public PersonServiceImpl() throws RemoteException {
super();
// TODO Auto-generated constructor stub
}

@Override
public List<PersonEntity> GetList() throws RemoteException {
// TODO Auto-generated method stub
System.out.println("Get Person Start!");
List<PersonEntity> personList = new LinkedList<PersonEntity>();

PersonEntity person1 = new PersonEntity();
person1.setAge(3);
person1.setId(0);
person1.setName("mi1k7ea");
personList.add(person1);

PersonEntity person2 = new PersonEntity();
person2.setAge(18);
person2.setId(1);
person2.setName("Alan");
personList.add(person2);

return personList;
}
}

创建Server和Registry

其实Server和Registry可以单独运行创建,其中Registry可通过代码启动也可通过rmiregistry命令启动,这里只进行简单的演示,将Server和Registry的创建、对象绑定注册表等都写到一块,且Registry直接代码启动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;

public class Program {
public static void main(String[] args) {
try {
PersonService personService=new PersonServiceImpl();
//注册通讯端口
LocateRegistry.createRegistry(6600);
//注册通讯路径
Naming.rebind("rmi://127.0.0.1:6600/PersonService", personService);
System.out.println("Service Start!");
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}

创建客户端并查找调用远程方法

这里我们通过Naming.lookup()来查找RMI Server端的远程对象并获取到本地客户端环境中输出出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.rmi.Naming;
import java.util.List;

public class Client {
public static void main(String[] args){
try{
//调用远程对象,注意RMI路径与接口必须与服务器配置一致
PersonService personService=(PersonService) Naming.lookup("rmi://127.0.0.1:6600/PersonService");
List<PersonEntity> personList=personService.GetList();
for(PersonEntity person:personList){
System.out.println("ID:"+person.getId()+" Age:"+person.getAge()+" Name:"+person.getName());
}
}catch(Exception ex){
ex.printStackTrace();
}
}
}

最后,我们看下模拟运行的场景。

先启动Server和Register,开启成功后显示“Server Start!”,然后运行我们的Client程序,可以看到客户端成功获取到了在Register注册的Server中的远程对象的内容:

当然,写法有很多,还可以参考这个不一样写法的Demo:https://blog.csdn.net/qq_28081453/article/details/83279066

几个函数

这里小结下几个函数:

  • bind(String name, Object obj):注册对象,把对象和一个名字name绑定,这里的name其实就是URL格式。如果改名字已经与其他对象绑定,则抛出NameAlreadyBoundException错误;
  • rebind(String name, Object obj):注册对象,把对象和一个名字name绑定。如果改名字已经与其他对象绑定,不会抛出NameAlreadyBoundException错误,而是把当前参数obj指定的对象覆盖原先的对象;
  • lookup(String name):查找对象,返回与参数name指定的名字所绑定的对象;
  • unbind(String name):注销对象,取消对象与名字的绑定;

0x06 参考

java rmi 使用教程及原理

Java RMI原理与使用

JAVA RMI 原理和使用浅析

学习笔记:JAVA RMI远程方法调用简单实例

深入理解JNDI注入与Java反序列化漏洞利用