Java的RMI

继续学习p神星球上的文章

GitHub - phith0n/JavaThings: Share Things Related to Java - Java安全漫谈笔记相关内容

深入理解 RMI 之运行逻辑与漏洞原理 - FreeBuf网络安全行业门户

RMI简介

Java SE Remote Method Invocation APIs and Developer Guides (oracle.com)

官网解释

Java Remote Method Invocation (Java RMI) enables the programmer to create distributed Java technology-based to Java technology-based applications, in which the methods of remote Java objects can be invoked from other Java virtual machines, possibly on different hosts. RMI uses object serialization to marshal and unmarshal parameters and does not truncate types, supporting true object-oriented polymorphism.

翻译过来

Java远程方法调用(Java RMI)使程序员能够创建基于分布式Java技术到基于Java技术的应用程序,其中远程Java对象的方法可以从其他Java虚拟机(可能在不同的主机上)调用。RMI使用对象序列化来封送和解封送参数,并且不截断类型,支持真正的面向对象多态性。

RMI基本原理

RMI的目的是使运行在不同计算机上的对象之间的调用表现得像本地方法调用一样

RMI由3个部分构成,第一个是RMIService即JDK提供的一个可以独立运行的程序(bin目录下的rmiregistry),第二个是RMIServer即我们自己编写的一个java项目,这个项目对外提供服务。第三个是RMIClient即我们自己编写的另外一个java项目,这个项目远程使用RMIServer提供的服务

RMI应用程序通常包括两个独立的程序:服务端程序和客户端程序

RMI需要将行为的定义和行为的实现分别定义,并允许将行为定义与行为实现存放并运行在不同的jvm上

在RMI中,远程服务的定义是存放在继承了Remote的接口中,远程服务的实现是存放在实现该定义接口的类中

RMI支持两个类实现一个相同的远程服务接口:一个类实现行为并运行在服务器端,另一个类作为一个远程服务代理运行在客户端

客户程序发出关于代理对象的调用方法,RMI将该调用请求发送到远程的JVM上,并且进一步发送到实现的方法中,实现方法将结果返回给代理,在通过代理返回给调用者

RMI构建三个抽象层,高层覆盖底层,分别负责socket通信,参数和结果的序列化和反序列化等工作

存根(stub)和骨架(skeleton)合在一起形成了RMI框架协议

RMI的使用

直接从⼀个例⼦开始演示RMI的流程吧。 ⾸先编写⼀个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
30
31
32
package RMI;

import java.rmi.Naming;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.UnicastRemoteObject;
public class RMIServer {
public interface IRemoteHelloWorld extends Remote {
public String hello() throws RemoteException;
}
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";
}
}
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();
}
}

⼀个RMI Server分为三部分:

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

RMI Client:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package RMI;

import RMI.RMIServer;
import java.rmi.Naming;
import java.rmi.NotBoundException;
import java.rmi.RemoteException;
public class RMIClient {
public static void main(String[] args) throws Exception {
RMIServer.IRemoteHelloWorld hello = (RMIServer.IRemoteHelloWorld)
Naming.lookup("rmi://127.0.0.1:1099/Hello");
String ret = hello.hello();
System.out.println( ret);
}
}

使⽤ Naming.lookup 在Registry中寻找到名字是Hello的对象,后⾯的使⽤就和在 本地使⽤⼀样了。

为了理解RMI的通信过程,wireshark抓包得到:

image-20240215214442271

这就是完整的通信过程,我们可以发现,整个过程进⾏了两次TCP握⼿,也就是我们实际建⽴了两次 TCP连接。 第⼀次建⽴TCP连接是连接远端 127.0.0.1 的1099端⼝,这也是我们在代码⾥看到的端⼝,⼆ 者进⾏沟通后,我向远端发送了⼀个“Call”消息,远端回复了⼀个“ReturnData”消息,然后我新建了⼀ 个TCP连接,连到远端的55407端⼝。

那么为什么我会连接55407端⼝呢?

在“ReturnData”这个包中,返回了⽬标的IP地址 192.168.1.3,其 后跟的⼀个字节 \x00\x00\xD8\x6F ,刚好就是整数 55407 的⽹络序列:

1
2
3
4
from struct import pack

a=pack('>I',55407)
print(a)

image-20240215221413227

QQ图片20240215221546

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

image-20240215221613622

所以捋⼀捋这整个过程,⾸先客户端连接Registry,并在其中寻找Name是Hello的对象,这个对应数据 流中的Call消息;然后Registry返回⼀个序列化的数据,这个就是找到的Name=Hello的对象,这个对应 数据流中的ReturnData消息;客户端反序列化该对象,发现该对象是⼀个远程对象,地址 在 192.168.1.3:55507 ,于是再与这个地址建⽴TCP连接;在这个新的连接中,才执⾏真正远程⽅法调⽤,也就是 hello() 。

image-20240215224607680

RMI Registry就像⼀个⽹关,他⾃⼰是不会执⾏远程⽅法的,但RMI Server可以在上⾯注册⼀个Name 到对象的绑定关系;RMI Client通过Name向RMI Registry查询,得到这个绑定关系,然后再连接RMI Server;最后,远程⽅法实际上在RMI Server上调⽤。

一个RMI过程有以下三个参与者:

RMI Registry

RMI Server

RMI Client

但是为什么我给的示例代码只有两个部分呢?原因是,通常我们在新建一个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整个的原理与流程

1
Naming.bind("Hello", new RemoteHelloWorld());

RMI 是一个基于序列化的 Java 远程方法调用机制。

RMI带来的安全问题

两个问题:

  1. 如果我们能访问RMI Registry服务,如何对其攻击?
  2. 如果我们控制了目标RMI客户端中 Naming.lookup 的第一个参数(也就是RMI Registry的地 址),能不能进行攻击?

在Server中注释掉

1
LocateRegistry.createRegistry(1099);

会看到这样的报错

image-20240216100146211

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

1
String[] s = Naming.list("rmi://192.168.135.142:1099");

lookup作用就是获得某个远程对象。 那么,只要目标服务器上存在一些危险方法,我们通过RMI就可以对其进行调用,之前曾经有一个工具 https://github.com/NickstaDB/BaRMIe,其中一个功能就是进行危险方法的探测。

RMI利用codebase执行任意代码

Java Applet 基础 | 菜鸟教程 (runoob.com)

Java是可以运行在浏览器中的,对,就是Applet这个奇葩。在使用Applet的时候通常需 要指定一个codebase属性,比如: 除了Applet,RMI中也存在远程加载的场景,也会涉及到codebase。 codebase是一个地址,告诉Java虚拟机我们应该从哪个地方去搜索类,有点像我们日常用的 CLASSPATH,但CLASSPATH是本地路径,而codebase通常是远程URL,比如http、ftp等。 如果我们指定 codebase=http://example.com/ ,然后加载 org.vulhub.example.Example 类,则 Java虚拟机会下载这个文件 http://example.com/org/vulhub/example/Example.class ,并作为 Example类的字节码。 RMI的流程中,客户端和服务端之间传递的是一些序列化后的对象,这些对象在反序列化时,就会去寻 找类。如果某一端反序列化时发现一个对象,那么就会去自己的CLASSPATH下寻找想对应的类;如果在 本地没有找到这个类,就会去远程加载codebase中的类。

在RMI中,我们是可以将codebase随着序列化数据一起传输的,服务器在接收到这个数据后就会去 CLASSPATH和指定的codebase寻找类,由于codebase被控制导致任意命令执行漏洞。

只有满足如下条件的RMI服务器才能被攻击:

  • 安装并配置了SecurityManager
  • Java版本低于7u21、6u45,或者设置了 java.rmi.server.useCodebaseOnly=false

其中 java.rmi.server.useCodebaseOnly 是在Java 7u21、6u45的时候修改的一个默认设置。官方将 java.rmi.server.useCodebaseOnly 的默认值由 false 改为了 true 。在 java.rmi.server.useCodebaseOnly 配置为 true 的情况下,Java虚拟机将只信任预先配置好的 codebase ,不再支持从RMI请求中获取。

版本问题复现失败了,考虑到使用条件比较苛刻,就先放一放吧。