THIS IS B3c0me

记录生活中的点点滴滴

0%

Java-RMI入门篇

前言:

本文参考文章为:

1.https://blog.csdn.net/cold___play/article/details/132086492

2.https://su18.org/post/rmi-attack/

3.https://blog.csdn.net/why811/article/details/132232205

感谢原创作者

一、RMI概述

1.1 简介

RMI 是 Java 提供的一个完善的简单易用的远程方法调用框架,采用客户/服务器通信方式,在服务器上部署了提供各种服务的远程对象,客户端请求访问服务器上远程对象的方法,它要求客户端与服务器端都是 Java 程序。

RMI 框架采用代理来负责客户与远程对象之间通过 Socket 进行通信的细节。RMI 框架为远程对象分别生成了客户端代理和服务器端代理。位于客户端的代理被称为存根(Stub),位于服务器端的代理类被称为骨架(Skeleton)。

1.2 原理

当客户端调用远程对象的一个方法时,实际上是调用本地存根对象的相应方法。存根对象与远程对象具有同样的接口。存根采用一种与平台无关的编码方式,把方法的参数编码为字节序列,这个编码过程被称为参数编组。RMI 主要采用Java 序列化机制进行参数编组。

存根把以下请求信息发送给服务器:

  • 被访问的远程对象的名字

  • 被调用的方法的描述

  • 编组后的参数的字节序列

服务器端接收到客户端的请求信息,然后由相应的骨架对象来处理这一请求信息,骨架对象执行以下操作:

  • 反编组参数,即把参数的字节序列反编码为参数
  • 定位要访问的远程对象
  • 调用远程对象的相应方法
  • 获取方法调用产生的返回值或者异常,然后对它进行编组
  • 把编组后的返回值或者异常发送给客户

客户端的存根接收到服务器发送过来的编组后的返回值或者异常,再对它进行反编组,就得到调用远程方法的返回结果

JDK5.0 之后,RMI 框架会在运行时自动为运程对象生成动态代理类(包括存根和骨架类),从而更彻底地封装了 RMI 框架的实现细节,简化了 RMI 框架的使用方式。

RMI交互图

Stub和Skeleton通信过程

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

存根:扮演着远程服务器对象的代理的角色,使该对象可被客户激活。
远程调用层:处理语义、管理单一或多重对象的通信,决定调用是应发往一个服务器还是多个。
传输层:管理实际的连接,并且追踪可以接受方法调用的远程对象。
骨干网:完成对服务器对象实际的方法调用,并获取返回值。返回值向下经远程引用层、服务器端的传输层传递回客户端,再向上经传输层和远程调用层返回。最后,存根获得返回值。

1.3 组成

RMI由三个部分构成:

  • rmiregistry(JDK提供的一个可以独立运行的程序,在bin目录下)
  • server端的程序,对外提供远程对象
  • client端的程序,想要调用远程对象的方法。

首先,启动rmiregistry服务,启动时可以指定服务监听的端口,也可以使用默认的端口(1099)。
其次,server端在本地先实例化一个提供服务的实现类,然后通过RMI提供的Naming/Context/Registry(下面实例用的Registry)等类的bind或rebind方法将刚才实例化好的实现类注册到rmiregistry上并对外暴露一个名称。
最后,client端通过本地的接口和一个已知的名称(即rmiregistry暴露出的名称)再使用RMI提供的Naming/Context/Registry等类的lookup方法从RMIService那拿到实现类。这样虽然本地没有这个类的实现类,但所有的方法都在接口里了,便可以实现远程调用对象的方法了。

1.4 数据传递

首先,先启动rmiregistry服务,启动时可以指定服务监听的端口,也可以使用默认的端口(1099)。
其次,server端在本地先实例化一个提供服务的实现类,然后通过RMI提供的Naming/Context/Registry(下面实例用的Registry)等类的bind或rebind方法将刚才实例化好的实现类注册到rmiregistry上并对外暴露一个名称。
最后,client端通过本地的接口和一个已知的名称(即rmiregistry暴露出的名称)再使用RMI提供的Naming/Context/Registry等类的lookup方法从RMIService那拿到实现类。这样虽然本地没有这个类的实现类,但所有的方法都在接口里了,便可以实现远程调用对象的方法了。

Java程序中引用类型(不包括基本类型)的参数传递是按引用传递的,对于在同一个虚拟机中的传递时是没有问题的,因为的参数的引用对应的是同一个内存空间,在分布式系统中,由于对象不存在于同一个内存空间,虚拟机A的对象引用对于虚拟机B没有任何意义,那么怎么解决这个问题呢?

方法一:

将引用传递更改为值传递,也就是将对象序列化为字节,然后使用该字节的副本在客户端和服务器之间传递,而且一个虚拟机中对该值的修改不会影响到其他主机中的数据;
但是对象的序列化也有一个问题,就是对象的嵌套引用就会造成序列化的嵌套,这必然会导致数据量的激增,因此我们需要有选择进行序列化。
在Java中一个对象如果能够被序列化,需要满足下面两个条件之一:
是Java的基本类型;
实现java.io.Serializable接口(String类即实现了该接口);
对于容器类,如果其中的对象是可以序列化的,那么该容器也是可以序列化的;
可序列化的子类也是可以序列化的;

方法二:

使用引用传递,每当远程主机调用本地主机方法时,该调用还要通过本地主机查询该引用对应的对象,在任何一台机器上的改变都会影响原始主机上的数据,因为这个对象是共享的;

RMI中的参数传递和结果返回可以使用的三种机制(取决于数据类型):

简单类型:按值传递,直接传递数据拷贝;
远程对象引用(实现了Remote接口):以远程对象的引用传递;
远程对象引用(未实现Remote接口):按值传递,通过序列化对象传递副本,本身不允许序列化的对象不允许传递给远程方法;

在调用远程对象的方法之前需要一个远程对象的引用,如何获得这个远程对象的引用在RMI中是一个关键的问题,如果将远程对象的发现类比于IP地址的发现可能比较好理解一些。

平常我们上网是通过域名来定位一个网站,实际上网络是通过IP地址来定位网站,因此其中就存在一个映射的过程,域名系统(DNS)就是为了这个目的出现的,在域名系统中通过域名来查找对应的IP地址来访问对应的服务器。

对应的,IP地址在这里就相当于远程对象的引用,而DNS则相当于一个注册表(Registry)

而域名在RMI中就相当于远程对象的标识符,客户端通过提供远程对象的标识符访问注册表,来得到远程对象的引用。这个标识符是类似URL地址格式的,它要满足的规范如下:

1
2
3
该名称是URL形式的,类似于http的URL,schema是rmi;
格式类似于rmi://host:port/name,host指明注册表运行的注解,port表明接收调用的端口,name是一个标识该对象的简单名称
主机和端口都是可选的,如果省略主机,则默认运行在本地;如果端口也省略,则默认端口是1099

二、RMI示例

2.1 创建接口

创建一个接口Hello,该接口需要继承Remote接口,接口所定义的方法需要抛出RemoteException异常:

1
2
3
public interface Hello extends Remote {
public String welcome(String name) throws RemoteException;
}

2.2 实现接口类

基于上面定义的接口实现一个类Helloimpl,该实现类需要继承UnicastRemoteObject类,同样重载的方法需要抛出RemoteException异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 远程接口实现类,必须继承UnicastRemoteObject
* (继承RemoteServer->继承RemoteObject->实现Remote,Serializable),
* 只有继承UnicastRemoteObject类,才表明其可以作为远程对象,被注册到注册表中供客户端远程调用
* (补充:客户端lookup找到的对象,只是该远程对象的Stub(存根对象),
* 而服务端的对象有一个对应的骨架Skeleton(用于接收客户端stub的请求,以及调用真实的对象)对应,
* Stub是远程对象的客户端代理,Skeleton是远程对象的服务端代理,
* 他们之间协作完成客户端与服务器之间的方法调用时的通信。)
*/
public class HelloImpl extends UnicastRemoteObject implements Hello {
//因为UnicastRemoteObject的构造方法抛出了RemoteException异常,
//因此这里默认的构造方法必须写,也必须声明抛出RemoteException异常
public HelloImpl() throws RemoteException {
}

@Override
public String welcome(String name) throws RemoteException {
return "Hello " + name;
}
}

如果一个远程类已经继承了其他类,无法再继承 UnicastRemoteObiect 类,那么可以在构造方法中调用 UnicastRemoteObject 类的静态 expotObject 方法,同样,远程类的构造方法也必须声明抛出 RemoteException

1
2
3
4
5
6
7
8
9
10
11
public class HelloImpl2 implements Hello {
@Override
public String welcome(String name) throws RemoteException {
return "Hello " + name;
}

public HelloImpl2() throws RemoteException{
//参数 port 指定监听的端口,如果取值为0,就表示监听任意一个匿名端口
UnicastRemoteObject.exportObject(this, 0);
}
}

2.3 创建服务端

服务端创建了一个注册表,并注册了客户端需要的对象

1
2
3
4
5
6
7
8
9
10
11
public class Server {
public static void main(String[] args) throws RemoteException {
//创建对象
Hello hello = new HelloImpl();
// 本地主机上的远程对象注册表Registry的实例
// 并指定端口,这一步必不可少(Java默认端口是1099)
Registry registry = LocateRegistry.createRegistry(1099);
//绑定对象到注册表,并给它取名为hello
registry.rebind("hello", hello);
}
}

向注册器注册远程对象有三种方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
//创建远程对象
HelloService service1 = new HelloServiceImpl("service1");

//方式1:调用 java.i.registry.Registy 接口的 bind 或 rebind 方法
Registry registry = LocateRegistry.createRegistry(1099);
registry.rebind("HelloService1", service1);

//方式2:调用命名服务类 java.rmi.Naming 的 bind 或 rebind 方法
Naming.rebind("HelloService1", service1);

//方式3:调用 JNDI API 的 javax.naming.Context 接口的 bind 或rebind 方法
Context namingContext = new InitialContext();
namingContext.rebind("rmi:HelloService1", service1);

2.4 客户端调用远程对象

1
2
3
4
5
6
7
8
9
10
public class Client {
public static void main(String[] args) throws RemoteException, NotBoundException {
//获取到注册表的代理
Registry registry = LocateRegistry.getRegistry("localhost", 1099);
//利用注册表的代理去查询远程注册表中名为hello的对象
Hello hello = (Hello) registry.lookup("hello");
//调用远程方法
System.out.println(hello.welcome("tom"));
}
}

2.5 运行结果

先运行服务端,再运行客户端

三、其他

3.1 远程方法中的参数与返回值传递

当客户端调用服务器端的远程对象的方法时,客户端会向服务器端传递参数,服务器端则会向客户端传递返回值。RMI 规范对参数以及返回值的传递的规定如下所述:

  • 只有基本类型的数据、远程对象以及可序列化的对象才可以被作为参数或者返回值进行传递

  • 如果参数或返回值是一个远程对象,那么把它的存根对象传递到接收方。也就是说接收方得到的是远程对象的存根对象

  • 如果参数或返回值是可序列化对象,那么直接传递该对象的序列化数据。也就是说接收方得到的是发送方的可序列化对象的复制品

  • 如果参数或返回值是基本类型的数据,那么直接传递该数据的序列化数据。也就是说,接收方得到的是发送方的基本类型的数据的复制品

3.2 分布式垃圾收集

在 Java 虚拟机中,对于一个本地对象,只要不被本地 Java 虚拟机内的任何变量引用,它就会结束生命周期,可以被垃圾回收器回收。而对于一个远程对象,不仅会被本地 Java 虚拟机内的变量引用,还会被远程引用

服务器端的一个远程对象受到三种引用:

  • 服务器端的一个本地对象持有它的本地引用
  • 这个远程对象已经被注册到 RMI 注册器,可以理解为,RMI 注册器持有它的引用
  • 客户端获得了这个远程对象的存根对象,可以理解为,客户端持有它的远程引用

RMI 框架采用分布式垃圾收集机制来管理远程对象的生命周期,当一个远程对象不受到任何本地引用和远程引用时,这个远程对象才会结束生命周期,并且可以被本地 Java 虚拟机的垃圾回收器回收。

服务器端如何知道客户端持有一个远程对象的远程引用呢?当客户端获得了一个服务器端的远程对象的存根后,就会向服务器发送一条租约通知,告诉服务器自己持有这个远程对象的引用了。客户端对这个远程对象有一个租约期限,默认值为 600000ms。当至达了租约期限的一半时间,客户如果还持有远程引用,就会再次向服务器发送租约通知。客户端不断在给定的时间间隔中向服务器发送租约通知,从而使肠务器知道客户端一直持有远程对象的引用。如果在租约到期后,服务器端没有继续收到客户端的新的租约通知,服务器端就会认为这个客户已经不再持有远程对象的引用了

3.3 动态加载

远程对象一般分布在服务器端,当客户端试图调用远程对象的方法时,如果在客户端还不存在远程对象所依赖的类文件,比如远程方法的参数和返回值对应的类文件,客户就会从 java.rmi.server.codebase 系统属性指定的位置动态加载该类文件

同样,当服务器端访问客户端的远程对象时,如果服务器端不存在相关的类文件,服务器就会从 java.rmi.server.codebase 属性指定的位置动态加载它们

此外,当服务器向 RMI 注册器注册远程对象时,注册器也会从 java.rmi.server.codebase 属性指定的位置动态加载相关的远程接口的类文件

前面的例子都是在同一个 classpath 下运行服务器程序以及客户程序的,这些程序都能从本地 classpath 中找到相应的类文件,因此无须从 java.rmi.server.codebase 属性指定的位置动态加载类。而在实际应用中,客户程序与服务器程序运行在不同的主机上,因此当客户端调用服务器端的远程对象的方法时,有可能需要从远程文件系统加载类文件。同样,当服务器端调用客户端的远程对象的方法时,也有可能从远程文件系统加载类文件

我们可以且把这些需要被加载的类的文件都集中放在网络上的同一地方,启动时将java.rmi.server.codebase 设置为指定位置,从而实现动态加载

1
start java -Djava.rmi.server.codebase=http://www.javathinker.net/download/

四、RMI攻击

参与一次 RMI 调用的有三个角色,分别是 Server 端,Registry 端和 Client 端。严格意义上来讲,只有 Registry 端和使用 Registry 的端,因为 Registry 端只负责查询和传递引用,真正的方法调用是不需要经过 Registry 端的,只不过注册服务的我们称之为 Server 端,使用服务的我们称之为 Client 端。有一种我只负责帮你找到人,至于你找这个人做什么非法勾当我不管的感觉,不过为了更清晰的划分不同角色,我们还是将其分为三个角色,而通常情况下,Server 端和 Registry 端是同一端。

在上面的 RMI 调用过程中我们可以发现,全部的通信流程均通过反序列化实现,而且在三个角色中均进行了反序列化的操作。那也就说明针对三端都有攻击的可能,我们依次来看一下。

4.1攻击方式

  • Java反序列化漏洞:
  • 序列化:将java对象转换为字节序列的过程
  • 反序列化:序列化的逆过程,从储存区读出字节序列还原成对象的过程

4.2 什么用法会导致RMI反序列化漏洞

RMI(远程方法调用)反序列化漏洞是一种安全漏洞,它允许攻击者在RMI通信中利用Java对象的反序列化过程进行攻击。导致RMI反序列化漏洞的常见原因之一是未正确验证和处理接收到的反序列化数据。

以下是导致RMI反序列化漏洞的一些常见用法:

  • 不安全的序列化/反序列化实现:使用不安全或过于宽松的序列化/反序列化实现可能会导致RMI反序列化漏洞。例如,使用不受信任的ObjectInputStream类进行反序列化时,攻击者可以通过精心构造的恶意数据来执行任意代码。
  • 未验证反序列化数据:在RMI通信中,接收到的反序列化数据应该经过验证,以确保其完整性和合法性。如果没有正确验证反序列化数据,攻击者可以篡改数据并执行未经授权的操作。
  • 序列化数据的来源不可信:反序列化的数据应该来自可信的来源。如果接收到的数据来自不受信任的来源,就有可能存在安全风险。攻击者可以发送恶意数据来触发反序列化漏洞。

4.3 防止RMI漏洞的一些手段

  • 使用安全的序列化/反序列化实现:使用经过安全审查和修复的序列化/反序列化库,如Jackson、Gson等,可以减少RMI反序列化漏洞的风险。
  • 对反序列化数据进行验证:在接收到的反序列化数据之前,应该对其进行验证,包括检查数据的完整性、合法性和有效性。可以使用数字签名、消息认证码(MAC)等技术来确保数据的完整性和来源可信。
  • 限制反序列化操作的权限:通过配置安全策略文件,限制RMI服务器执行反序列化操作时的权限,并避免执行不受信任的代码。

4.4 RMI反序列化漏洞利用

  • 攻击RMI Client
    • RMI Registry在RMI客户端使用lookup方法的时候,可以实现被动攻击RMI客户端
    • 服务端替换其返回的序列化数据为恶意序列化数据攻击客户端。
  • 攻击RMI Server
    • 客户端把参数的序列化数据替换成恶意序列化数据攻击服务端
  • 攻击RMI Registry
    • RMI客户端使用lookup方法理论上可以主动攻击RMI Registry
    • RMI服务端使用bind方法可以实现主动攻击RMI Registry
  • 可利用函数
    • Bind
    • Rebind
    • Lookup
    • unbind

4.5 防御

开发者对于RMI漏洞的修复方法

  • 更新到最新的Java版本:及时更新使用的Java版本,因为Java通常会发布安全补丁来修复已知的漏洞。确保应用程序和服务器上使用的Java版本是最新的,并实施自动更新机制,以便及时获得安全修复。

  • 配置安全策略文件:通过配置安全策略文件,可以限制RMI服务器执行反序列化操作时的权限。在策略文件中,指定允许访问和执行的代码路径,并限制RMI服务器对不受信任的代码的访问。这样可以减少潜在的攻击面。

  • 实现自定义的ObjectInputFilter:自Java 9起,引入了ObjectInputFilter接口,可以用于过滤不受信任的类和限制反序列化操作。通过实现自定义的ObjectInputFilter,可以定义特定的反序列化策略,对输入流中的类进行验证和过滤。
  • 使用安全的序列化/反序列化库:选择经过安全审查和修复的序列化/反序列化库,如Jackson、Gson等,来代替Java默认的序列化机制。这些库通常实现了额外的安全措施,可以降低RMI漏洞的风险。
  • 审查和修复现有代码:审查应用程序中的代码,特别是涉及到RMI通信和反序列化的部分。查找潜在的漏洞,并修复存在的安全问题。确保对接收到的反序列化数据进行正确的验证和处理。
  • 安全测试和漏洞扫描:进行安全测试和漏洞扫描以发现潜在的RMI漏洞。使用专业的安全工具和服务,对应用程序进行渗透测试和代码审计,以识别和修复可能存在的漏洞。

现有的防御方式

  • 黑/白名单
  • JEP290
    • JDK6 6u141
    • JDK7 7u131
    • JDK8 8u121

欢迎关注我的其它发布渠道