Java RMI 通信源码分析

前言

在刚学习 Java 反序列化漏洞时,我有简单分析过 Java RMI 机制( 可以参考 Java 反序列化漏洞(1) – Java RMI 原理/流程 )。但当时是站在使用的角度来分析的,对 Java 的理解完全不够,所以没有深究其原理,也留下了一些问题。

那么本章我就来补充前面的内容,从源码分析的角度,来谈一谈 Java RMI 机制究竟是怎样的.


Java RMI 机制回顾

关于什么是 Java RMI 机制这里不再赘述, 前文的链接中已经说的很清楚了。这里我们只来回顾一个最简单的 RMI Demo。

  • 在服务端, 需要先定义一个提供 Hello 服务的接口, 以及该接口的实现。

  • 此外,在服务端还需要创建注册中心, 并在注册中心上发布要提供的服务。

  • 在客户端, 也需要一个提供 Hello 服务的接口, 以及实现远程方法调用的客户端代码。

  • 最后, 启动服务端端口监听, 即可在客户端调用服务端提供的方法。

这样, 客户端成功调用了服务端提供的方法, 实现了远程方法调用(RMI)。


Java RMI 机制源码分析

RMI 服务发布阶段

先来看看 RMI 的服务发布阶段, 该阶段首先需要实例化一个 helloImpl 实例对象。

这个实例化过程实际上是通过 super() 方法来调用父类 UnicastRemoteObject 的构造方法。

跟进 UnicastRemoteObject 构造方法, 该方法会指定一个匿名端口, 并调用 exportObject() 方法来发布服务。

匿名端口: 操作系统为服务分配的一个任意可用的端口

这个 exportObject() 方法是一个挺核心的方法, 它主要用于发布一个服务. 下面我们跟进该方法, 来看一看它到底做了什么。


封装网络信息

如上图, 该方法首先会生成一个 UnicastServerRef 实例对象, 进入 UnicastServerRef 的构造方法, 可以看到其中实例化了一个 LiveRef 类型的属性, 并调用其父类的构造方法。

LiveRef 的实例化过程中会调用 TCPEndpoint.getLocalEndpoint() 方法来获取一个 TCPEndpoint 类型的变量。

因此, 实例化的 UnicastServerRef 对象会封装当前的网络信息.


创建Stub对象

这里会判断传入的对象是不是我们要发布的服务对象(即继承了UnicastRemoteObject接口),如果是则将该对象设置为当前对象, 并通过exportObject()方法来发布.

继续跟进 exportObject() 方法, 这里会先用我们传入的参数创建一个代理对象, 这个代理对象实际上就是之前一直说的STUB存根对象. 我们来看一看它到底是如何生成的.


通过JDK原生动态代理创建Stub代理类

在该方法中会先获取被提供服务的实现类, 这个实现类必须要继承java.rmi.Remote接口.

然后程序会判断存根类是否存在, 以及存根类的生成方式. 如果发现存根类已存在, 就会调用 createStub() 方法生成存根对象.

如果发现存根类不存在, 就会通过JDK原生动态代理来生成存根类. 我们来看一看代码中是如何判断的.

  • var2

    var2其实是 UnicastServerRef.forceStubUse 属性值. 如果该属性值为 True, 则代表当存根类不存在时会抛出异常, 结束程序运行. 该属性的默认值为 False .

  • !ignoreStubClasses

    该属性表示存根类的生成方式, 这个点在 StackOverFlow 这篇文章中有提到. 如果ignoreStubClasses == True, 则代表存根类是通过RMIC手动生成的: 如果 ignoreStubClasses == False, 则代表存根类需要通过动态代理模式来生成.

    该属性在初始化时会被赋值为 False, 因此这里 !ignoreStubClasses == True, 即需要下文通过JDK原生动态代理来生成存根类.

  • stubClassExists(var3)

    该函数用于判断存根类是否存在, 跟进该函数, 来看下代码.

    withoutStub 属性包含被提供服务的接口实现类的缓存, 代码中会判断该缓存中是否存在被提供方法的实现类, 如果没有, 则调用 Class.forName() 方法查找对应的存根类.

    如果没有查询到对应类, 则代码将抛出 ClassNotFoundException 异常, 并将传入的接口实现类(helloImpl.class)存入 withoutStub 属性中. 并返回 False.

    在初始化阶段, 存根类肯定不存在( 默认不使用 RMIC 手动生成 ), 因此程序无法直接调用 createStub() 方法来实例化存根对象, 程序会进入 Else 代码块.

根据上面的理解, 我们知道 IF 语句中第 2 个和第 3 个条件应该是同时判断的, 即同时判断存根类是否存在以及存根类的来源. 这也引申出另一个点: Java 中 && 运算符优先级高于 || 元素符, 所以程序会先对第 2 个和第 3 个条件进行判断.

Else代码块的内容非常眼熟, 这是JDK原生动态代理. 回顾一下基础知识, 要想使用JDK原生动态代理, 就必须传入以下三个参数.

  • 动态代理类的类加载器 ClassLoader
  • 被代理对象接口数组 Interfaces
  • 调用处理器 InvocationHandler

代码中会逐一获取这些参数, 最后再通过 Proxy.newProxyInstance()方法生成动态代理对象.

被代理的对象中有 hello 接口, 这刚好是我们要提供服务的接口. 根据JDK原生动态代理的机制, 所有访问 hello 接口方法的调用请求都会被转发到调用处理器的 invoke() 方法中. 这里还未进行方法调用, 因此我们暂且跳过这里, 来看一看 createProxy() 方法的返回值.

生成的动态代理对象的确代理了 hello.sayHello() 方法, 说明我们的代码没错.

接着程序会判断生成的代理对象是否属于 RemoteStub 类型及其子类. 如果判断条件成立, 则程序会调用 setSkeleton() 方法生成 Skeleton.

RemoteStub 对象是通过 createStub() 方法生成的, 但这里还未生成存根对象, 因此自然不会调用 setSkeleton() 方法.

此时, 存根类的初始化工作就已经完成了, 我们继续往下看.


开启端口监听

随后程序会实例化一个 Target 对象, 该对象封装了服务接口的实现类和生成的动态代理类等信息, 并调用 exportObject() 方法来创建服务.

跟进到TCPTransport.exportObject()方法后,会看到调用了一个listen()方法, 这个方法用于开启Socket端口监听. 此外, 一个端口上可能会发布多个服务, 因此使用 this.exportCount 属性来记录发布的服务个数,

该方法中会先获取一些端口信息和IP地址信息. 然后判断 this.server 属性是否为空; 此时服务还未启动, 因此我们跟进到 if 语句结构中.

在 if 语句结构中, 会调用 TCPEndpoint.newServerSocket()方法来开启端口监听.

然后会创建并启动了一个新的线程来循环监听端口数据.

此外, 程序还会将 Target 对象添加到 ObjectTable 中. 便于RMI客户端通过它找到远程对象的存根对象.


RMI 服务注册阶段

接下来需要通过 LocateRegister.createRegister() 方法在 RMIRegister 上注册服务. CreateRegister() 方法中会实例化一个 RegistryImpl 对象, 因此我们直接跟进其构造方法.

同样会实例化 LiveRef 对象与 UnicastServerRef对象. 这里的步骤与前文基本一致, 只是端口号被指定为 1099, 且UnicastServerRef.filter属性被指定为 RegisterFilter .

接下来继续调用 UnicastServerRef.export() 来创建 Stub 代理对象, 这一步我们比较熟悉了. 但不同的是, 这里被提供服务的实现类是 RegistryImpl. 它是一个JDK内置类, 因此 RegistryImpl_Stub.class 是存在的.

存根类存在, 程序就会通过 createStub() 方法来生成存根对象并返回.

返回的对象是 remoteStub 类型, 那么程序就调用 setSkeleton()createSkeleton() 方法来生成 Skeleton 对象.

生成 Skeleton 对象的代码与生成 Stub 对象非常类似, 这里就不赘述了. 同理, registryImpl_Skel.class 也是默认存在的.

然后就是生成 Target 对象并发布服务了.

此外, 程序还会将生成的 Target 对象添加到 ObjectTable 中. 便于RMI客户端通过它找到远程对象的存根对象.


RMI 服务绑定阶段

在测试代码中是通过 java.rmi.Naming.rebind() 方法进行服务绑定的, 因此我们跟进该方法.

该方法会先解析传入的URL参数, 然后调用 getRegistry() 方法来处理解析结果. 获得 Registry 实例对象.

最后调用 registry.rebind() 方法来在 Registry 上绑定被提供服务的对象, 来看一看绑定工作是如何实现的.


RMIServer 与 RMIRegistry 握手流程

程序会先通过调用 UnicastRef.newCall() 方法来完成 RMIServerRMIRegistry 的握手协商.

跟进 newCall() 方法, 程序会先调用UnicastRef.getChannel().newConnection()方法初始化,并通过 createConnection() 方法创建连接.

跟进 createConnection() 方法, 当TCP三次握手协商成功后, 程序会通过 TCPChannel.writeTransportHeader() 方法来发送JRMI Header信息, 尝试建立RMI通信.

这里会先写入一个INT型值:1246907721, 然后再写入一个SHORT型值:2, 最后再判断var1是否为空且是否为RMISocketInfo.Class的实例对象, 如果是则写入十进制值75. 最后通过 flush() 方法完成写入操作, 并发往 RMIResgister.

DEC(1246907721) == HEX(4A 52 4D 49)
DEC(2) == HEX(02)
DEC(75) == HEX(4B)

RMIRegister在接收到数据后, 会返回一个ACK报文, 这个ACK报文里会携带一个确认码0x4E(78), 以上一步RMIServer发起请求的IP地址和端口号.

RMIServer在接收到ACK报文后, 会解析这个报文, 并返回Stub的IP地址和端口号信息.

这样, RMIServer 和 RMIRegister 的握手流程就完成了.


创建并组合调用内容

当RMI握手完成后, RMIServer会创建并组合调用内容. 来看下这一步是如何实现的.

首先, RMIServer 会生成一个StreamRemoteCall实例对象. 其中包含一个远程调用头信息(80), 以及 ObjectID, Hash 等信息. 值得一提的是var3这个变量, 其值为3. 最开始我一直不明白这个3代表什么意思, 后来看到 p1g3@D0g3 前辈[Java 安全-RMI-学习总结] 一文才恍然大悟.

这个3其实是映射到rebind方法, 在 RegistryImpl_Stub 中有5种不同的操作, 每种操作分别对应一个整型值.

  • bind() ---> 0
  • list() ---> 1
  • lookup() ---> 2
  • rebind() ---> 3
  • unbind() ---> 4

随后, RMIServer 会对返回的 var7.getOutputStream() 调用 marshalCustomCallData() 方法, 但这个方法似乎是空的, 没看到有什么意义.

最后, RMIServer 会将被提供服务的名称及其实例对象序列化后写入到 StreamRemoteCall.getOutputStream() 中. 并调用 Unicast.invoke() 方法来发送序列化数据.


发送绑定请求.

UnicastRef.invoke() 的核心就是 StreamRemoteCall.executeCall() 方法. 我们跟进该方法, 该方法实际是通过调用 releaseOutputStream() 方法来发送序列化数据的.

该方法里会判断 this.out 是否为空, 不为空就通过 this.out.flush() 方法发送数据.

随后 RMIServer 会接收响应数据包并进行验证, 比如判断响应是否正确(81)等.

至此, RMI服务绑定阶段就已经完成了. 最后会通过 UnicastRef.done() 方法来释放连接.


RMI 方法调用阶段

接下来我们来看看客户端的代码, 分析远程方法调用的流程是怎么样的. 首先跟进 Naming.lookup()方法. 该方法会先解析传入的URL参数, 并获取 Registry 实例对象.

跟进 registry.lookup() 方法, 这里的代码我们比较熟悉, 会先通过 UnicastRef.newCall() 方法完成RMI握手, 然后通过 writeObject() 写入序列化数据, 最后通过 UnicastRef.invoke() 方法发送数据. 值得一提的是: 这里发送的是要访问的服务: hello .

invoke() 方法中的内容也是类似的, 我们直接来看 executeCall() 方法发送的Pcap数据包.

RMIRegistry 会返回我们之前创建的 Stub 存根对象(动态代理对象), RMIClient 会调用 writeObject() 方法来反序列化该对象. 最后再通过 done() 方法完成垃圾回收, RMIRegistry 返回 DgcAck 报文.

然后来就是动态代理的内容了, RMIClient 会调用代理类的 sayHello() 方法. 且方法调用请求会被转发到调用处理器的invoke()方法中(RemoteObjectInvocationHandler.invoke()). 最后逐步转发到 UnicastRef.invoke()方法中.

invoke()方法中会先通过 marshalValue() 方法组合要发送的数据, 可以看到这里会组合我们传入的参数 "Epicccal" 以及传入参数的类型.

当数据组合好后, RMIClient 会调用 executeCall() 方法来发送数据. 然后等待被调用的方法在 RMIServer 执行完, 并获取返回值.

可见, RMIServer 最终会返回方法调用的结果. 这样一次远程方法调用就完成了.


RMIRegistry 响应 RMIServer、RMIClient 流程

在创建注册中心(LocateRegistry.createRegistry)时, 是调用 TCPTransport.AcceptLoop() 方法创建一个新的线程来处理请求, 那么这个处理过程是怎么样的呢?

RMIClient 和 RMIServer 都会与注册中心通信,且步骤是类似的. 当执行上述步骤时,会触发 TCPTransport$AcceptLoop.run() 方法, 我们切换调试线程,来看一看具体流程.

跟进 TCPTransport.executeAcceptLoop() 方法,该方法会先获取一些基本网络信息(比如IP地址等),然后会创建一个新的线程来调用 ConnectionHandler 类来处理请求.

当执行该步骤时, 会触发 TCPTransport$ConnectionHandler.run() 方法, 我们切换调试线程,来看一看具体流程。

run() 方法中会调用 run0() 方法,该方法会获取一些客户端发送的数据,并对其进行解析与验证。

举个例子, 在 RMIServer 与 RMIRegistry 握手阶段,当 RMIServer 发送握手请求后, RMIRegistry 会解析并处理这个请求。

首先 RMIRegistry 会先读取 RMI Magic 信息, 判断通信是 RMI-Over-HTTP(1347375956) 模式还是 JRMI(1246907721) 模式.

然后会通过 Switch 语句判断协议字段,RMIServer 在握手时使用的是 StreamProtocol(0x4B), 所以进入 "case 75:" 中.

在 "case 75:" 中会构造响应数据包.

此外,还会实例化一个 TCPConnection 实例对象,并通过 TCPTransport.handleMessages() 方法来处理该对象.

在 RMI 握手完毕后, RMIServer 会发送服务绑定请求,其中就包含被提供服务的名称及其实例对象序列化数据. 在 handleMessages() 方法中会解析处理这部分序列化数据.

首先,RMIRegistry 会读取序列化数据第一个字节,判断 Output-Stream-Message 信息. 这里为调用 Call 功能(0x50),也就是80.

在 "case 80:" 中,会先实例化一个 StreamRemoteCall 对象, 然后调用 seviceCall() 方法,跟进该方法.

该方法会先提取一些基本信息(ObjID,ClassLoader)等,然后调用 UnicastServerRef.dispatch() 方法来处理它们. 这里会传入两个参数,一个是Target对象,一个StreamRemoteCall对象,都是我们之前构造好的.

跟进 dispatch() 方法,该方法会先获取StreamRemoteCall对象与整数3(对应rebind操作),然后判断RegistryImpl_Skel对象是否存在,如果存在就调用 oldDispatch() 方法来进行进一步处理.

oldDispatch() 方法也会获取一些数据,不过他的关键在调用 RegistryImpl_Skel.dispatch() 方法上,跟进该方法.

在该方法首先会判断 RMIServer 执行的操作,我们前文一直说整数 3 会映射到 rebind 操作,就是从这里来的.

然后 RMIRegistry 会依次获取要绑定的服务名称,并反序列化前面发送的数据. 最后调用 RegistryImpl.rebind() 方法来绑定服务.

跟进这个 rebind() 方法会看的更清楚. 将要绑定服务的名称和 Stub 代理对象 put 进了一个 hashtable 中,也就算服务绑定成功了.

最后通过 releaseOutputStream() 来发送响应数据包.


RMIServer 响应 RMIClient 流程

最后,当 RMIClient 发起远程方法调用时, RMIServer 是如何处理的呢?

这里调试比较简单,因为我们知道 RMIServer 最终会调用 helloImpl.sayHello() 方法,因此我们可以直接在 sayHello() 方法中下断点,运行后直接看函数调用栈.

看这个调用栈,核心应该还是在 UnicastServerRef.dispatch() 方法中,该方法会不断读取数据并解析.

  1. 这里获取到了要远程调用的方法

  2. 这里获取到了要远程调用的方法的参数

然后这里调用了 Method.invoke() 方法,分别传如 helloImpl 对象和参数 "Epicccal".

接下来就是反射的内容了,服务端会执行 helloImpl.sayHello("Epicccal") 方法并返回结果,然后 RMIServer 会将执行结果返回给 RMIClient. 这个远程调用过程就看完了.


总结

本章内容主要是对 RMI 整个过程的源码分析. 学到了不少东西,非常感谢各位前辈和老师的文章和指导,让我受益匪浅.

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇