RMI 基础
RMI是什么
RMI (Remote Method Invocation) 远程方法调用,顾名思义,是一种调用远程位置的对象来执行方法的思想,类似于分布式RPC的思想。但两者间传送的数据不同。RPC打包和传送数据结构,而RMI是java世界的,通常传递一个完整的对象,该对象即包含数据,又包含操作数据的方法。因此,java中完整的在网络中向远程位置传输一个对象,依赖java原生反序列化,并结合一些机制(包括安全机制),如动态类加载(容错机制)和安全管理器(安全机制)来保证可容错且安全的传输一个java类。
具体的实现思想是获取远程主机上的引用,调用这个引用对象的方法,实际上是客户端通过网络通信将调用的所需的参数数据传输给远程,实际方法在远程执行然后返回结果。
为了屏蔽网络通信的复杂性,RMI引入的两个概念
- Stubs(客户端存根)
- Skeletons(服务端骨架)
当客户端Client试图调用一个远程对象时,实际调用的是从远程获取来的保存在客户端本地的一个代理对象Proxy,这个代理对象就是Stub,而调用远程目标对象的方法时,也会经过一个远程代理类,就是Skeleton。Stub与Skeleton直接通信,Stub传输远程方法调用信息(调用的方法、类、参数等),Skeleton接收并向上传递给实际的目标类进行方法调用。对于RMI服务的使用者来说,Stubs和Skeletons的通信调用是隐藏的,我们无需关注这两个类的api,但实际远程通信时时依赖的这两个类。
RMI远程调用的时序图如下:
RMI 怎么用
上面简单解释了RMI调用的核心细节,我们现在创建一个远程目标类。
- 定义一个远程调用的接口。该接口必须扩展
java.rmi.Remote
接口,且必须同时存在于Client和Server端
1 | public interface RemoteInterface extends Remote { |
- 创建远程接口的实现类。该类执行真正逻辑,通常需要继承
java.rmi.server.UnicastRemoteObject
,RMI会自动将目标类export(暴露在端口上),同时来封装了一些基础的equals/hashcode/toString
方法。实现类需要提供无参构造方法且抛出RemoteException
。如果不继承UnicastRemoteObject,则需要手动调用静态方法exportObject
来export对象。(?export哪个类的)
1 | public class RemoteObject extends UnicastRemoteObject implements RemoteInterface { |
可以被调用的远程对象创建好了,但此时,Client端并不知道Server端是否有这个对象(虽然远程调用的接口类ServiceInterface是同时存在于两端的)。所以,RMI设置了Registry来以注册-暴露的形式提供远程对象。过程大概类似于:一个 RMI 电话本,我们想在某个人那里获取信息时(Remote Method Invocation),我们在电话本上(Registry)通过这个人的名称 (Name)来找到这个人的电话号码(Reference),并通过这个号码找到这个人(Remote Object)
而实现该思路需要java.rmi.registry.Registry
和java.rmi.Naming
java.rmi.Naming
:是一个 final 类,提供了在远程对象注册表(Registry)中存储和获取远程对象引用的方法,这个类提供的每个方法都有一个 URL 格式的参数,格式如下://host:port/name
- host 表示注册表所在的主机
- port 表示注册表接受调用的端口号,默认为 1099
- name 表示一个注册 Remote Object 的引用的名称,不能是注册表中的一些关键字
比如rmi://127.0.0.1:1099/remote
等
Naming 提供了查询(lookup)、绑定(bind)、重新绑定(rebind)、接触绑定(unbind)、list(列表)用来对注册表进行操作。也就是说,Naming 是一个用来对注册表进行操作的类,实际是对注册表对象的操作进行的封装,这些方法的具体实现,其实是调用 LocateRegistry.getRegistry
方法获取了 Registry 接口的实现类,并调用其相关方法进行实现的
而java.rmi.registry.Registry
就是注册表(注册中心)本身,该接口有两实现类:RegistryImpl 和 RegistryImpl_Stub。创建一个注册中心的代码如下:
1 | public class Registry { |
Server向注册中心注册远程对象RemoteObject
1 | public class Server { |
至此,Client便可以进行RMI操作了,即调用远程对象方法
1 | public class RMIClient { |
通常情况下,Service和Registry是放在一起作为Server端的(但也有分开的情况,后面会说,即Service和Registry端对打),而Client作为一端,最后的目录结构如下
RMI特性
动态类加载
动态类加载解决的问题是:如果客户端在调用时,传递了一个可序列化对象,这个对象在服务端不存在,会抛出ClassNotFound 的异常,但RMI支持动态类加载,若设置了java.rmi.server.codebase
属性,则会尝试以该地址作为额外的classpath获取.class
并加载和反序列化,可以使用以下两者方式指定
System.setProperty("java.rmi.server.codebase", "http://127.0.0.1:7777/");
-Djava.rmi.server.codebase="http://127.0.0.1:9999/"
但该特性由于安全问题受到jdk版本的限制,最好在8u121以下版本测试。
由于是通过外部加载类并执行,因此为了保证安全,RMI中引用SecurityManager来限制动态类加载的行为,如果不设置SecurityManager,则RMI默认不会动态加载任何类,即报错
1 | com.diggid.rmi.RemoteInterface (no security manager: RMI class loader disabled) |
因此我们可以**需要动态类加载的一端(比如客户端传一个服务端不存在的参数对象给服务端,则服务端就需要设置)**设置以下内容
1 | System.setProperty("java.rmi.server.codebase", "http://127.0.0.1:7777/"); |
RMI 源码
服务创建
远程对象创建
我们创建了一个RemoteObject对象,该对象继承UnicastRemoteObject,在后面会使用JRMP协议export暴露远程对象,并获取与远程对象进行通信的Stub。
与创建对象相关的编写代码就一行:RemoteInterface remoteObject = new RemoteObject();
RemoteObject对象继承的UnicastRemoteObject
,所有远程对象创建并暴露的逻辑从该类的构造方法中开始
跟进exportObject
方法
先会创建sun.rmi.server.UnicastServerRef
对象,这个对象表示服务端远程对象的引用Reference
。初始化该对象的大部分工作实际是初始化LiveRef
类型的ref
成员变量。
继续跟进具体的exportObject方法的实现
实际上是调用UnicastServerRef#exportObject
来暴露我们的RemoteObject
,在ref
属性中也保存了RemoteRef
接口类型的实例,即UnicastServerRef
。
在exportObject方法中,先调用sun.rmi.server.Util#createProxy
来创建代理类。再创建Target
对象封装远程执行方法和动态代理类
先看一下Util#createProxy
。在调用这个方法前,首先会创建和服务引用UnicastServerRef对等的UnicastRef
,其中ref也保存了同样的LiveRef
,这是Client端的对象的Reference
创建RemoteObjectInvocationHandler
作为handler,其保存了UnicastRef
,用于后续具体的方法调用。然后创建Proxy,代理了Remote
和自定义的RemoteInterface
接口,作为Remote
类型返回。
回到exportObject方法中,这里将RemoteObject
、UnicastServerRef
(服务端引用)、Proxy
(本地存根Stub)封装在Target
对象中。其中Proxy就作为了我们前面所说的Stub
。
最后调用LiveRef#exportObject
方法来真正暴露Target对象:监听随机注册端口 + 注册实例到相关Map中。
具体实现是:调用 TCPTransport 的 exportObject 方法将 Target 实例注册到 ObjectTable 中。ObjectTable 用来管理所有发布的服务实例 Target,ObjectTable 提供了根据 ObjectEndpoint 和 Remote 实例两种方式查找 Target 的方法(不同参数的 getTarget 方法)。
至此,以上流程汇总成一张时序图如下
注册中心创建
1 | LocateRegistry.createRegistry(1099); |
首先注册中心实际是一个RegistryImpl
对象,该构造方法和远程对象的创建类似,也是先初始化一个LiveRef
用于建立TCPEndpoint,根据不同的构造方法来建立客户端或服务端的TCPEndpoint。也就是说,LiveRef是连接端点TCPEndpoint的封装,具体是监听的一方还是连接的一方,根据构造方法初始化相关参数来决定。
- 只有第一个构造方法中直接传入Endpoint,表示连接的一方(即客户端)
- 剩下的四个,都是类似的,ObjID是区别每个LiveRef的id(在后面区别是Registry还是DGC会提到),int类型的参数是需要本地监听的端口,其他剩下的参数是工厂类
所以在RegistryImpl即注册中心的初始化中,传入了RegistryImpl.id(常量0)
,var1
是要监听的端口
之后调用setup
方法,传入的对象是一个UnicastServerRef
,这里就和前面远程对象的创建流程很像了。
继续跟进UnicastServerRef#exportObject
方法,与之前不同的是,Util.creatProxy
方法中有一步判断:stubClassExists(var3)
,即当存在RemoteObject
存在_Stub
后缀的类时,直接初始化该类作为Stub,一般是服务端已经定义好的一些服务需要的Stub会存在。因此直接就实例化了RegistryImpl_Stub
RegistryImpl_Stub
实现了Registry, Remote
,继承RemoteStub
。同时实现了Registry
定义的注册中心的一些操作:bind/list/lookup/rebind/unbind
。这些方法都是基于序列化和反序列化的
创建完Stub之后,会调用serSkeleton
创建RegistryImpl_Skel
,该类被引用在UnicastServerRef的skel
变量中,作为服务端的Skeleton
。
该类提供了dispatch
方法来分发具体的操作,即调用RegistryImpl
的bind/rebind/unbind/list/lookup
而后面封装为Target
并调用LiveRef#exportObject
的流程和前面一样。总结一下:
- 远程服务对象使用动态代理作为Stub,invoke 方法最终调用 UnicastRef 的 invoke 方法。注册中心使用 RegistryImpl_Stub,同时还创建了 RegistryImpl_Skel,其中
dispatch
方法调度RegistryImpl
- 远程对象默认随机端口,注册中心默认是 1099(当然也可以指定)
服务注册(bind)
服务注册的过程对应的代码如下:
1 | registry = LocateRegistry.getRegistry(9999); |
从代码可以看出,这里主要讨论服务端和Registry端不再同一端的情况。如果在同一端,createRegistry
得到的是RegistryImpl
,那么直接调用其bind
方法往bindings
这个Hashtable变量中放String(名称)
和Remote
的键值对即可,没啥好说的。而getRegistry
得到的是RegistryImpl_Stub
,具体来看一下。
服务端
RegistryImpl_Stub
是Registry端在服务端本地的存根,根据此于Registry端通信。
如果 Server 端和 Registry 不同端,则在 Server 端或 Client 端使用 LocateRegistry.getRegistry()
方法获取注册中心时都是一样的流程:
- 首先在本地创建了一个包含了具体通信地址、端口的 RegistryImpl_Stub 对象
- 通过调用这个本地的 RegistryImpl_Stub 对象的 bind/list… 等方法,来与 Registry 端进行通信
- 而 RegistryImpl_Stub 的每个方法,都实际上调用了 UnicastRef 的 invoke 方法,进行了一次远程调用连接(invoke方法有两个重载,注意参数)
- 这个过程使用 java 原生序列化及反序列化来实现
看到bind方法,创建与远端的连接StreamRemoteCall,然后将参数序列化。
注意这里的序列化流程,StreamRemoteCall#getOutputStream
获取的是ConnectionOutputStream
,其继承了sun.rmi.server.MarshalOutputStream
调用的是writeObject序列化参数。MarshalOutputStream中定义了一个replaceObject
方法,在序列化时用于对Remote
实例替换为动态代理Stub,即
序列化对象后保存在ConnectionOutputStream
中
调用sun.rmi.server.UnicastRef#invoke(java.rmi.server.RemoteCall)
,实际是调用StreamRemoteCall#executeCall
来写出序列化数据并处理DGC
处理DGC时也用到的反序列化,这里后面攻击方式中展开。
Registry端
接下来看看Registry端做了什么。简单来说,Registry需要响应请求、发配请求、调用相应的方法(反序列化MarshalledObject)。首先接受TCP并处理的方法是sun.rmi.transport.tcp.TCPTransport#handleMessages
该方法从TCPConnection中获取java.io.DataInputStream
,即发现端发送过来的数据封装
进一步调用serviceCall
处理
调用getTarget
从ObjectTable
中获取Target
,再从Target中取出UnicastServerRef
然后调用之前说到的UnicastServerRef#dispatch
方法,这里判断this.skel
是否为空来区别自己是Registry端还是服务端的。因为后面服务端的方法调用也是通过UnicastServerRef#dispatch
方法来调用的,因此这里需要区别一下,如果this.skel为空,就会调用后面的invoke方法而不是这里的oldDispatch了。
该方法实际就是调用UnicastServerRef#skel
即RegistryImpl_skel
的dispatch
方法。这里也就可以体会到了是Client的Stub和Server的Skeleton直接通信的思想。
RegistryImpl_Skel 的 dispatch
方法根据流中写入的不同的操作类型分发给不同的方法处理,例如 0 代表着 bind 方法。bind的处理方式是:先反序列化Stub传递过来的参数,即String
名称和Remote
即RemoteObject。然后才调用实际的RegistryImpl#bind
方法来注册。
服务发现(lookup)
这里就是 Client 端向 Registry 端查询的过程了
1 | Registry registry = LocateRegistry.getRegistry("127.0.0.1", 9999); |
以Registry的角度来看,服务端的bind和Client端的lookup是对等的操作,因此调用逻辑流程在Registry端基本是一样的,只是最终调用的RegistryImpl
的方法不一样而已。
Client端
Client端获取Registry的流程和上面所述的服务端getRegistry是一样的,不再赘述。
其调用lookup的过程很简单,当获取到本地创建的RegistryImpl_Stub
后,直接调用其lookup
方法。将name序列化传送给Registry端,接受到Registry返回的结果之后再进行反序列化。
通信的参数传递和结果接受都以sun.rmi.transport.StreamRemoteCall
作为载体。通过UnicastRef#invoke -> StreamRemoteCall#executeCall
实现底层的DGC通信。这一部分在后面的攻击的攻击方式中再说
Registry端
Registry端的前半部分过程和bind操作一样,从RegistryImpl_Skel#dispatch
开始派发,这里lookup对应的常量是2。先反序列化Client端发来的参数,即service
字符串,然后调用RegistryImpl#lookup
。最后再writeObject将RemoteObject替换为RemoteObjectInvocationHandler
写出。
看一下调用RegistryImpl#lookup
方法
直观上看很简单,是bind操作的逆操作。从bindings中根据名称获取Remote实例。
这里多提一点,对于服务端和注册端是否在同一端的情况,在前面服务注册中已经体现了细微的差别,在这里的同样有类似的区别:
- 在同一端:lookup出来并返回的是
RemoteObject
,并没有封装代理。但lookup之后回到RegistryImpl_Skel#dispatch
中,writeObject还是会将其替换为动态代理类Stub的(这里想一想也可以知道,Client端只有持有对于RemoteInterface的动态代理,才能正常调用方法,同时进行通信,仅仅是RemoteObject肯定完成不了)
- 不在同一端:lookup出来并返回的是
RemoteObjectInvocationHandler
。就无需再进行替换了
服务调用
1 | System.out.println(remoteObject.sayHello()); |
Client端
前面的流程弄完之后,说明Client端已经持有Registry端传回的RemoteObjectInvocationHandler
了。再看一下通过该动态代理Stub具体的方法调用,直接看invoke方法
如果是Object对象的话,则调用invokeObjectMethod
方法,正常情况下调用invokeRemoteMethod
方法,实际是调用UnicastRef#invoke
方法,getMethodHash
从代理Stub的methodToHash_Maps中取出对应的方法
然后调用this.ref.getChannel().newConnection();
与UnicastServerRef连接,同时序列化参数、通过JRMP协议传输参数数据和远程调用信息。
此时切换到服务端,等待服务端调用方法并返回结果。得到服务端返回的序列化结果后,调用unmarshalValue方法来反序列化。至此,整个RMI流程就结束了。
服务端
这里直接和服务端UnicastServerRef
的进行通信。和前面bind/lookup的执行流程一样,再放一下调用链吧
1 | dispatch:324, UnicastServerRef (sun.rmi.server) |
UnicastServerRef#dispatch
方法,先从UnicastServerRef#hashToMethod_Map
中获取Client端要调用的RemoteObject的方法,这里是sayHello
(在服务创建时服务端初始化UnicastServerRef就保存了RemoteObject的所有方法)
如果有参数的话,还会调用UnicastServerRef#unmarshalParameters
来反序列化参数,底层是调用的unmarshalValue
方法
拿到方法和处理完参数之后,直接反射invoke调用。然后封装调用结果(写入一些其他的标志信息,同时序列化invoke返回的结果)
最后release出去,即将数据流写到TCP连接中传输回Client端。至此服务端的工作就完成了,可以回看Client端了
总结
RMI 底层通讯采用了Stub (运行在客户端) 和 Skeleton (运行在服务端) 机制,RMI 调用远程方法的大致如下:
- RMI 客户端在调用远程方法时会先创建 Stub (
sun.rmi.registry.RegistryImpl_Stub
)。 - Stub 会将 Remote 对象传递给远程引用层 (
java.rmi.server.RemoteRef
) 并创建java.rmi.server.RemoteCall
( 远程调用 )对象。 - RemoteCall 序列化 RMI 服务名称、Remote 对象。
- RMI 客户端的远程引用层传输 RemoteCall 序列化后的请求信息通过 Socket 连接的方式传输到 RMI 服务端的远程引用层。
- RMI服务端的远程引用层(
sun.rmi.server.UnicastServerRef
)收到请求会请求传递给 Skeleton (sun.rmi.registry.RegistryImpl_Skel#dispatch
)。 - Skeleton 调用 RemoteCall 反序列化 RMI 客户端传过来的序列化。
- Skeleton 处理客户端请求:bind、list、lookup、rebind、unbind,如果是 lookup 则查找 RMI 服务名绑定的接口对象,序列化该对象并通过 RemoteCall 传输到客户端。
- RMI 客户端反序列化服务端结果,获取远程对象的引用。
- RMI 客户端调用远程方法,RMI服务端反射调用RMI服务实现类的对应方法并序列化执行结果返回给客户端。
- RMI 客户端反序列化 RMI 远程方法调用结果。
特别需要注意的是,很明显的,RMI是完全基于反序列化的:
除了在本地就能完成的流程(服务创建),其余需要端与端通信的操作(如果服务注册时两端同端的话,则不需要),都进行的序列化/反序列化来传递参数或结果。
攻击 RMI - JEP290前
这里引用@su18师傅的铺垫解释
参与一次 RMI 调用的有三个角色,分别是 Server 端,Registry 端和 Client 端。严格意义上来讲,只有 Registry 端和使用 Registry 的端,因为 Registry 端只负责查询和传递引用,真正的方法调用是不需要经过 Registry 端的,只不过注册服务的我们称之为 Server 端,使用服务的我们称之为 Client 端。有一种我只负责帮你找到人,至于你找这个人做什么非法勾当我不管的感觉,不过为了更清晰的划分不同角色,我们还是将其分为三个角色,而通常情况下,Server 端和 Registry 端是同一端。
在三个角色中均进行了反序列化的操作。那也就说明针对三端都有攻击的可能。
攻击Server端
恶意服务参数
前面提到了:在 Client 端获取到 Server 端创建的 Stub(RemoteObjectInvocationHandler)后,会在本地调用这个 Stub 并传递参数,Stub 会序列化这个参数,并传递给 Server 端,Server 端会反序列化 Client 端传入的参数并进行调用。这里对应的是上面服务调用Client->Server的流程。
我们进阶的来看一下
- 当Client端和Server端的远程调用接口RemoteInterface都存在参数为Object类型的方法时
在这种情况下,当Client端传输一个恶意的Object作为参数,这里以CC6的链子为例,直接弹出计算机。
如果参数不是Object类型,但可以封装我们的恶意对象到参数类中,那么也是可以触发的,比如把Object参数类型换成HashMap,或者具有Object类型成员变量的Xxx类,再把恶意对象封装进去即可,最终还是会反序列化出来的。
- 当Client端和Server端远程调用方法的签名不一致时(即Server端指定类型)
有这种想法的原因是:如果远程调用方法不能封装我们的恶意对象,那么就无法正常触发攻击了。那么尝试一下修改Client端的参数类型为Object,Server端不管。这样还能不能触发成功呢?
直接触发是不行的,会报错:java.rmi.UnmarshalException: unrecognized method hash: method not supported by remote object。
从前面的分析也可以知道,在UnicastServerRef#dispatch
方法中,Server端会根据Client端传递过来的方法信息在UnicastServerRef#hashToMethod_Map
查找对应的方法。这个 hash 实际上是一个基于方法签名的 SHA1 hash 值。
但这其中其实有逻辑的漏洞点:限制了两端方法方法签名必须一致,但限制了方法参数和实际参数必须一致吗?
显然没有,RMI在整个参数处理对于序列化得来的参数都是单独的进行的反序列化处理。因此有这么一种情况:不改变方法签名(即保持两端方法一样),而传递的参数从HelloObject变为恶意对象。
据此,在 mogwailabs 的 [PPT](https://github.com/mogwailabs/rmi-deserialization/blob/master/BSides Exploiting RMI Services.pdf) 中提出了以下 4 种方法
- 通过网络代理,在流量层修改数据。@0c0c0f师傅在这篇文章里使用了。
- 自定义 “java.rmi” 包的代码,自行实现
- hook并使用字节码修改。@Afant1师傅在这篇文章里使用了。
- 使用 debugger(即IDEA来修改),测试的方便用。其实debugger底层就是使用了第三种方式hook+javaagent。
此外,还有其他讨论的攻击方式:
- 替身攻击
大体的思路就是调用的方法参数是
ServerObject
,而攻击者希望使用 CC 链来反序列化,比如使用了一个入口点为 HashMap 的 POC,那么攻击者在本地的环境中将 HashMap 重写,让 HashMap 继承 HelloObject,然后实现反序列化漏洞攻击的逻辑,用来欺骗 RMI 的校验机制。
为了方便测试,我们使用IDEA debugger修改参数的方式。很显然,我们的Hook点是RemoteObjectInvocationHandler#invokeRemoteMethod
。大致过程是,先调用恶意的Method,传递我们的恶意参数对象,再在invokeRemoteMethod方法中将恶意Method修改为两端都有的正常Method(即HelloObject作为参数的)
可以成功弹出计算机,将上述流程使用javaAgent的方式去实现也十分方便。
其实仔细看一下反序列化的逻辑sun.rmi.server.UnicastRef#unmarshalValue
,可以得到如下结论:
只要Server 端的调用方法存在非基础类型(String不算)的参数时,就可以被恶意 Client 端传入恶意数据流触发反序列化漏洞。
绕过制定类型的思路问题在:如何欺骗Server端指定类型的签名校验同时又能传递Object类型恶意反序列化Gadgets。解决的层面:(运行时传输前)修改/伪造方法(java运行时层面)、(传输后)通过代理网络在流量层修改、(运行前载入时)替身攻击
动态类加载
前面我们提到过RMI的一大特性之一就是动态类加载机制。简单来说就是当本地ClassPath中无法找到相应的类时,会在制定的codebase里加载class。在6u45/7u21之前都是默认开启的。
这里先说几点结论:
- 动态类加载机制不区分角色,在三端中都可以触发
- 需要在触发动态类加载机制的一端设置
SecurityManager
,否则会报错。前面也提到了 - 需要在触态类加载机制的一端设置
java.rmi.server.useCodebaseOnly=false
,该方法用于限制该机制是否允许远程加载。 - 设置
java.rmi.server.codebase
为目标类远程地址,不是在触发端设置的,触发端并不知道地址,而是Client端提供的 - 恶意代码需要放在恶意类的static块或readObject中(回顾原生反序列化)
重点关注一下第二点和第四点,容易产生误区。带着以上结论,跟一下源码。
这里先准备一个恶意的Object,恶意代码只能放在static块中,因为后续触发Class.forName类初始化只会执行static中的代码。
1 | // Evil.java |
在Server端,调用UnicastServerRef#dispatch
方法处理参数和invoke目标方法,unmarshalParameters
方法反序列化Client端传递过来的参数,跟进看一下
调用unmarshalValue
,接着调用ObjectInput#readObject
,实际是ConnectionInputStream
的。反序列化过程会调用自定义类的resolveClass
方法,调用栈如下
1 | resolveClass:189, MarshalInputStream (sun.rmi.server) |
整个过程如下,简单来说就是java原生反序列化,中间插件式的加上了远程加载类的流程。所以远程加载到的类还是会经过原生反序列化的流程,即调用readObject
- 调用
MarshalInputStream#readLocation
来读取java.rmi.server.codebase
设置的地址。对应Client端写入地址的方法是writeLocation
- 判断
MarshalInputStream#useCodebaseOnly
成员变量是否为false。所以我们要在Server端设置java.rmi.server.useCodebaseOnly=false
- 调用
java.rmi.server.RMIClassLoader#loadClass
来远程加载类
这里使用的是SPI机制通过provider来加载类,provider的获取方式如下:
通过java.rmi.server.RMIClassLoaderSpi
配置provider类名,如果classpath下存在实现了RMIClassLoaderSpi
接口的provider的话,就加载自定义的provider。否则就加载默认的defaultProvider。
我们这里没配置啥,默认是default,因此直接跟进java.rmi.server.RMIClassLoaderSpi#loadClass
方法,其委托sun.rmi.server.LoaderHandler#loadClass
来处理,没有自定义的ClassLoader,则调用双参数的loadClass
获取SecurityManager,如果为空的话,意味着直接就尝试本地加载,本地加载不成功就会报错,这个报错在上面也出现过了。
这里SM不为空,看接下来的逻辑,获取sun.rmi.server.LoaderHandler$Loader
。这个内部类继承了java.net.URLClassLoader
,其loadClass
方法就是简单的调用父类的loadClass方法
首先检验permissions
最少需要("java.net.SocketPermission" "127.0.0.1:7777" "connect,resolve")
的权限。
然后调用loadClassForName
来进行远程类加载,注意这里的Class.forName
第二个参数是false,意味着只进行类装载而不进行类初始化。所以到这里一步是能够从远程加载Evil类,但还未执行代码。然后一直返回到ObjectInputStream#readNonProxyDesc
方法中,后面就是正常的反序列化流程了,会调用恶意类的readObject方法。
攻击Registry端
Registry端的攻击主要集中在sun.rmi.registry.RegistryImpl_Skel#dispatch
,即Registry的bind/rebind/unbind
和lookup
这几个操作的反序列化问题。
- bind/rebind
- unbind
- lookup
在不讨论版本带来的安全机制的前提下,理论上当Client端和Server端调用上述4个操作向Registry端传输恶意对象参数时,Registry都会执行反序列化操作。
但是在攻击端,lookup
、unbind
的参数类型都是String,这在运行前编写代码时限制了我们传入恶意Object。解决的思路和前面利用JavaAgent修改方法签名攻击Server端类似,我们可以在运行时修改参数为恶意的Object。Hook点在 RegistryImpl_Stub
的lookup/unbind方法。这个后面集中来说。
这里简单测试一下bind/rebind的情况,这两个方法的第二个参数是Remote类型的。但是在代码层面上还是有限制,前面讨论了远程对象创建将RemoteObject
、UnicastServerRef
(服务端引用)、Proxy
(本地存根Stub)封装在Target
对象中,而RemoteObject
里面才是我们的恶意对象。服务端RegistryImpl_Stub#bind
时会对序列化数据进行replaceObject
处理,前面也提到了。因此我们要绕过这一层面的处理。
这里提供了三种思路:
- 自定义一个类,实现Remote和Serializable接口但不继承UnicastRemoteObject。
如果从ObjectTable
中拿出来的Target是null的话,就不会进行替换了。而OjectTable#putTarget是在远程对象创建时的发生的(即调用了exportObject)。因此这里我们直接自定义一个恶意的RemoteObject,其实现Remote和Serializable接口但不继承UnicastRemoteObject,就不会触发UnicastRemoteObject#exportObject
了。该类的成员变量object保存我们的恶意类即可。熟悉原生反序列化的话,会知道,即使顶层的类找不到,整个readObject流程不会中断,而是会继续完成,具体可以看ObjectInputStream#readOrdinaryObject
中readClassDesc
和handles.markException
两处。
恶意类的代码如下:
1 | import java.io.Serializable; |
- 使用已有的Gadgets。需要实现Remote接口且有带Object类型的成员
这个是 ysoserial.exploit.RMIRegistryExploit
的方式,创建一个代理Remote接口的代理,用AnnotationInvocationHandler的memberValues变量来持有恶意对象,也满足条件。为什么要用代理?因为在所有继承了java.rmi.Remote
接口的类中,找不到带Object类型成员的类。由于没有继承UnicastRemoteObject啥的,所以在ObjectTable中也就不存在了,自然获取时就是null。
1 | Class c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); |
注意使用AnnotationInvocationHandler并没有像CC1反序列化的jdk限制,要区别开来。因为这个Gadgets调用的起点是CC6,而AnnotationInvocationHandler只是起到封装CC6恶意对象HashSet的作用,反序列化是从
HashSet#readObject
开始的,并不是像CC1一样需要其成员变量memberValues来连接链子。
- 简单粗暴直接把
ObjectOutputStream#enableReplace
变量改为false
这个方式一直没想到,因为一直惯性思维认为一定会进入到replaceObject
,所以一直在想怎么绕过这玩意(见下面两个方法)。在RegistryImpl_Stub#bind
方法中,我们调用call.getOutputStream();
初始化我们的ConnectionOutputStream
,正是在初始化的过程中设置了enableReplace为true。所以我们可以自实现bind的过程,同时反射修改enableReplace为false。这样子我们的恶意类就可以继承UnicastRemoteObject而不用担心会被替换了。代码如下:
1 | private static void bind(String name, Remote remote, RegistryImpl_Stub registry) throws Exception{ |
- 让
Target#getStub
返回恶意的RemoteObject
前两个方法由于没有继承UnicastRemoteObject导致没有exportObject会破坏对象暴露的逻辑,而第3钟方法并不会。这里要说的第4种方法也不会,这种方法的恶意对象无需继承UnicastRemoteObject,但是需要修改暴露逻辑。我们可以在创建Target的时候做点手脚。不按照正常的exportObject流程来创建
- 正常的创建
1 | new Target(Remote, UnicastServerRef, Stub, this.ref.getObjID(), var3); |
- 恶意的创建
1 | new Target(Remote, UnicastServerRef, 恶意RemoteObject, this.ref.getObjID(), var3); |
即将stub改为恶意RemoteObject即可,这样getStub就能顺利取出来了。代码如下
1 | public class EvilServer4 { |
攻击Client端
攻击Client端,如果LocateRegistry.getRegistry
方法的地址可控。那么意味着可以连接到恶意的Registry端,也就能获取到恶意的Stub或者和恶意的服务端交互。Client端的交互行为包括:
- 从 Registry 端获取调用服务的 stub 并反序列化
- 调用服务后获取结果并反序列化
恶意Server Stub
Registry端在注册中心注册了恶意的Stub。当Client端调用lookup进行服务发现时,会拿到Server端在Registry端注册的恶意代理对象并进行反序列化。但这里会遇到几个问题:
- 和攻击Registry端时的Server端一样,replaceObject问题也会出现在Registry端向Client端writeObject中
- 远程对象创建并暴露的问题。问题1在攻击Registry端很好解决,直接不考虑对象暴露,自定义合法的恶意RemoteObject即可。而这里必须要export恶意对象,否则Client端就lookup不到了,但正常的
UnicastRemoteObject#exportObject
不行。
根据前面攻击Registry端绕过恶意对象替换的4种思路,也提到了只有3、4能够满足条件:绕过replaceObject + 暴露对象。但实际上4方法比较方便。因为3方法的局限性在于,我们修改ConnectionOutputStream,是绑定在每次通信当中的,上面的通信过程是Server端 -> Registry端,在Server端劫持bind方法自定义逻辑即可。而这次的通信过程是Registry端 -> Client端,需要自定义的是RegistryImpl_Skel#dispatch(因为writeObject在此触发),因此不是很方便。用4方法的话就比较方便。
恶意Server端返回值
思路同攻击 Server 端的恶意服务参数,只不过关注点从Server端方法的参数变成了Client端方法的返回值
动态类加载
思路同攻击 Server 端的动态类加载。注意角色调换和配置项设置。
攻击DGC
DGC介绍
DGC(Distributed Garbage Collection):分布式垃圾回收,当 Server 端返回一个对象到 Client 端(远程方法的调用方)时,其跟踪远程对象在 Client 端中的使用。当再没有更多的对 Client 远程对象的引用时,或者如果引用的“租借”过期并且没有更新,服务器将垃圾回收远程对象。启动一个 RMI 服务,就会伴随着 DGC 服务端的启动。
RMI 定义了一个 java.rmi.dgc.DGC
接口,提供了两个方法 dirty
和 clean
:
- 客户端想要使用服务端上的远程引用,使用
dirty
方法来注册一个。同时这还跟租房子一样,过段时间继续用的话还要再调用一次来续租。 - 客户端不使用的时候,需要调用
clean
方法来清楚这个远程引用。
这个接口有两个实现类,分别是 sun.rmi.transport.DGCImpl
以及 sun.rmi.transport.DGCImpl_Stub
,同时还定义了 sun.rmi.transport.DGCImpl_Skel
。
很像 Registry、RegistryImpl、RegistryImpl_Stub、RegistryImpl_Skel,实际上不单是命名相近,处理逻辑也是类似的。通过在服务端和客户端之间传递引用,依旧是 Stub 与 Skel 之间的通信模式:Server 端启动 DGCImpl,在 Registry 端注册 DGCImpl_Stub ,Client 端获取到 DGCImpl_Stub,通过其与 Server 端通信,Server 端使用 RegistryImpl_Skel 来处理。
实际初始化创建DGC的代码发生在Registry端创建createRegistry时,调用栈如下:
1 | static块 |
具体原因是在导出RegistryImpl
对象的时候,会传入permanent
为true
到exportObject
方法中,该参数选项表示是否标记一个暴露(导出)的对象(Target)为持久化的(由DGC跟踪并处理),如果为true,则在new Target
中会触发pinImpl
方法,从而在sun.rmi.transport.WeakRef#pin
引用了DGCImpl.dgcLog
,当一个类的静态成员被引用时,会触发static快。
static块中创建了DGCImpl和封装Target并存入ObjectTable中,几乎和Registry端的RegistryImpl的创建流程一样。这里同样监听的是随机端口(LiveRef参数为0)
DGC 通信的处理逻辑在服务端是 DGCImpl_Skel 的 dispatch 方法,依旧通过 Java 原生的序列化和反序列化来处理对象。而客户端使用的DGCImpl_Stub则负责处理dirty和clean。
DGC客户端打DGC服务端
从上面的介绍中可以看出,DGC也是基于反序列化的,因此我们只需要构造一个DGC通信并在指定的位置写入序列化恶意类,经由DGC传输到DGC服务端,从而触发反序列化。
同时,由于 DGC 通信和 RMI 通信在 Transport 层是同样的处理逻辑,只不过根据 Client 端写入的标记ObjID(在初始化LiveRef的时候创建,DGC的ObjID是特定的new ObjID(2);
)来区分是是由 RegistryImpl_Skel 还是 DGCImpl_Skel 来处理
所以我们可以扩大攻击面,使用 DGC 来攻击任意一个由 JRMP 协议监听的端口,即通过伪造传输伪造的ObjID为dgcID,从而进入DGCImpl_Skel的处理流程,然后触发反序列化。任何初始化LiveRef
的操作都会暴露一个JRMP协议的端口,针对服务端(一般来讲这里的Server端和Registry端是一起的,因为利用需要DGCImpl_Skel,这个是在Registry端创建的),包括:Registry 端监听端口、RegistryImpl_Stub 监听端口(Server端)、DGCImpl_Stub 监听端口。
由于后两者的端口号是随机的,因此通常使用 DGC 层来攻击 Registry 端。这个攻击手段实际上就是 ysoserial 中的 ysoserial.exploit.JRMPClient 的实现原理。
结合下面的注释分析一下payload。如果熟悉上面的流程的话会发现十分简单
1 | public static void makeDGCCall ( String hostname, int port, Object payloadObject ) throws IOException, UnknownHostException, SocketException { |
这里起一个服务端,然后拿ysoserial配合CC6的链子打一下看看。成功弹出计算器,这里把dirty换成clean也是可以的,即换成0
有意思的是,这里我起的是前面说到的攻击Client端的恶意Server,这里却被恶意的JRMPClient给打了。所以对于RMI来说,相互对打是很常见的。因此打的时候也要注意防止反制,比如设置SecurityManager等等。
UnicastRemoteObject
java.rmi.server.UnicastRemoteObject
类通常是远程调用接口实现类的父类,该类可以作为一个反序列化的RMI原生Gadgets,其作用是可以开启JRMP监听端口(调用new LiveRef
),配合DGC客户端打DGC服务端
看一下该类的readObject方法
跟进reexport发现,直接执行的exportObject(后面就是创建LiveRef暴露的逻辑)并且暴露的端口号是可控的成员变量。因此我们可以实现暴露任意可控端口的JRMP服务。
这一部分对应的实现就是yso中的ysoserial.payloads.JRMPListener
1 | public UnicastRemoteObject getObject ( final String command ) throws Exception { |
但yso中使用的是UnicastRemoteObject的子类ActivationGroupImpl作为实例,其实也可以直接使用Unsafe自建一个
1 | public UnicastRemoteObject getObject ( final String command ) throws Exception { |
JEP290
在下面介绍的利用JRMP协议(主要是打Registry端) or 前面说的恶意服务参数打Server端/恶意返回值打Client端,都可以绕过JEP290。在了解绕过之前,先来看看JEP290的基础内容,并重点针对RMI利用JEP290机制缓解反序列化问题进行分析。主要参考:https://paper.seebug.org/1689/#_7
简单介绍:
JEP 290 在 JDK 9 中加入,并在低版本的6u141/7u131/8u121中添加
- Provide a flexible mechanism to narrow the classes that can be deserialized from any class available to an application down to a context-appropriate set of classes:提供了一个灵活的机制,将可以反序列化的类从应用程序类缩小到适合上下文的类集(也就是说提供一个限制反序列化的类的机制,黑白名单方式)
- Provide metrics to the filter for graph size and complexity during deserialization to validate normal graph behaviors.(限制反序列化深度和复杂度)
- Provide a mechanism for RMI-exported objects to validate the classes expected in invocations:为 RMI 导出的对象设置了验证机制。( 比如对于 RegistryImpl , DGCImpl 类内置了默认的白名单过滤)
- The filter mechanism must not require subclassing or modification to existing subclasses of ObjectInputStream.
- Define a global filter that can be configured by properties or a configuration file.(提供一个全局过滤器,可以从属性或者配置文件中配置)
JEP290 基础
首先看看JEP290几个核心类: ObjectInputStream
类,ObjectInputFilter
接口,Config
静态类以及 Global
静态类。其中 Config
类是 ObjectInputFilter
接口的内部类,Global
类又是Config
类的内部类。
ObjectInputStream类
- 构造方法
该类的两个构造方法都会主动设置serialFilter字段,是从下面的ObjectInputFilter$Config类中的serialFilter
属性拿的。在拿的过程中,会初始化Config类,下面会说。
serialFilter
属性
该属性是ObjectInputFilter
接口类型,是所有继承ObjectInputStream的子类中过滤器字段,该字段配合readObject过程的filterCheck
方法来进行反序列化的限制
filterCheck
方法
反序列化readObject方法,所有InputStream都会调用父类ObjectInputStream#filterCheck方法来进行检测,该方法中,当serialFilter属性非空时,会调用该函数式接口类型属性的checkInput方法,返回值为ObjectInputFilter.Status
类型,如果是null或REJECTED
,就抛异常
ObjectInputFilter接口
该接口是一个函数式接口,有一个 checkInput
函数,一个静态类 Config
,一个 FilterInfo
接口,一个 Status
枚举类。
该对于该接口类型的参数可以通过两种方式进行传递:
- lambda表达式:
1 | ObjectInputFilter filter = (ObjectInputFilter.FilterInfo var1) -> { |
- 函数引用:把相同方法签名和返回值类型(方法名可以不一致)作为函数引用
ObjectInputFilter$Config静态类
由于是一个静态类,所以在被引用的时候,会触发static块和所有static字段的初始化
configuredFilter
属性
会拿到 jdk.serailFilter
属性值,如果不为空,会返回 createFilter(var0)
的结果(createFilter
实际返回的是一个 Global
对象)。jdk.serailFilter
属性值获取的方法用两种,第一种是获取 JVM 的 jdk.serialFilter
属性,第二种通过在 %JAVA_HOME%\conf\security\java.security
文件中指定 jdk.serialFilter
来设置。优先选择第一种。
createFilter
方法
会进一步调用 Global.createFilter
方法,将传入的 JEP 290 规则字符串解析到Global
对象的 filters
字段上,并且返回这个 Global
对象。
- static块
Config 类的静态块,会赋值 Config.configuredFilter
到 Config.serialFilter
上。
Config$Global静态类
该类实现了ObjectInputFilter
接口,是一个可变的全局过滤器。结合前面Config类的createFilter。
filters
字段
list类型,保存所有的ObjectInputFilter。
createFilter
方法和构造方法
构造方法解析相关配置的JEP290规则语法,以;
分割将其作为一个个规则。
具体的规则如下,其实很简单,类似于SpringBoot中的路径匹配规则
checkInput
方法
该方法就是ObjectInputFilter
函数式接口的标准实现,对于待检测的类,便利filters字段的规则进行检测。这个stream流进行map、filter和findFirst操作,取出不是UNDECIDED
状态的Status,如果不为空,则返回该Status,如果为空,则返回UNDECIDED。
过滤器
配置过滤器其实就是设置 ObjectInputStream
类中的 serialFilter
属性
- 第一种是通过配置文件或者
JVM
属性来配置的全局过滤器,比如前面说的Global类、Weblogic中的 - 第二种则是来通过改变
ObjectInputStream
的serialFilter
属性来配置的局部过滤器,比如RMI的局部过滤器实现
全局过滤器
设置全局过滤器,其实就是设置
Config
静态类的serialFilter
静态字段值。即在初始化ObjectInputStream时,都会为serialFilter
属性调用ObjectInputFilter.Config.getSerialFilter()
来赋值
前面也说到了,对于Config类serialFilter
字段的初始化,在原生jdk中,是通过JVM或者%JAVA_HOME%\conf\security\java.security
的jdk.serailFilter
属性来设置了,默认是JVM的优先。
对于其他的组件,可能会设置自己的全局过滤器,反正最终都是作用在Config
类的serialFilter
字段上。
局部过滤器
设置局部过滤器,是针对不同的ObjectInputStream子类实例的
serialFilter
进行设置。也就说说,当filterCheck的时候,使用的serialFilter
是子类的,而不是父类的(父类的就是Config#serialFilter)
局部过滤器的优先级高于全局过滤器的优先级,即在一次readObject方法中,调用filterCheck时使用的是局部过滤器(有的话会覆盖了全局过滤器)
提供了两个API来设置单个 ObjectInputStream
对象的 serialFilter
字段
ObjectInputStream#setInternalObjectInputFilter(ObjectInputFilter filter)
Config#setObjectInputFilter(ObjectInputStream var0, ObjectInputFilter var1)
这个是委托了一个辅助类sun.misc.JavaOISAccess
的setObjectInputFilter
来设置,在ObjectInputStream中定义了一个匿名的JavaOISAccess实例
RMI
有了前面的基础,在这里就可以来解释RMI利用JEP290机制来对抗反序列化的具体实现了。总结来说,JEP290在RMI的实现,主要针对Registry端和DGC服务端。
UnicastServerRef导出过程
在前面介绍的注册中心创建的过程中,对于我们的RegistryImpl,其会封装为一个Target,这个Target加上JEP290之后的表现如下:
- ObjID是固定的
ObjID.REGISTRY_ID
,即0 disp
字段为UnicastServerRef
,filter为RegisryImpl::RegistryFilter
,skel为RegsitryImpl_skel
,是Registry端暴露的骨架Skeletonstub
字段为RegistryImpl_stub
,是Registry端的Stub
同理,对于DGC服务端也是一样,把ObjID换为2,把Rgistry
替换为DGC
即可。这里就以Rgistry端为例。在注册中心创建的过程中,会初始化UnicastServerRef
,JEP290后,默认使用的构造方法会传入RegisryImpl::registryFilter
作为filter。这个filter在后续获取反序列化流ConnectionInputStream时,会设置到其serialFilter中。
Registry层面
TCPTransport#handleMessages
到UnicastServerRef#oldDispatch
,其中this.unmarshalCustomCallData(var6);
会往ConnectionInputStream的serialFilter局部过滤器中设置UnicastServerRef#filter
(RegisryImpl::RegistryFilter)。
因此在后续反序列化的流程中,就会受到该RegisryImpl::registryFilter这个过滤器的限制
是一个基于白名单的限制,满足一下类型,才不会被REJECTED
DGC层面
- DGC服务端
同上,DGC服务端的局部过滤器为lambda表达式:
1 | final UnicastServerRef var4 = new UnicastServerRef(var3, (var0) -> { |
也是基于白名单。
- DGC客户端(DGCImpl_Stub)
在dirty方法中,由于要接受DGCImpl_Skel返回的Lease,所以在反序列化前设置了局部过滤器DGCImpl_Stub::leaseFilter
那为什么不在RegistryImpl_Stub的相关方法中也和DGC一样设置类似的局部过滤器呢?
这主要和JEP290的目标有关,前面提到了JEP290主要是为了保护Registry层(服务端),而RegistryImpl_Stub是提供给其他端的,在Registry端并不会获取并执行操作,所以没啥威胁(如果有,可能是另一个0day)。但Registry端可能成为DGC的客户端,从后面绕过JEP290的分析中也可以看出来,比如Server端注册服务到Registry端,此时Server端就是DGC服务端,Registry端是DGC客户端(Server端需要监控注册的远程对象的情况),所以RegistryImpl_Skel中会调用到DGCImpl_Stub
的方法,因此需要对DGCImpl_Stub保护。
通过JVM和配置文件配置
除了以上需要通过RegistryImpl/DGCImpl初始化来设置serialFilter的方法外,还可以使用类似前面的通过设置JVM和java.security属性的方式来设置过滤器。相关属性分别是
- sun.rmi.registry.registryFilter
- sun.rmi.transport.dgcFilter
具体在RegistryImpl#initRegistryFilter
和DGCImpl#initDgcFilter
方法中,这里就说明前者即可,后者一样,本身registryFilter
静态字段在初始化一开始是有值的。initRegistryFilter看着和前面jdk serialFilter的全局过滤器的设置很类似吧。
在对应的过滤器方法中,是以外部配置设置的优先
JEP290的局限性
首先这里不提具体的做法,只提一下大概的思路,具体的做法在下面的小节中。
对于RMI的JEP290,其局限性有:
- 只对Registry端和DGC层做了校验,Server端和Client端的问题没有解决(所以后面的绕过JEP290是针对Registry和DGC的,Server端和Client端的问题还在)
- 对JRMP层面通信的反序列化处理没有限制,即
StreamRemoteCall
的这个JRMP底层通信对象并没有所谓的过滤器字段(JRMP绕过),因此我们能够利用该类的executeCall
方法,只不过需要向上回溯总结一下何处会调用executeCall方法。
bind的局限性
前面一直没提到,Registry端是会对Server端进行IP校验的(默认只信任本机地址)。通过RegistryImpl.checkAccess("Registry.bind");
方法来校验
这个RegistryImpl#checkAccess
方法对bind的影响的版本分界线是8u141:
- 8u141前:checkAccess方法放在
RegistryImpl_Stub#bind/rebind
方法中,在RegistryImpl_Skel的反序列化之后,因此没有影响 - 8u141及之后:checkAccess方法放在
RegistryImpl_Skel#dispatch
的bind/rebind的反序列化操作之前,因此有影响
因此在8u141及其后,对于Registry端的攻击利用就转移到了lookup方式的利用上,即Client端打Registry端。但是实际上,对于JEP290出来在8u121后已经起到了比较好的限制作用。但是后面的介绍中提到了JEP290的绕过,因此这里的IP限制还是有点用处的。
原生Gadgets + JRMP绕过JEP290
根据JEP290的防护机制,其默认只为RMI注册表(RMI Register层)和RMI分布式垃圾收集器(DGC层)提供了相应的内置过滤器。对于Client层、两端分离的Server层、更底层的JRMP协议层是没有做过滤器的。具体来看一下绕过原理(ps:需要熟知前面的流程)
首先我们将JRMP分为客户端和服务端,加上RMI的三端,这里有5个角色,注意区分。
JRMP服务端打JRMP客户端
这部分是ysoserial.exploit.JRMPListener的攻击逻辑。这一层是完全的JRMP层的反序列化,对于上层的DGC/RMI存在的JEP290的限制,能够绕过。
这种攻击方式也是基于RMI原生Gadgets的,先贴一下攻击过程和调用链:
- 使用ysoserial.exploit.JRMPListener开启一个恶意的JRMP服务。
- 使用一个受害的JRMPClient去连接JRMPListener,从而触发
1 | public class JRMPClient { |
调用栈
1 | executeCall:245, StreamRemoteCall (sun.rmi.transport) |
直接看一下StreamRemoteCall#executeCall
方法
可以看到出发点是在case 2的情况下,直接进行了反序列化。那这个反序列化的内容是什么呢?在服务端是何时发生序列化写入该内容的呢?
case 2的情况其实对应的是TCP传输的returnType为2,即TransportConstants.ExceptionalReturn
。所以我们只需要全局找一下哪里写入了这个常量
在服务端全局可以找到3处地方
sun.rmi.server.UnicastServerRef#dispatch
sun.rmi.server.UnicastServerRef#oldDispatch
sun.rmi.transport.StreamRemoteCall#getResultStream
根据服务端从handleMessages下来的调用链,在sun.rmi.transport.Transport#serviceCall
处调用了getResultStream
收缩起来的地方是我们之前说过的RegistryImpl_Skel
或DGCImpl_Skel
的dispatch,这里我们重点看出现异常的地方,可以看到,调用getResultStream
写入异常标志2,同时序列化异常var2返回给客户端。
因此在yso的ysoserial.exploit.JRMPListener中自实现了这个主动抛出异常并序列化异常序列号2和恶意类的流程,下面只是处理TCP流程的一部分,其他JRMP服务端的处理流程可以自行看看。
这里说几个可以会有疑惑的点:
- 上面的流程虽然只演示了lookup的情况,但实际上bind/unbind/rebind/list都可以,这与非Registry端的操作无关,只需要能够让受害端主动与恶意JRMP服务端建立起JRMP连接即可,简言之就是出现了
StreamRemoteCall#executeCall
。所以在后面也会说到Registry端也可以是受害者,但和Client、Server端这些能够主动发起的利用方式不太一样。
Server端打Registry端 - 结合bind
我们需要找到一个能让Registry端主动发出JRMP请求调用的Gadgets。理想的攻击方式如下:
- 要RMI注册端作为JRMP客户端去主动连接我们的JRMP服务端(白名单过滤器只对反序列化过程有效,对序列化过程无效)
- 我们恶意的JRMP服务端在原本是报错信息的位置写入利用链,序列化成数据包返回到JRMP客户端(RMI注册端)。
- 由于JRMP客户端的反序列化过程不存在JEP290的过滤器,所以我们的payload可以成功被执行,从而完成RCE。
步骤1是我们这里要说的,步骤2、3是前面说的。
UnicastRef
Server端得天独厚的优势就是bind操作,我们可以往Registry端bind一个在JEP290白名单之内的类,在Registry端bind的过程中可以指定端口去连接我们的JRMP服务端。UnicastRef#readExternal
加上Registry_Skel
的bind过程可以完成上述要求。具体看一下
直接回顾RegistryImpl_Skel#dispatch
看一下bind操作
这里我们已经很熟悉了。我们再仔细一点,把这个过程拆成两部分来看
var8 = (Remote)var11.readObject();
:组装UnicastRef,即LiveRef创建连接体var2.releaseInputStream();
:处理UnicastRef,即发出DGC请求
这里先放一下调用栈
1 | readExternal:493, UnicastRef (sun.rmi.server) |
可以看到上述流程先会跟到UnicastRef#readExternal
中,我们正向来看一下
可以看到这里有一个非常敏感的对象LiveRef
,是创建连接体的关键
可以看到,new LiveRef()
创建了一个JRMP连接(只是创建,并没有实际发出连接),这里我们可以在序列化时指定TCPEndpoint来指定host和port
1 | String host = "127.0.0.1"; |
然后这里有一个关键的判断,如果var0是ConnectionInputStream的话,会进入到第一个分支,而第二个分支的操作后续也会调用到,是在releaseOutputStream
中。但是假设我们这里的场景是一个原生的反序列化,反序列化的是UnicastRef,也会调用到这里,且因为var0是ObjectInputStream,那么就会进入第二个分支直接调用registerRefs
。这是很关键的,因为我们后续触发JRMP连接是通过registerRefs往下调用到makeDirtyCall
发出的。说了这么多,想解释的就是原生反序列化UnicastRef也能直接发出JRMP连接实现效果,而不需要这里的两个流程串起来(往后看就明白了)。而原生反序列化UnicastRef就是ysoserial.payloads.JRMPClient做的
然后调用saveRef(ref)
将LiveRef填入到incomingRefTable
字段中,这里是关键地方,要记一下(和后面的修复有关)
然后readobject执行完了,就跟进到第二个关键部分releaseOutputStream
会调用sun.rmi.transport.ConnectionInputStream#registerRefs
看统一处理ref
继续跟进sun.rmi.transport.DGCClient#registerRefs
可以看到这里一个DGC客户端调用的方法,且注意到这里是一个循环,意味着只要var2.registerRefs返回的false,那么就会一直循环该流程,不断的进入registerRefs
方法中,其实这里是一个DGC客户端不断向DGC服务端汇报远程对象ref的存活状况,当ref不存在时,该方法的返回值就会是true了,然后就会跳出循环。这样就会造成一个有趣的现象:DGC客户端以一定的时间间隔向DGC服务端(恶意的JRMP服务端)发起请求,类似TCP连接的心跳包,又类似一个不死马,受攻击的DGC客户端(这里是Registry端)会以同频的时间间隔不断弹出计算器。
继续跟进一下后面的流程:
1 | sun.rmi.transport.DGCClient$EndpointEntry#makeDirtyCall |
在makeDirtyCall
方法中,DGC客户端会发出dirty请求来注册DGC要跟踪的引用对象
实际是调用DGCImpl_Stub#dirty
方法。这个方法在前面DGC介绍中没有细说,实际上,和RegistryImpl_Stub
的几个操作方法的处理逻辑都很类似。那么这里就和JRMP客户端(Client端)的流程几乎一样了,最后会调用StreamRemoteCall
和我们指定的JRMP服务端(DGC服务端)。其实上述过程就是DGC服务端注册跟踪一个新的引用对象UnicastRef的过程,自然在”客户端”创建好UnicastRef的过程中,DGC客户端会将创建好的Lease(待跟踪对象,封装了UnicastRef)序列化传输给服务端,且客户端会以一定的时间间隔向服务端发起DGC请求并传输Lease,这样服务端才能持续的跟踪远程对象的存活情况。总而言之,我们已经找到了一个能够主动连接JRMP服务端的可序列化Gadgets
再重复一下攻击流程:
- 攻击端先起一个
ysoserial.exploit.JRMPListener
1 | java -cp ysoserial.jar 8787 ysoserial.exploit.JRMPListener CommonsCollections6 "open -a Calculator.app" |
- 攻击端连接Registry端,并bind一个指向JRMPListener的UnicastRef(需要Registry外连)
解决bind端的参数限制
现在我们可以考虑在Server端使用bind来绑定我们恶意的UnicastRef对象了。直接这么写可以吗?
1 | registry.bind("service", UnicastRef实例); |
显然不行,和前面一样,只要涉及两端交互一端写出的情况下,变会调用写出端的replaceObject方法,所以这里也要解决一下参数限制。解决方法这里就不赘述了,思路和前面说的攻击Registry端一样。只需把”携带恶意的Object对象”换成”携带恶意的UnicastRef对象”即可。还是那四条思路,只不过对于第2种,不需要动态代理了,RemoteObjectInvocationHandler
、RMIConnectionImpl_Stub
都可以,但UnicastRemoteObject不行哦
真正解释为何能绕过JEP290
总结上面的两个利用方式
- JRMP服务端打JRMP客户端:利用恶意的JRMP服务端返回恶意的异常类,客户端反序列化异常类导致问题。Sink点核心是
StreamRemoteCall#executeCall
- UnicastRef对象:该对象作为主动发出JRMP请求的一方(可以不粗糙的认为是JRMP可客户端),其
UnicastRef#invoke
方法能触发executeCall
。我们通过bind/rebind/unbind/lookup的参数注入方式,向Registry端”注入”UnicastRef对象,在处理过程中向JRMP服务端发起请求,接受恶意对象并反序列化
最终利用成功的Sink点是StreamRemoteCall#executeCall
,在该方法中获取的ConnectionInputStream,由于JEP290只能通过UnicastServerRef#filter设置serialFilter,因此这里的ConnectionInputStream#serialFilter
为null,自然就没有过滤了。从更底层的JRMP通信来绕过。
Client端打Registry端 - 结合lookup
这部分就不班门弄斧复制粘贴了,直接参考@啦啦0咯咯师傅的即可,攻击的原理清楚了,剩下的就主要是攻击端参数类型限制的解决问题了。
https://xz.aliyun.com/t/7932#toc-4
简单来说,lookup的问题包括:绕过replaceObject + 代码层面String类型的限制。对于第一个问题,我们已经很有经验了,对于第二个String类型的限制,在@啦啦0咯咯师傅的文章里也介绍了几种方式
- 直接修改最底层的lookup的返回数据包,在数据包中插入我们的payload
- 在代码层面自定义Client端lookup的过程,这个比较简单,这里演示一下
代码如下:
1 | public class EvilClientBypassJEP290 { |
改写的lookup几乎如法炮制第3种思路改写的bind,就操作号换了一下,序列化的参数换了一下2333
8u231修复
针对前面JEP290的局限性,给
StreamRemoteCall
添加了filter局部过滤器,并在合适的地方(反序列化前)赋值(但并没有考虑全,造成了后面的绕过),因此调用任何StreamRemoteCall#getInputStream
方法且filter不为空时,都会给取出的ConnectionInputStream设置过滤器。
sun.rmi.registry.RegistryImpl_Skel#dispatch
报错情况消除ref
在openjdk中可以在线查看对比8u232u8的RegistryImpl_Skel.java与8u232u9的RegistryImpl_Skel.java
其实只有一行的区别,在RegistryImpl_Skel#dispatch
每个动作比如lookup、bind等都添加了一个逻辑:**反序列化报错都会进入catch,执行discardPedingRefs
**。
看一下我们可能会报错的情况
- 自定义类:报错ClassNotFoundException。针对了第1种思路
- 无法类型转换:报错ClassNotFoundException。针对lookup、unbind这种Registry端接受的实际类型是String,但传输恶意对象是其他类型的情况。但对于bind/rebind Registry端第二个参数本身就是Remote,这里限制不了,需要依赖第2个修复
在sun.rmi.transport.StreamRemoteCall#discardPendingRefs
中其实也就是做了一件事情,把我们之前装载的incomingRefTable
清空。这样ref就保存不下来了,因此在后续registerRefs
DGC客户端就无法向我们的DGC服务端发起连接了
1 | public void discardPendingRefs() { |
- 在
DGCImpl_Stub#dirty
中,给StreamRemoteCall
添加了过滤器(在最开头)。
![image-20211207211407257](/Users/diggid/Library/Application Support/typora-user-images/image-20211207211407257.png)
绕过8u231
这里先把笔者精简过的payload放一下,还是以Server端打Registry端为例
- 恶意Client
1 | public class EvilServerBypass8u231 { |
- 用来生成不同UnicastRef、不同Gadgets的Util类
1 | public class Util { |
这个绕过思路是@An Trinh师傅提出来的,文章收录在这篇文章中。坑点很多,也有点套娃,但是不妨碍这个绕过思路大放异彩。从下面的分析中就可以知道原作者对RMI要有多熟才能想到,但其实仔细再回看总结一下前面RMI基础部分各端的交互流程的各个细节,其实这个绕过也就自然而然了。
从恶意的UnicastRef的封装细节入手,又使用到了一些其他的原生Gadgets。关注两个地方:
- 为什么要UnicastRemoteObject来封装在最外层,换成其他的行不行?(因为UnicastRemoteObject会受到replaceObject的影响)
- 为什么要用动态代理,且代理的接口是
RMIServerSocketFactory
,handler是RemoteObjectInvocationHandler
先从8u231的修复说起
- 对于第一处修复,是在Registry端的修复,限制的是第一次反序列化出UnicastRef的过程。对于bind操作来说,只要UnicastRef是被封装在已知Remote实例中,就没啥问题,但是对于lookup来说,String类型的限制可以干掉所有前面说的4种绕过思路。
- 对于第二处修复,是在DGC客户端的修复,限制的是第二次反序列化Gadgets的过程。
其实这种修复已经很”天衣无缝”了。但是还可以绕过,思路大致是:在RegistryImpl_Skel#dispatch
反序列化时(还没反序列化完,绕过报错),在这里就发出JRMP请求,不需要后面的releaseInputStream,同时也不需要在DGC客户端的ConnectionInputStream中完成readObject,而是另起一道来执行(绕过DGC层过滤)。
可能实现吗?
其实我们最终的Sink点是UnicastRef#invoke
,前面的无论咋变,只能要触发到这个Sink都可以。而UnicastRef#invoke
的invoke方法有两个重载
invoke(java.rmi.server.RemoteCall)
invoke(java.rmi.Remote, java.lang.reflect.Method, java.lang.Object[], long)
仔细总结前面的流程会发现,这些invoke都是提供给Stub用来让客户端(相对的)引用UnicastRef和服务端引用UnicastServerRef通信的。我们有RegistryImpl_Stub
、DGCImpl_Stub
、RemoteObjectInvocationHandler的代理
RegistryImpl_Stub
、DGCImpl_Stub
使用的是第一个重载RemoteObjectInvocationHandler的代理
使用的是第二个重载
前面我们都是使用的第一个重载的方式触发的通信,但是8u231修复了DGCImpl_Stub的反序列化。这里我们使用的是第二个重载的方式。在Server端远程远程对象创建的过程中,生成
RemoteObjectInvocationHandler的代理
封装在Target里在ObjectTable中。所以最终的Sink点是RemoteObjectInvocationHandler#invoke -> UnicastRef#invoke
,对于Client端(Client端使用代理来调用远程方法),JEP290和8u231的修复都没有针对这里,所以在RemoteObjectInvocationHandler的代理
这一层的StreamRemoteCall
中,是没有设置filter的。
关注UnicastRemoteObject#readObject
方法,我们前面提到过他可以开启一个JRMP的监听端口。带着payload我们跟进看一下,从readObject开始跟进如下调用栈
1 | newServerSocket:666, TCPEndpoint (sun.rmi.transport.tcp) |
这里会调用RMIServerSocketFactory
工厂的RMIServerSocketFactory来建立RMI监听。这个工厂是从UnicastRemoteObject的ssf
成员变量中拿出来的,同时在exportObject创建LiveRef的过程中会传递给TCPEndpoint
,所以这玩意的是可控的,我们创建了一个代理类,代理了该端口,同时handler是RemoteObjectInvocationHandler,调用createServerSocket就会进入RemoteObjectInvocationHandler#invoke
,之后的流程就是以前面说的从RemoteObjectInvocationHandler#invoke
到UnicastRef#invoke
,完整调用栈如下
1 | executeCall:233, StreamRemoteCall (sun.rmi.transport) |
所以上面这种方式的确能绕过8u231的修复2,成功规避的DGC层的调用。同时,上述过程是在反序列化UnicastRemoteObject时就完成的,因此还没到类型转换,所以又绕过了修复1,挺完美的,也挺巧的。这里再放一下修复前后的两条调用栈
- 修复前
1 | executeCall:233, StreamRemoteCall (sun.rmi.transport) |
- 修复后
1 | executeCall:233, StreamRemoteCall (sun.rmi.transport) |
8u241修复
- 将
RegistryImpl_Skel
的四个操作中(String)var9.readobject()
的部分换成SharedSecrets.getJavaObjectInputStreamReadString().readString(var9);
,即完全不接受String类型参数处的反序列化。这里前面没提,对于那四个操作,bind的第一个参数也可以作为我们恶意对象的注入位置。这个修复重点针对的应该是lookup的情况,对于bind,前面提到了在Registry端已经有IP的限制了:RegistryImpl.checkAccess("Registry.bind");
- 在
RemoteObjectInvocationHandler#invokeRemoteMethod
处加了一处被代理的方法的所属类是否为Remote类型的判断。这是命中要害的地方,前面动态代理的方法是createServerSocket,类是RMIServerSocketFactory自然就被拦截了。
![image-20211203100220886](/Users/diggid/Library/Application Support/typora-user-images/image-20211203100220886.png)
但是对于第二条修复,仍然有绕过的可能性,只不过在现有的调用链中比较难找。
绕过8u241(0day 加油找!!)
- UnicastRemoteObject可以换成其他可反序列化的类,但是要满足:
- 可以持有一个Remote类型的变量,该变量的值是和前面一样的但是代理的是Remote接口的代理类。
- 在该类的反序列化过程中,有Remote类型的方法调用。且这个方法的调用者还得是可控的变量,即上面的代理类。
- 这个类和过程可以不是sum.rmi包下的,只要和rmi有关的都可以找找看。比如在jndi、jmx里面找
- RegistryImpl_Stub的四个操作没有被限制。但是要求在RegistryImpl_Skel的发配流程中能够主动调用这四个操作(几乎不可能)
后记
还有一些Todo:
- 替身攻击的实现。涉及修改标准内置jar的问题。
- JavaAgent运行时修改,能否更完美的解决参数限制的绕过问题(4种思路)
- 8u241的绕过。
- JRMP协议的具体分析。
- RMI 回显
发现
- 攻击总结
- RMI远程类加载器(codebase)的SPI机制能不能恶意利用一下
参考
感谢各位师傅的研究总结!膜拜
https://su18.org/post/rmi-attack/#jep-290
https://xz.aliyun.com/t/7932#toc-0