Java RMI 学习笔记(一)
⚠️ 注意:这是一篇粗糙的个人笔记!!!
📖 参考文章:https://su18.org/post/rmi-attack/
流程涉及的部分类及其意义
注册表:java.rmi.registry.Registry
注册表获取与创建:java.rmi.registry.LocateRegistry
远程类所继承的:java.rmi.server.UnicastRemoteObject
远程对象的应用信息:sun.rmi.transport.LiveRef
处理客户端对远程对象的调用请求(代表服务端):java.rmi.server.UnicastServerRef
远程对象引用的一个具体实现(代表客户端):java.rmi.server.UnicastRef
处理网络传输层相关任务的类和接口:sun.rmi.transport
ObjectTable负责管理服务器端的远程对象引用。
源码分析
创建
远程对象创建
远程对象实例化的时候,会调用UnicastRemoteObject的构造方法。
最后会调用UnicastServerRef的exportObject方法

而UnicastServerRef在初始化的时候,会用一个LiveRef对象为自己的ref属性赋值

LiveRef对象则封装了这个远程对象引用信息

这里UnicastServerRef的exportObject方法主要做了三件事:
- 为这个远程对象创建一个存根(stub)
- 创建目标(Target)实例。
- 调用
ref.exportObject(target)将远程对象导出,使其可以接受远程调用。

在调用Util.createProxy(implClass, getClientRef(), forceStubUse)创建stub的时候调用getClientRef()所返回的是一个根据UnicastServerRef的ref属性创建的UnicastRef实例

而stub其实是我们远程方法的一个动态代理,它的使用的InvocationHandler是RemoteObjectInvocationHandler

RemoteObjectInvocationHandler继承于RemoteObject,实例化的时候会接受一个UnicastRef为自己的ref属性赋值,在远程调用的时候就会依靠这个ref去于服务器进行通讯。它的invoke方法在后文会说

创建好stub之后,紧接着会创建Target实例。该实例封装了远程对象(impl),它所属的UnicastServerRef实例(通常是this),远程对象的存根(stub),对象ID(由ref.getObjID()获取),以及是否为永久导出(permanent参数)的信息。

然后调用LiveRef的exportObject将对象导出。这里最终会调用到sun.rmi.transport.tcp.TCPTransport#exportObject,其中会监听本地端口,
在 export 时,会随机绑定一个端口,监听客户端的请求,所以即使不注册,直接请求这个端口也可以通信。

然后调用 Transport 的 exportObject 方法将 Target 实例注册到 ObjectTable 中。

ObjectTable用来管理所有发布的服务实例 Target,ObjectTable 提供了根据 ObjectEndpoint 和 Remote 实例两种方式查找 Target 的方法

注册中心创建
调用java.rmi.registry.LocateRegistry#createRegistry实例化一个RegistryImpl

实例化一个UnicastServerRef然后调用setup方法

setup会调用UnicastServerRef的exportObject将自己(RegistryImpl)导出(RegistryImpl的ref是UnicastServerRef)

后面的内容和远程对象创建的流程基本一样,第一个区别在createProxy。createProxy在创建stub的时候会判断本地是否有远程对象的存根类存在

判断的方式就是查看是否有远程对象类名+"_Stub"的类存在

这里就会把RegistryImpl_Stub找出来然后实例化返回

RegistryImpl_Stub和RemoteObjectInvocationHandler同样继承于RemoteObject,实例化的时候也会接受一个LiveRef。

第二个区别在RegistryImpl_Stub创建好之后,会调用setSkeleton设置UnicastServerRef的skel属性


这里的skel其实就是实例化的RegistryImpl_Skel,RegistryImpl_Skel使用的是java.rmi.server.Skeleton接口,实现了dispatch方法来分发具体的操作。

后续Target的导出就和远程对象的那一块没什么区别了。
于远程对象创建做个类比:
- 手写远程对象继承于UnicastRemoteObject;注册中心则直接使用的RegistryImpl
- 远程对象的存根是使用RemoteObjectInvocationHandler的动态代理;注册中心的存根是反射创建的RegistryImpl_Stub
- 远程对象封装的UnicastServerRef中Skel为空;注册中心的则是RegistryImpl_Skel
bind/lookup
注册中心获取
Server的Naming.bind("rmi://localhost:1099/Hello", remoteObject)在绑定远程对象前,和Client一样,都先调用了LocateRegistry.getRegistry("localhost", 1099)获取注册表。其中会调用sun.rmi.server.Util#createProxy也就是之前创建stub的方法,创建一个RegistryImpl_Stub返回。

bind
RegistryImpl_Stub.bind大致可以分为三步:
- 创建远程调用:调用
sun.rmi.server.UnicastRef#newCall实例化一个StreamRemoteCall对象并返回,它用于处理RMI客户端与服务端之间的远程调用过程中的序列化(编组)和反序列化(解组)的流操作。创建的时候指定的操作数,0表示bind一个远程对象。 - 序列化参数:获取RemoteCall的输出流(
ObjectOutput var4 = var3.getOutputStream();),并将要绑定的名称(var1)和远程对象(var2)序列化到这个流中。这个过程是将Java对象转换为可以通过网络传输的字节流的过程,称为编组(marshalling)。 - 执行远程调用:使用
super.ref.invoke(var3)调用在远程引用上执行远程方法调用。这个步骤实际上是将之前编组的参数发送到远程RMI注册表服务,并请求执行bind操作。

(次日补充:
如果按照这里代码所示,在注册中心绑定的应该是这个远程类的实现类。但实际上绑定的是这个类的一个代理类,其结构如下:

按照文章中的说法,在实例化StreamRemoteCall的时候会调用marshalCustomCallData方法,这个方法是设计来序列化(编组)自定义的调用数据到输出流中的。引用文中的图片和描述
使用 sun.rmi.server.MarshalOutputStream 封装后会使用动态代理类来替换原始类。
不过我所调试的源码中marshalCustomCallData没有实现任何内容

奇怪的是最后注册表收到的类,还是这个远程类的代理类。可能是Java版本的原因(我所用的是JDK 1.8.0_65),这里暂不深究
)
当传入sun.rmi.server.UnicastRef#invoke的参数为RemoteCall类型时,会直接调用它的executeCall方法

在这里面会调用releaseOutputStream()释放输出流,它标志着发送给远程对象的数据已经完成序列化并被发送出去;接着会调用getInputStream()来从网络连接接收数据。

在 Registry 端,由 sun.rmi.transport.tcp.TCPTransport#handleMessages 来处理请求,调用 serviceCall 方法处理。

serviceCall 方法中从 ObjectTable 中获取封装的 Target 对象,并获取其中的封装的 UnicastServerRef 以及 RegistryImpl 对象。然后调用 UnicastServerRef 的 dispatch 方法

UnicastServerRef 的 dispatch 方法调用 oldDispatch 方法,这里判断了 this.skel 是否为空,用来区别自己是 Registry 还是 Server。

在oldDispatch中会调用RegistryImpl_Skel 的 dispatch。

RegistryImpl_Skel 的 dispatch会反序列化远程对象以及它的名称,然后调用 RegistryImpl 的bind方法

最终会在 RegistryImpl的 bindings 上添加远程对象的信息

lookup
客户端调用的lookup,其实是RegistryImpl_Stub的lookup。这个流程和上面bind的类似,首先会创建一个RemoteCall指定操作数为2,也就是lookup。接着会将要查找对象名称序列化后写入流,注册中心查找对应的stub,序列化之后返回给客户端。客户端反序列化之后返回这个stub。

在RegistryImpl_Skel的dispatch中看下lookup,也就是switch 2的处理方式。调用的是RegistryImpl的lookup,也就是从bindings中获取对应的代理类。再将这个类序列化写入流,由客户端接受。

客户端获取的stub

调用
客户端调用远程对象方法的时候,就是通过 **RemoteObjectInvocationHandler **的 invoke 方法。

RemoteObjectInvocationHandler的 invokeRemoteMethod 会调用自身 UnicastRef的 invoke 方法,它接受远程对象的代理(proxy)、调用的方法(method)、参数(args)和方法的哈希(getMethodHash(method))。

UnicastRef的 invoke 方法会先于服务端绑定这个远程对象的端口建立,执行调用,读取结果并反序列化。

反序列化的方法在后面的unmarshalValue中,可以看到除了基础类型(不包含String),都会对返回对象进行反序列化操作。

和 RegistryImpl_Stub调用流程相似,服务端首先根据连接的信息从 ObjectTable 找到 Target,再用 getDispatcher 获得其中的 UnicastServerRef,并调用 UnicastServerRef的 dispatch 方法来处理。
在这里可以看到,由于是远程对象调用,操作数为-1,就不再走skel的处理逻辑了

调用方法前会先检查要调用的方法存不存在,如果存在则调用unmarshalValue反序列化出参数,然后反射调用方法

然后将调用结果序列化返回给客户端

小结
现在对rmi大致的工作流程有个了解了。
主要是代表客户端 stub的调用方式,而注册中心 Registry就相当于在基础的 stub 和 UnicastServerRef 的通信流程上多了一点东西。
从这个流程我们也知道了,Server,Registry 和 Client 之间的通讯均是由反序列化实现的,也就说明针对三端都有攻击的可能。
具体的攻击方法先大致看了一遍,具体流程等之后有时间再做了,这里先记一下。
攻击Server端:
- 恶意序列化对象作为参数传入
- 如何参数不是Object类型,可以通过修改传入方法Hash来匹配。总结:Server 端的调用方法存在非基础类型的参数时,就可以被恶意 Client 端传入恶意数据流触发反序列化漏洞。
- 动态类加载:通过
this.readLocation()方法读取流中序列化的java.rmi.server.codebase地址让服务端再远程加载恶意类
攻击 Registry 端:
- 在 Server 端向 Registry 端提交
bind的远程对象时,Registry 会反序列化这个对象存放到bindings中。所以 Server 端可以提交一个恶意类,需要是 Remote 类型(用 AnnotationInvocationHandler 来做)
攻击 Client 端:
- 恶意 Server Stub:同攻击 Registry 端,Client 端在 Registry 端 lookup 后会拿到一个 Server 端注册在 Registry 端的代理对象并反序列化触发漏洞。
- 恶意 Server 端返回值:同攻击 Server 端的恶意服务参数,Server 端返回给 Client 端恶意的返回值,Client 端反序列化触发漏洞,不再赘述。
- 动态类加载:同攻击 Server 端的动态类加载,Server 端返回给 Client 端不存在的类,要求 Client 端去 codebase 地址远程加载恶意类触发漏洞,不再赘述。
