Java反序列化之RMI专题01-RMI基础
0x00 RMI简述
RMI是什么
RMI 全称 Remote Method Invocation(远程方法调用),是Java语言执行远程方法调用的Java API。RMI允许一个 JVM 中 Java 程序调用在另一个远程 JVM 中运行的 Java 程序,这个远程 JVM 既可以在同一台实体机上,也可以在不同的实体机上,两者之间通过网络进行通信。
JRMP是什么
RMI里两个JVM通信所采用的协议正是JRMP(Java Remote Method Protocol, Java远程方法协议)。RMI用JRMP协议去组织数据格式然后通过TCP进行传输,从而实现远程方法调用。
RMI时序图
RMI的流程较为复杂,不妨先从下图入手:
服务端先创建远程对象并向注册中心注册该远程对象,客户端向注册中心查找该远程对象,注册中心会返回该远程对象的存根(Stub),客户端对存根调用service进而与服务器骨架(Skeleton)通信,服务器对骨架调用service并返回给本地存根,本地存根返回给客户端。
0x01 基础环境
本文有相当一部分的调试内容,但在此之前需要准备RMI工作的基本代码。
起两个项目:RMIServer、RMIClient
服务端 RMIServer
接口 IRemoteObj
1 | package TestOne; |
此远程接口要求作用域为 public;
继承 Remote 接口;
让其中的接口方法抛出异常
实现类 RemoteObjImpl
1 | package TestOne; |
- 实现远程接口
- 继承 UnicastRemoteObject 类,用于生成 Stub(存根)和 Skeleton(骨架)。 这个在后续的通信原理当中会讲到
- 构造函数需要抛出一个RemoteException错误
- 实现类中使用的对象必须都可序列化,即都继承
java.io.Serializable
注册远程对象 RMIServer
1 | package TestOne; |
- port 默认是 1099,不写会自动补上,其他端口必须写
- bind 的绑定这里,只要和客户端去查找的 registry 一致即可。
服务端就写好了。
客户端 RMIClient
同样的接口 IRemoteObj
1 | package TestOne; |
客户端操作 RMIClient
1 | package TestOne; |
客户端代码也写好了。
0x02 调试分析
0. 流程总览
RMI 有三部分:RMI Registry、RMI Server、RMI Client,两两通信就有6个交互过程,加上3个创建过程,一共是9个观察。
再给一张RMI的工作原理流程图:
1. 创建远程服务
一共有两大要点:
- 发布远程服务
- 发布完成之后的记录
发布远程服务
创建远程服务属于服务端的工作,且不存在漏洞。为了方便大家理解该工作各个类的关系,先给出一张图。初学者会觉得这张图很难懂,跟着调试几遍就能理解。
开始调试,断点如下图:
步入:
强制步入到父类构造方法,步入到exportObject :
在最开始我们可以想到,我们需要把远程对象发布到注册中心,这里就有两个重要的点:
- 用什么方式发布对象
- 怎么保证通信的一致性(处理网络请求)
exportObject方法是一个静态函数,它主要负责将远程服务发布到网络上。所以我们下一步的重点就在该方法上,同时要有意关注Java如何通过层层封装实现上述的两个重点。
接着调试,步入exportObject方法:
不难知晓,第一个参数Obj是远程对象,第二个参数是UnicastServerRef构造方法的返回值,也就是UnicastServerRef类实例,自然与第二个要点(通信一致性/处理网络请求)有关——事实上也正是如此。
UnicastRef对应客户端,UnicastServerRef对应服务端,二者是神奇的继承的关系,读者不妨看一下上文的导图,得知UnicastServerRef的作用是创建服务端引用,通俗来说就是处理网络请求。
可以猜想,UnicastServerRef类肯定是层层封装过的,当前的任务是找到真正处理网络请求的底层逻辑。
接着调试,步入到UnicastServerRef构造方法:
不知道是super方法还是LiveRef类处理网络请求,都看看:
很明显是LiveRef类处理网络请求,步入到关键处:
可以看出,想要知道处理网络请求的底层逻辑还需要深入到TCPEndpoint类。
但是需要重视,LiveRef类在之后的调试流程里始终是同一个对象,这正是通信一致性的体现——LiveRef对象的功能正是处理网络请求。
接着调试,调试getLocalEndpoint方法到关键处:
综上,我们解决了第一个小问题:处理网络请求/通信一致性。但是流程尚未结束,我们的主线并不只是处理网络请求,而是发布远程服务。
不断步过,回归此处:
接着调试,进入到exportObject方法:
这里的sref就是上文谈的包含LiveRef的UnicastServerRef。
不难得知,sref的exportObject方法会承接发布远程服务这个工作,步入:
看到了熟悉的存根(Stub)及其构造,步入creatProxy方法:
可见存根的本质就是动态代理。
接着走,看到了骨架(Skeleton)的创建过程:
但是当前的Stub不是系统内置的Stub(RemoteStub及其子类),不进该分支。
接着走,到了下一个重点:
targret可以理解为一个总封装,把目前我们创建的零件封装到一起,最后再把target发布出去。步入target看看属性:
disp:UnicastServerRef 服务端引用
stub:代理(动态代理)
impl:远程服务实现类(后面的注册中心实现类也是这个参数)
id:内部liveRef的编号
关键是二者都封装了同一个liveRef,并且该liveRef的id就是整体target的id,可见其重要性。(不要小看这个id,后面会发挥大作用)
回归下图:
一直步入ref.exportObject方法到下图:
listen方法开启了一个新的ServerSocket,开启了一个新的线程,监听客户端请求。
newServerSocket方法将创建随机端口,步入:
回到listen方法:
网络请求的线程和代码逻辑已经分开,我们代码可以继续走,处理网络请求的线程将持续等待。
这时,远程对象已经在服务端上的随机端口发布出去了
发布完成之后的记录
先步过到这里:
步入:
步入+步过:这两张表
其实就是把一些琐碎对象存储到ObjectTable
类的两个静态Map中:
创建远程服务 · 小结
主线一直都是层层封装的类的exportObject方法,多调试就不难想到。
2. 创建注册中心 + 绑定
创建注册中心
与创建远程服务有相似处,给出调试入口:
强制步入:
注册中心的端口必须是1099。
智能步入:
不管是否进行安全检查,这两行代码是肯定会执行的。
建一个新的LiveRef(这次端口强制是1099),作为注册中心,id默认为0,套在UnicastServerRef里面。
熟悉的步骤,我们直接进入到setup方法:
重点来了,我们先对比一下前文exportObject方法的三个参数与此有何异同,功能上又有何异同:
exportObject方法参数对比图:
对象 | impl | data | permanent |
---|---|---|---|
sref:UnicastServerRef | RemoteObjImpl(远程对象) | null | false |
uref:UnicastServerRef | RegistryImpl(注册中心) | null | true |
- 第一个参数代表远程对象,创建远程对象就是自己实现的Impl,创建注册中心就是RegistryImpl
- 第三个参数代表时效选项,上次是false,这次变成了true
第三个参数反映:
- 远程对象的创建依赖于我们自定义的实现类,是一个临时的;
- 注册中心是JDK原生支持的,所以是永久的;
言归正传,再往下走。步入exportObject方法:
进去观察stub是如何构造的:
这个判断提炼一下:如果需要stub的类的类名+_stub,比如RegistryImpl_Stub是Java内置的类,则判断正确进入该分支;试想,如果是我们自己构造的远程对象类,其类名+_Stub肯定不属于Java内置类。其具体细节如下:
所以在我们创建远程服务时,我们不会进入if分支,也就是不执行如下代码:
1 | createStub(remoteClass, clientRef); |
我们当前创建的是注册中心:RegistryImpl,系统存在对应的Stub类,进入该分支也就是creatStub方法。我们也进去看看:
反射构造RegistryImpl_Stub类并返回该类。细心的读者不难发现,创建远程服务和创建注册中心下创建对应Stub的方法和结果是不太一样的:
- 创建远程对象时,stub是动态代理,Proxy对象,内部封装了RemoteObjectInvocationHandler(clientRef);
- 创建注册中心时,stub是RegistryImpl_Stub对象,内部也需要clientRef参与构造;
二者创建代理时,都将clientRef包含进去了。clientRef是UnicastRef,内部封装了LiveRef属性,liveRef在创建远程对象时id是随机的,创建注册中心时是0。
言归正传,接着往下调试到这里:
RegistryImpl_Stub继承RemoteStub,进入该分支,步入:
步入createSkeleton方法:
创建好的skeleton,会被放在注册中心实现类中。具体来说,创建好的skeleton其实会存储在UnicastServerRef的skel属性中:
先梳理一下注册中心/RegistryImpl的结构图:(图篇源于大佬,在参考教程)
调试到这一步:
观察一下各个属性,再给出大佬的更加详细的图
现在我们又回到了主线:exportObject方法,步入到这里:
很熟悉吧。接着步过到这里:
步入exportObject方法:
步入putTarget方法,观察objTable中的具体内容:
第一个是分布式垃圾回收的,第二个是远程对象的,第三个是注册中心的。
具体细节请读者自行观察对比,或者参考文末的教程。下面给出具体的流程导图:
此时注册中心打开了1099端口,等待客户端(或服务端)发送lookup、bind等请求。
绑定
Register+Name方式
调试入口:
强制步入:
bindings本质上就是一个哈希表:
这一步的逻辑也不难,如果需要绑定的对象已经在哈希表里,则抛出异常;如果其不在表哈希里,则put到哈希表里。
给出注册中心的示意图:
Naming+url方式
也看看这种绑定方式吧。
1 | Naming.bind("rmi://localhost:1099/Hello", remoteObject); |
绑定需要两个参数,前者是url,后者是远程对象。文末参考教程里有详细的调试过程,这里就不赘叙了。
3. 客户端请求注册中心
先看代码:
关注前两行代码:
- 客户端创建注册中心(RegistryImpl_Stub)
- 客户端查找远程对象(remoteObj)/ 获得远程对象代理(Proxy)
客户端创建注册中心
先进入第一行代码,调试到这里:
返回值是RegistryImpl_Stub,即注册中心。这里要关心一点,RegistryImpl_Stub的ref是UnicastRef类型:
接着进入creatProxy方法:
不难想到,客户端其实是利用注册中心给的参数自己本地创建了一个RegistryImpl_Stub,结构图如下:
创建了注册中心,下一步就是查找远程对象。
客户端查找远程对象
进入第二行代码,强制步入后是这样的情况:
回溯一层,到lookup方法:
上文谈到的ref在这里调用了newCall方法,其作用是建立连接,这里不叙。
走到这里······走不了的,有一些版本上的差异导致该类(RegistryImpl_Stub)无法进行调试。往下看:
var1是参数“remoteObj”, 最后是作为序列化的数据传进去的。注册中心后续会通过反序列化读取。再往下走:
到重点了。这里的 invoke方法是类似于激活的方法,进去看看:
接着进去,到这里:
in是数据流里的东西,我们只要制造出预期的错误,就可以实现反序列化攻击,这种攻击更加隐秘。事实上,RegistryImpl_Stub里的bind方法、list方法、lookup方法等等需要网络通信的方法,都有用到invoke方法,都可以在此处尝试Java反序列化攻击。
上述就是注册中心与客户端进行交互时会产生的攻击。
回到lookup方法,后续将进行反序列化,给出大佬的图:
远程对象会以动态代理的形式返回,里面包含了liveref,需要连接的ip:port等等信息:
远程对象查找成功。
4. 客户端请求服务端
对应这一行代码:
自行调试到这里:(学这么久了调试应该有点思路了)
都是些判断,走到这里
进去看看,再进到ref.invoke看看:
该方法会序列化一个值,这个值是远程服务调用的参数,也就是我们传进去的参数 “hello”,就不展开讲了。
接着走,嘿,好熟悉:
不就是前文的这里吗:
就目前而言,只要客户端有通信——RMI 处理网络请求,就会调用executeCall方法,就有可能于此处进行Java反序列化攻击。
接着往下走:
这里有一个
unmarshalValueSee
的方法,因为现在我们传进去的类型是 String,不符合上面的一系列类型,这里会进行反序列化的操作,把这个数据读回来,这里是存在入口类的攻击点的。
把“hello”反序列化回来了:
客户端小结
- 简单梳理一下流程:
分为三步走,先获取注册中心,再查找远程对象,查找远程对象这里获取到了一个 ref,最后客户端发出请求,与服务端建立连接,进行通信。
- 用我的话讲:
先得到注册中心的代理(Stub),再得到服务端的代理(Stub),最后调用远程服务。
- 再谈谈这一部分所展露的反序列化攻击点:
- 注册中心可以in.readObject操作一下客户端
- 服务端不仅可以如此,还可以unmarshalValue一下。
下面我们要谈谈,在前面客户端做交互的时候,对应的注册中心和服务端是如何回应的。
5. 注册中心响应客户端
调试的入口在哪里呢?
先前在走到listen方法的时候,注册中心单开了一个线程来监听网络。很明显,客户端的请求会被该线程监听到,那我们就从这里入手。
具体过程不赘叙,给出流程图:
1 | listen->AcceptLoop.run()->executeAcceptLoop()->ConnectionHandler.run()->run0()->handleMessages()->serviceCall(call) |
给出断点位置:
注册中心里面的objTable存放着两个Target,其中我们根据ObjectEndpoint在表中寻找具有和客户端相同RegistryImpl_Stub的Target对象,因为它的disp属性(UnicastServerRef)里面,我们当时存放了skel属性,现在需要用了。
ObjectEndpoint中的id应该是由ip和端口号生成的,注册中心创建和客户端的是相同的,所以可以准确找到包含skel属性的Target。
如何开启调试就不说了,想通关节处自然明白。
走到这里:
注意此时的端口是1099:
此时的disp就有了skel:
再进入下面的disp.dispatch方法看看:
进去看看:
再进去,直到RegisterImpl_Skel.class:
- 先介绍一下这段源码吧,很长,基本都是在做 case 的工作。
我们与注册中心进行交互可以使用如下几种方式:
- list
- bind
- rebind
- unbind
- lookup
这几种方法位于
RegistryImpl_Skel#dispatch
中,也就是我们现在 dispatch 这个方法的地方。如果存在对传入的对象调用
readObject
方法,则可以利用,dispatch
里面对应关系如下:
- 0->bind
- 1->list
- 2->lookup
- 3->rebind
- 4->unbind
事实上,除了list方法都可以。
简单,注册中心就是处理 Target,进行 Skel 的生成与处理。
漏洞点是在 dispatch 这里,存在反序列化的入口类。这里可以结合 CC 链子打
6. 服务端响应客户端
动态代理的Stub
两个断点:
不断F9,直到Stub是Proxy:
步入:
走到这里:
步入,里面就是把客户端传进来的远程服务的参数反序列化,可以利用:
客户段序列化参数,服务端反序列化参数;服务端序列化结果,客户端反序列化结果。
DGC的Stub
断点需要下在 ObjectTable类的 putTarget 方法里面。并且将前面两个断点去掉,直接调试即可。
回溯到这边:
往上翻,打断点:
想想DGC这个类是怎么创建的:
在 DGC 这个类在调用静态变量的时候,就会完成类的初始化。
类的初始化是由 DGCImpl 这个类完成的,我们跟到 DGCImpl 中去看,发现里面有一个 static 方法,作用是 class initializer,也就是上图那边。
与前文类似,此处会创建Stub和Skel,我们重点关注DGCImpl_Stub:
到
DGCImpl_Stub
这个类下,它有两个方法,一个是 clean,另外一个是 dirty。clean 就是”强”清除内存,dirty 就是”弱”清除内存。
无论是clean还是dirty方法都可以反序列化,对应的Skel中也有(分case0和case1与之对应)。
小结一下 DGC 的过程
DGC是自动创建的一个过程,用于清理内存。
漏洞点在客户端与服务端都存在,存在于 Skel 与 Stub 当中,这也就是所谓的 JRMP 绕过。
7. 高版本绕过
这里先不讲(现在讲不合适),等到用到的时候再讲。
0x0x 参考教程
Java反序列化之RMI专题01-RMI基础 | Drunkbaby’s Blog