前言
RMI攻击手法只对jdk8u121之前有效,在8u121之后,bind,rebind,unbind这三个方法只能对本地进行攻击。
RMI
RMI是什么
RMI全称 Remote Method Invokcation,它允许一个Java程序中的对象调用运行在另一个JVM中的正在运行的对象方法,就像在本地调用一样。两者之间通过底层Socket进行通信。
RMI使用的通信协议是JRMP(Java Remote Message Protocol),这协议是为Java定制的协议,只能用于两个Java程序之间进行通信。
RMI主要组成
RMI主要由三个组件组成Server,Client,Registry.
Server将想要被调用的对象放置到通过包装放到某个端口之中
Client通过lookup进行查找
因为一个远程对象对应了一个Socket,一个Socket对应了一个端口,所以Client想要调用远程对象首先得知道那个对象对应的端口在哪。
但是Server放置对象是随机放置的,具体端口不容易知道。所以Registry就起到了一个中转作用,Registry一般运行在Server的1099端口,Registry起到了一个类似于Map的作用,将String作为Key,远程对象作为value,Client只需要知道key就能通过Registry获取到一个value的stub。(后面再解释Stub是什么)
简单实现
- 先编写一个远程接口
1 | public interface RemoteObj extends Remote{ |
这接口有三个要求
作用域为public
继承Remote
让其接口中的方法抛出RemoteException
接下来定义该接口的实现类
1 | public class RemoteImpl extends UnicastRemoteObject implements RemoteObj{ |
实现类的要求
实现远程接口
继承UnicastObject
构造函数抛出RemoteException
实现类中所有使用的对象都得实现Serialize接口,因为Server和Client是通过序列化进行数据传输
接下来注册远程对象
1 | public class Server { |
至此服务端就写好了
客户端只需去Registry那获取对象即可,但是因为要给获取到的对象一个编译类型,所以客户端也得有一个RemoteObj接口
1 | public interface RemoteObj extends Remote{ |
从Wireshark抓包分析RMI通信流程
这部分是复制的其他师傅的文章,讲的非常好
数据端与注册中心(1099 端口)建立通讯
- 客户端查询需要调用的函数的远程引用,注册中心返回远程引用和提供该服务的服务端 IP 与端口。

数据端与注册中心(1099 端口)建立通讯完成后,RMI Server 向远端发送了⼀个 “Call” 消息,远端回复了⼀个 “ReturnData” 消息,然后 RMI Server 端新建了⼀个 TCP 连接,连到远端的 33769 端⼝

AC ED 00 05是常见的 Java 反序列化 16 进制特征
注意以上两个关键步骤都是使用序列化语句
客户端新起一个端口与服务端建立 TCP 通讯
客户端发送远程引用给服务端,服务端返回函数唯一标识符,来确认可以被调用

同样使用序列化的传输形式
以上两个过程对应的代码是这两句
JAVA
1 | Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099); |
这里会返回一个 Proxy 类型函数,这个 Proxy 类型函数会在我们后续的攻击中用到。
客户端序列化传输调用函数的输入参数至服务端
- 这一步的同时:服务端返回序列化的执行结果至客户端

以上调用通讯过程对应的代码是这一句
JAVA
1 | remoteObj.sayHello("hello"); |
可以看出所有的数据流都是使用序列化传输的,那必然在客户端和服务带都存在反序列化的语句。
总结一下 RMI 的通信原理
实际建⽴了两次 TCP 连接,第一次是去连 1099 端口的;第二次是由服务端发送给客户端的。
在第一次连接当中,是客户端连 Registry 的,在其中寻找 Name 为 hello 的对象,这个对应数据流中的 Call 消息;然后 Registry 返回⼀个序列化的数据,这个就是找到的 Name=Hello 的对象,这个对应数据流中的ReturnData消息。
到了第二次连接,服务端发送给客户端 Call 的消息。客户端反序列化该对象,发现该对象是⼀个远程对象,地址在 172.17.88.209:24429,于是再与这个地址建⽴ TCP 连接;在这个新的连接中,才执⾏真正远程⽅法调⽤,也就是 sayHello()
RMI Registry 就像⼀个⽹关,他⾃⼰是不会执⾏远程⽅法的,但 RMI Server 可以在上⾯注册⼀个 Name 到对象的绑定关系;RMI Client 通过 Name 向 RMI Registry 查询,得到这个绑定关系,然后再连接 RMI Server;最后,远程⽅法实际上在 RMI Server 上调⽤。
原理图如图

那么我们可以确定 RMI 是一个基于序列化的 Java 远程方法调用机制。
通过这一部分我们就可以知道
1 | RemoteObj remoteobj = (RemoteObj) registry.lookup("Impl"); |
这一条语句Client向Registry发送数据包,Registry返回序列化的对象(这个对象就是前文提到的stub),对象中包含了远程对象的地址,Client接收到了之后对其进行了反序列化,并赋值给了remoteobj
1 | remoteobj.hello(); |
这条语句就是Client发送序列化后的信息,Server反序列化并调用hello(),再将输出的结果序列化后返回给Client.
Registry就是一个地址簿,Server告诉他远程对象的名字是什么,地址在哪。Client问Registry它想找的远程对象的地址在哪,Registry告诉Client它地址,最后Client与Server进行直接通信,至于为什么最开始Client不与Server直接通信,前文已经说过,因为Server分配远程对象端口时是随机分配的。
RMI通讯原理源码分析
Server端如何发布远程对象
这部分对应的是蓝色行的语句
步入,因为RemoteImpl构造函数什么都没写所以直接进入其父类的构造函数,前文我们构造这个Impl时让其继承了UnicastRemoteObject

这里传入的port为0代表了随机端口

exportObject函数很关键,就是这个函数将对象绑定到随机端口上。
继续调试
步入UnicastRemoteObject.exportObject(Remote,port)

可以看到这里创建了一个Ref,步入看看

先看看super干了什么

将这个新建的LiveRef赋值给UnicastServerRef的父类UnicastRef.ref,要注意的是LiveRef至始至终只会存在一个
LiveRef的创建过程就不细看了,看一下创建好了的LiveRef的属性

可以看到liveRef装着一个对应着Server端某个随机端口的TCPEndpoint
TCPEndpoint类封装了一些TCP的东西,只需要调用它的方法就可以使Client和Server通信。现在有了这个远程对象对应的TCPEndpoint.
创建好UnicastServerRef后,回到

步入

将UnicastServerRef赋值给UnicastRemoteObject.ref(其实UnicastRemoteObject没有ref,这是赋值给它的父类RemoteObject.ref),调用UnicastServerRef.exportObject()

这里出现了Stub,可以看到stub其实就是一个动态代理对象Proxy,下面还有个将各种对象封装起来的Target,步入209行ref.exportObject(target)此处的Ref就是之前创建的LiveRef

调用LiveRef.ep.exportObject()即TCPEndpoint.exportObject(),TCPEndpoint这个类前文已经提过

TCPEndpoint.transport是一个TCPTransport

注意看上图的绿字,翻译过来就是将对象暴露出去从而能接收传入的调用,进入listen函数

335行步入newServerSocket()

光标所指的函数就是随机分配一个port给这个ServerSocket,与我们前文提到的Server端会随机为对象分配一个端口对应了
回到listen函数,注意341-344行,进入AcceptLoop构造函数。因为AcceptLoop实现了Runnable接口,所以new NewThreadAction()在new AcceptLoop后会调用acceptLoop.run(),进入executeAcceptLoop()


还是注意上面的绿字。接收服务端的连接,并在线程池中为其执行处理程序,因为在listen函数中是为AcceptLoop.run()单开了一个线程的。
所以listen()的作用其实就是监听随机分配的端口

可以看到现在ref中port有值了,不再是之前的0了,进入super.exportObject

这两条语句就是把target的信息放进一些map里,与日志相当。
以上就是Server发布远程服务的大致过程。
小结一下,我对这个发布远程服务的大致印象是要发布的类继承了UnicastRemoteObject,在实例化的过程中调用了其父类的构造函数,之后的流程就是不断地调用不同类的exportObject方法,最终创建了一个ServerSocket并且将要封装了各种各样的类的target对象给绑定在了一起,在RMI流程中很重要的stub在这一环节被创建了,是一个动态代理类。
注册中心如何被发布
其实注册中心Registry与远程对象发布流程几乎一模一样


因为没有安全管理器所以直接进入else,可以看到这里也创建了一个LiveRef
进入setup

后面跟远程对象的流程几乎一模一样
不停调用别的类的exportObject

但是这里因为RegistryImpl的特殊性,创建stub的步骤有点不一样,进入createProxy

发布自定义远程对象时这个判断是false直接跳过,但是进入这个判断看一下


返回true


返回了RegistryImpl_Stub实例

这一步是发布自定义类没有的,因为Proxy没有实现RemoteStub,但是RegistryImpl_Stub实现了

Skeleton是干嘛的暂时别管,后面会解释。withoutSkeleton现在也是空的,进入createSkeleton
很简单,就是加载RegistryImpl_Skel并实例化
回到exportObject可以看到后面的Target和LiveRef.exportObject和发布远程自定义类一模一样。后面的环节就不赘述了

再看下这句

bindings就是一个HashTable,将名字和对象放进去而已。
客户端与注册中心的交互-客户端
RMI中客户端去找远程对象进行调用最开始是去找Registry

跟注册中心创建RegistryImpl_Stub的流程一样,Util.createProxy返回了一个RegistryImpl_Stub,所以说registry其实是RegistryImpl_Stub

获取到了RegistryImpl_Stub,接下来进行查找

进入newCall看看

LiveRef.getChannel获取了一个管道,new Connection与目标对象创建了连接
最后返回了StreamRemoteCall这个类其实也是对Connection的封装,

可以看到这个类的方法名大多都是对connection中输入输出流的操作

回到lookup继续调试
获取call后之后再获取call中的输出流并写入我们要查找的对象名,进入ref.invoke看看
invoke调用了executeCall,这个函数大致分为两部分

第一部分释放输出流并读取输入流前面几个字节,根据返回的类型returnType决定后面的操作

可以看到如果返回的类型是个异常,就会反序列化这个类,如果说Registry是个恶意的Registry,这里就存在反序列化漏洞了。
回到lookup

这里也存在一个反序列化洞,但是这个洞没有刚才那里用途广泛,因为这是写在lookup里面的,而ref.invoke在RegistryImpl_Stub#bind/rebind/list/lookup/unbind都有,因为ref.invoke(call)是用来与Registry实现通信的
lookup最后返回反序列化的对象
客户端与服务端交互-客户端




序列化调用方法的参数

String不属于上面的类型所以writeObject写入输出流中

反序列化服务端返回的序列化数据
客户端与注册中心的交互-注册中心
我累了,以后再学吧
About this Post
This post is written by DashingBug, licensed under CC BY-NC 4.0.