概述
在学习Java反序列化漏洞的时候,经常会遇到RMI、JNDI、JRMP这些概念,其中RMI是一个基于序列化的Java远程方法调用机制。作为一个常见的反序列化入口,它和反序列化漏洞有着千丝万缕的联系。除了直接攻击RMI服务接口外(比如CVE-2017-3241),我们在构造反序列化漏洞利用时也可以结合RMI方便的实现远程代码执行。
关于RMI
RMI(Remote Method Invocation)的全称为远程方法调用。远程方法调用是分布式编程中的一个基本思想。实现远程方法调用的技术有很多,比如:CORBA、WebService,这两种都是独立于编程语言的。而Java RMI(Java Remote Method Invocation)是专为Java环境设计的远程方法调用机制,能够让一台Java虚拟机上的对象调用运行在另一台Java虚拟机上的对象的方法。它使客户机上运行的程序可以调用远程服务器上的对象。远程方法调用特性使Java编程人员能够在网络环境中分布操作。RMI全部的宗旨就是尽可能简化远程接口对象的使用。
RMI依赖的通信协议为JRMP(Java Remote Message Protocol ,Java 远程消息交换协议),该协议为Java定制,要求服务端与客户端都为Java编写。这个协议就像HTTP协议一样,规定了客户端和服务端通信要满足的规范。在RMI中对象是通过序列化方式进行编码传输的。
从RMI设计角度来讲,基本分为三层架构模式来实现RMI,分别为RMI服务端,RMI客户端和RMI注册中心
- Client-客户端:客户端调用服务端的方法
- Server-服务端:远程调用方法对象的提供者,也是代码真正执行的地方,执行结束会返回给客户端一个方法执行的结果
- Registry-注册中心:其实本质就是一个map,相当于是字典一样,用于客户端查询要调用的方法的引用(在低版本的JDK中,Server与Registry是可以不在一台服务器上的,而在高版本的JDK中,Server与Registry只能在一台服务器上,否则无法注册成功)
RMI
远程对象
使用远程方法调用,必然会涉及参数的传递和执行结果的返回。参数或者返回值可以是基本数据类型,当然也有可能是对象的引用。所以这些需要被传输的对象必须可以被序列化,这要求相应的类必须实现java.io.Serializable
接口,并且客户端的serialVersionUID
字段要与服务器端保持一致。
任何可以被远程调用方法的对象必须继承java.rmi.Remote
接口,远程对象的实现类必须继承UnicastRemoteObject
类。如果不继承UnicastRemoteObject
类,则需要手工初始化远程对象,在远程对象的构造方法的调用UnicastRemoteObject.exportObject()
静态方法,如下
package learn.rmi;
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
public class RMIServer {
public class RMIHello extends UnicastRemoteObject implements IHello{
protected RMIHello() throws RemoteException{
super();
}
// 在没有继承UnicastRemoteObject的时候构造函数也可以写成如下形式
// protected RMIHello() throws RemoteException{
// UnicastRemoteObject.exportObject(this,0);
// }
@Override
public String sayHello(String name) throws RemoteException {
System.out.println("Hello World!");
return "Feng";
}
}
}
IHello.java
package learn.rmi;
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface IHello extends Remote {
public String sayHello(String name) throws RemoteException;
}
IHello
是客户端和服务端共用的接口(客户端本地必须有远程对象的接口,不然无法指定要调用的方法,而且其全限定名必须与服务器上的对象完全相同),RMIHello
是一个服务端远程对象,提供了一个sayHello
方法供远程调用。
以上就构成了RMI Server
- 一个继承了
java.rmi.Remote
的接口IHello
,内部定义了我们将要远程调用的对象方法sayHello()
- 一个实现了此接口的类
RMIHello
- 一个主类
RMIServer
,用来创建Registry
,并将类RMIHello
实例化后绑定到一个地址
对象调用过程
本地对象调用
我们先看看本地对象方法的调用
ObjectClass objectA = new ObjectClass();
String retn = objectA.Method();
但如果对象在JVM A上,而客户端运行在JVM B上,那么B怎么访问A上的对象呢?这就需要RMI机制了。
远程对象调用
在JVM之间通信时,RMI对远程对象和非远程对象的处理方式是不一样的,它并没有直接把远程对象复制一份传递给客户端,而是传递了一个远程对象的Stub(存根),Stub相当于远程对象的引用或者代理。Stub对开发者是透明的,客户端可以像调用本地方法一样直接通过它来调用远程方法。Stub中包含了远程对象的定位信息,如Socket端口、服务端主机地址等等,并实现了远程调用过程中具体的底层网络通信细节。而位于服务器端的Skeleton(骨架),能够读取客户端传递的方法参数,调用服务器方的实际对象方法, 并接收方法执行后的返回值。所以RMI远程调用逻辑大致是这样的
从逻辑上来看,数据是在Client和Server之间横向流动的,但是实际上是从Client到Stub,然后从Skeleton到Server这样纵向流动的。
具体的通信流程如下
- Server监听一个端口,这个端口是JVM随机选择的
- Client并不知道Server远程对象的通信地址和端口,但是位于Client的Stub中包含了这些信息,并封装了底层网络操作。Client可以调用Stub上的方法,并且也可以向Stub发送方法参数。
- Stub连接到Server监听的通信端口并提交参数
- Server执行具体的方法,并将结果返回给Stub
- Stub返回执行结果给Client。因此在Clinet看来,就好像是Stub在本地执行了这个方法。
那么问题来了,位于Client上的Stub是怎么获取到远程Server的通信信息的呢?这就需要使用RMI Registry了。
RMI Registry
RMI Registry的注册
JDK提供了一个RMI注册表(RMI Registry)来解决这个问题。RMI Registry也是一个远程对象,默认监听在1099端口上,可以使用代码启动RMI Registry,也可以使用rmiregistry命令。
要注册远程对象,需要RMI URL和一个远程对象的引用
private void register() throws Exception{
RMIHello rmiHello=new RMIHello();
LocateRegistry.createRegistry(1099);
Naming.bind("rmi://127.0.0.1:1099/hello",rmiHello);
}
在主类的register()
方法中,我们首先实例化了一个将被远程调用的类RMIHello
,然后使用 LocateRegistry.createRegistry(port)
在本地的某个端口上创建了一个Registry
。最后使用Naming.bind()
将实例化对象和地址上的hello
绑定在一起,作为远程对象的名字。注意这里使用的是rmi://
协议。
这样,我们就完成了对RMI Registry的注册。
RMI Registry的使用
注册完RMI Registry以后,我们将要调用的远程对象已经和服务器端的某个地址绑定在了一起。那么Clinet又是怎么从Registry获取服务器远程对象信息的呢?我们创建一个简单的RMI Client,代码如下
package learn.rmi;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class RMIClient {
public static void main(String[] args) throws Exception{
Registry registry= LocateRegistry.getRegistry("127.0.0.1",1099);
IHello iHello=(IHello) registry.lookup("hello");
System.out.println(iHello.sayHello("Feng"));
}
}
LocateRegistry.getRegistry()
会使用给定的主机和端口等信息在本地创建一个Stub
对象作为Registry远程对象的代理,从而启动整个远程调用逻辑。服务端应用程序可以向RMI注册表中注册远程对象,然后客户端向RMI注册表查询某个远程对象名称,来获取该远程对象的Stub。这里我们使用了registry.lookup()
来查询获取注册表中的远程对象。还有另一种写法
public static void main(String[] args) throws Exception{
System.out.println(iHello.sayHello("Feng"));
}
}
使用了RMI Registry后,RMI的调用关系如下
从客户端角度看,服务端应用是有两个端口的,一个是RMI Registry端口(默认为1099),另一个是远程对象的通信端口(随机分配的)。更详细的通信过程如下
完整测试代码
服务器端
IHello.java
package learn.rmi;
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface IHello extends Remote {
public String sayHello(String name) throws RemoteException;
}
RMIServer.java
package learn.rmi;
import java.rmi.Naming;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.server.UnicastRemoteObject;
public class RMIServer {
public class RMIHello extends UnicastRemoteObject implements IHello {
protected RMIHello() throws RemoteException{
super();
}
@Override
public String sayHello(String name) throws RemoteException {
System.out.println("Hello World!");
return name;
}
}
private void register() throws Exception{
RMIHello rmiHello=new RMIHello();
LocateRegistry.createRegistry(1099);
Naming.bind("rmi://0.0.0.0:1099/hello",rmiHello);
System.out.println("Registry运行中......");
}
public static void main(String[] args) throws Exception {
new RMIServer().register();
}
}
客户端
IHello.java
package learn.rmi;
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface IHello extends Remote {
public String sayHello(String name) throws RemoteException;
}
RMIClient.java
package learn.rmi;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class RMIClient {
public static void main(String[] args) throws Exception{
Registry registry= LocateRegistry.getRegistry("127.0.0.1",1099);
IHello iHello=(IHello) registry.lookup("hello");
System.out.println(iHello.sayHello("Feng"));
}
}
运行结果
Server端
Client端
Clien成功调用位于Server端的远程对象方法。
JRMP协议分析
Java远程方法协议(Java Remote Method Protocol,JRMP)是特定于Java技术的、用于查找和引用远程对象的协议。这是运行在Java远程方法调用(RMI)之下、TCP/IP之上的线路层协议。
为了便于分析,我们将Cilent打包复制到另一台虚拟机中。启动RMI Server,然后在虚拟机中向RMI Server发出请求,截获的数据包如下
首先是TCP三次握手来建立第一条TCP链接,客户端连接服务器的1099端口,这里真正连接到的其实是RMI Registry,然后二者建立JRMP链接。
随后Clinet向Registry发送”Call”信息,Registry回复”ReturnData”。我们看一下Registry的回复内容。
0000 00 0c 29 b3 84 37 14 18 c3 e1 a9 29 08 00 45 00 ..)..7.....)..E.
0010 01 62 3d 67 40 00 80 06 00 00 c0 a8 2b a2 c0 a8 .b=g@.......+...
0020 2b 0a 04 4b b5 d0 a1 2c d2 91 8b 75 e2 86 50 18 +..K...,...u..P.
0030 08 04 d9 51 00 00 51 ac ed 00 05 77 0f 01 82 3c ...Q..Q....w...<
0040 5d f8 00 00 01 7e 4c 4d 6c e9 80 05 73 7d 00 00 ]....~LMl...s}..
0050 00 02 00 0f 6a 61 76 61 2e 72 6d 69 2e 52 65 6d ....java.rmi.Rem
0060 6f 74 65 00 10 6c 65 61 72 6e 2e 72 6d 69 2e 49 ote..learn.rmi.I
0070 48 65 6c 6c 6f 70 78 72 00 17 6a 61 76 61 2e 6c Hellopxr..java.l
0080 61 6e 67 2e 72 65 66 6c 65 63 74 2e 50 72 6f 78 ang.reflect.Prox
0090 79 e1 27 da 20 cc 10 43 cb 02 00 01 4c 00 01 68 y.'. ..C....L..h
00a0 74 00 25 4c 6a 61 76 61 2f 6c 61 6e 67 2f 72 65 t.%Ljava/lang/re
00b0 66 6c 65 63 74 2f 49 6e 76 6f 63 61 74 69 6f 6e flect/Invocation
00c0 48 61 6e 64 6c 65 72 3b 70 78 70 73 72 00 2d 6a Handler;pxpsr.-j
00d0 61 76 61 2e 72 6d 69 2e 73 65 72 76 65 72 2e 52 ava.rmi.server.R
00e0 65 6d 6f 74 65 4f 62 6a 65 63 74 49 6e 76 6f 63 emoteObjectInvoc
00f0 61 74 69 6f 6e 48 61 6e 64 6c 65 72 00 00 00 00 ationHandler....
0100 00 00 00 02 02 00 00 70 78 72 00 1c 6a 61 76 61 .......pxr..java
0110 2e 72 6d 69 2e 73 65 72 76 65 72 2e 52 65 6d 6f .rmi.server.Remo
0120 74 65 4f 62 6a 65 63 74 d3 61 b4 91 0c 61 33 1e teObject.a...a3.
0130 03 00 00 70 78 70 77 37 00 0a 55 6e 69 63 61 73 ...pxpw7..Unicas
0140 74 52 65 66 00 0e 31 39 32 2e 31 36 38 2e 34 33 tRef..192.168.43
0150 2e 31 36 32 00 00 ec 3c ba 3f a1 47 ea 85 db bb .162...<.?.G....
0160 82 3c 5d f8 00 00 01 7e 4c 4d 6c e9 80 01 01 78 .<]....~LMl....x
这里传输的是服务器的序列化数据。注意以上加粗倾斜的部分。\xAC\xED
是Java序列化的魔术头,该数据流往后的部分就是序列化的内容了。\xEC\x3C
转换成十进制为60476
,这便是Server在本地开放的随机端口。
我们分析一下第一条TCP链接干了什么。首先Client根据传入的rmi地址链接远端服务器1099端口上的RMI Registry,然后Registry向Client发送Server上的序列化数据,包括IP和开放的随机端口等。
再往下是第二个TCP链接,Client连接ReturnData中返回的端口,这条TCP链接用于Client与Server之间的传输数据。实际上是Client的Stub和Server上的Skeleton之间进行数据传输的。
再往后就是四次挥手,两条TCP链接分别断开
RMI Registry就像一个网关,Server在Registry中注册绑定在name上的远程对象,Client在Registry中根据name查询远程对象绑定信息。然后Client的Stub连接位于Server上的Skeleton,最终远程方法还是在服务器上执行。
RMI流程源码分析
有了上面对于RMI流程的分析,下面我们根据源码来捋一捋信息是怎么在Server、Client与Registry中流动的。
Registry端
创建Registry
我们可以通过createRegistry()
方法来创建一个Registry
Registry registry = LocateRegistry.createRegistry(1099);
可以看到,在创建Registry是返回的是RegistryImpl
对象
继续跟进UnicastServerRef.exportObject()
在其中创建了一个Skeleton(骨架),跟进
最终在Util.createSkeleton()
中返回了RegistryImpl_Skel
对象,这就是Server端处理 RMI Client 通信请求的具体操作类(Skeleton)。
因此最终createRegistry()
的结果就是返回了一个RegistryImpl
对象,并且赋值this.skel=RegistryImpl_Skel
。
完整过程如下
操作远程对象
对远程对象的操作有以下5种
- bind
- list
- lookup
- rebind
- unbind
对于Registry端,操作远程对象其实就是操作HashTable
,我们来看RegistryImpl
中的bind
操作
这里的this.bindings
其实就是一个Hash表,Registry使用的这张Hash表就类似于一张”路由表”,将name
和绑定其上的远程对象联系了起来。
private Hashtable<String, Remote> bindings = new Hashtable(101);
Client端
在Registry上绑定了远程对象后,Client也可以使用以下对远程对象进行操作
Registry registry = LocateRegistry.getRegistry(rmi,port);
registry.lookup(name);
获取Registry
这里获取指的是Client端获取远程Registry,可以通过getRegistry()
方法来获取远程Registry
Registry registry = LocateRegistry.getRegistry(ip,port);
可以看到返回是一个RegistryImpl_Stub
对象
操作远程对象
在Client端,对远程对象的操作同样有以下5种
这里我们以RegistryImpl_Stub.bind
操作为例进行分析
首先生成了一个RemoteCall
通信对象来建立连接,注意参数里的opnum
(操作数),上述5个操作中每一个操作都有一个opnum
,这里bind
操作的opnum
为0
。参数中的hash其实就是serializeID
。
跟进newCall
,在此时Client已经通过newConnection()
和Server端建立了连接
然后Client通过StreamRemoteCall()
提前将ObjID、opnum和serializeID发送给Server端
回到bind中,可以看到使用了writeObject()
将我们要发送的name以及Remote远程对象序列化发送了过去
再往下,bind中的invoke和done便是接收处理服务端返回的信息
完整流程
Server端
开启通信端口
我们知道,在RMI过程中,Server端往往是开启两个端口的,一个1099端口用于Registry,另一个是随机端口用于与Client通信。而远程方法最终是在Server端执行的,Server会把执行的结果返回给Client端。我们上面在创建Registry的时候已经生成了一个RegistryImpl_Skel
对象,正是这个对象与Client端的RegistryImpl_Stub
通信,那么他们之间是怎么通信的呢?
我们接着Registry部分,从UnicastServerRef.exportObject()
开始跟
最终跟到TCPTransport.exportObject()
,开始监听端口,跟进listen()
这里listen()启动了一个新的线程,跟进看看这个线程做了什么
此时Server开启监听本地的60956端口用作与Client的TCP连接。
我们在TCPTransport.run()
处下个断点
调用了executeAcceptLoop()
,跟进
在执行完this.serverSocket.accept()
之后server才开始真正等待Client的连接
与Client通信
紧接上文,我们在executeAcceptLoop()
方法中的TCPTransport.connectionThreadPool.execute()
方法处下个断点,然后Client向Server发送连接请求,如下
这里创建了一个ConnectionHandler
句柄,我们跟进ConnectionHandler句柄的run()方法,在TCPTransport.run()中
跟进run0()
,最终到了该方法中的handleMessages()
,这是用来传递消息的句柄
跟进,创建了StreamRemoteCall
对象,跟进下面的serviceCall()
方法
可以看到serviceCall()
方法的参数是一个RemoteCall对象。这里就是Client传来的RemoteCall对象,然后对该对象进行各种操作,读取其中的信息。
最终会调用dispatch()
方法
然后调用oldDispatch(),最后跟到this.skel.dispatch()方法中。最终在RegistryImpl_Skel.dispatch()
中根据Client发来的信息进行各种操作。
完整流程
进行源码分析的目的是更深入地理解RMI通信原理,以便理解后续针对RMI的各种攻击方式。
大佬,有些图片加载不出来
感谢提醒,已换源