Java RMI 攻击由浅入深

零、前言

十一本来打算不卷了,好好放松放松,但是几个事情对我触动比较大,所以就又给自己设了个搞懂 RMI 反序列化相关攻击的学习目标。

再加上一是为后续深入 IIOP/T3 打下基础,二是最近一位师傅问了我关于利用 RMI 回显的思路,帮师傅搞了半天没有搞定,怪我之前对 RMI 只是浅浅的了解,甚至都算不上会,没能帮上这位师傅的忙,很是自责,所以花点时间学一学。

这部分的实现略有复杂,本文在写的时候也是深感吃力,RMI 相关的文章在网上乱七八糟,由于又涉及到安全更新和绕过,之前看了几次看的头晕眼花,这次趁着有空梳理一下。

个人能力有限,如有理解错误及偏差,请不吝赐教。

一、RMI 介绍

RMI (Remote Method Invocation) 远程方法调用,顾名思义,是一种调用远程位置的对象来执行方法的思想。

这种思想在 C 语言中的 RPC(Remote Procedure Calls)中早就有了体现,但是 RPC 是打包和传送数据结构,而在 Java 中,我们通常传递一个完整的对象,这个对象既包含数据,也包含数据和操作数据的方法,Java 中如果想完整的在网络中向远程位置传输一个对象,我们通常使用的方法是 Java 原生反序列化,并且可以结合动态类加载和安全管理器来安全的传输一个 Java 类。

而具体的实现思想就是让我们获取远程主机上对象的引用,我们调用这个引用对象,但实际方法的执行在远程位置上。

为了屏蔽网络通信的复杂性,RMI 引入了两个概念,分别是 Stubs(客户端存根) 以及 Skeletons(服务端骨架),当客户端(Client)试图调用一个在远端的 Object 时,实际调用的是客户端本地的一个代理类(Proxy),这个代理类就称为 Stub,而在调用远端(Server)的目标类之前,也会经过一个对应的远端代理类,就是 Skeleton,它从 Stub 中接收远程方法调用并传递给真实的目标类。Stubs 以及 Skeletons 的调用对于 RMI 服务的使用者来讲是隐藏的,我们无需主动的去调用相关的方法。但实际的客户端和服务端的网络通信时通过 Stub 和 Skeleton 来实现的。

这里先简单列一下整体调用时序图,后面会详细进行展开说:

使用 RMI ,首先要定义一个我们期望能够远程调用的接口,这个接口必须扩展 java.rmi.Remote 接口,用来远程调用的对象作为这个接口的实例,也将实现这个接口,为这个接口生成的代理(Stub)也是如此。这个接口中的所有方法都必须声明抛出 java.rmi.RemoteException 异常,例如:

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 给远程想要调用它的 Client 端,同时还提供了一些基础的 equals/hashcode/toString 方法。这里必须为这个实现类提供一个构造函数并且抛出 RemoteException。

在 export 时,会随机绑定一个端口,监听客户端的请求,所以即使不注册,直接请求这个端口也可以通信,这部分也会在后面展开说。

如果不想让远程对象成为 UnicastRemoteObject 的子类,后面就需要主动的使用其静态方法 exportObject 来手动 export 对象。示例代码如下:

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";
	}
}

现在可以被远程调用的对象被创建好了,接下来改如何调用呢?Java 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 的引用的名称,不能是注册表中的一些关键字

Naming 提供了查询(lookup)、绑定(bind)、重新绑定(rebind)、接触绑定(unbind)、list(列表)用来对注册表进行操作。也就是说,Naming 是一个用来对注册表进行操作的类。而这些方法的具体实现,其实是调用 LocateRegistry.getRegistry 方法获取了 Registry 接口的实现类,并调用其相关方法进行实现的。

那就说到了 java.rmi.registry.Registry 接口,这个接口在 RMI 下有两个实现类,分别是 RegistryImpl 以及 RegistryImpl_Stub,具体也放面后面来说。

我们通常使用 LocateRegistry#createRegistry() 方法来创建注册中心:

public class Registry {

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

然后将待调用的类进行绑定:

public class RemoteServer {

	public static void main(String[] args) throws RemoteException, MalformedURLException, AlreadyBoundException, InterruptedException {
		// 创建远程对象
		RemoteInterface remoteObject = new RemoteObject();
		// 绑定
		Naming.bind("rmi://localhost:1099/Hello", remoteObject);
	}
}

客户端进行调用:

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());
	}
}

这里 RemoteInterface 接口在 Client/Server/Registry 均应该存在,只不过通常 Registry 与 Server 通常在同一端上。

这样一次简单的远程调用通信就完成了,但是这其中还有几个特性需要说一下。

首先是动态类加载,如果客户端在调用时,传递了一个可序列化对象,这个对象在服务端不存在,则在服务端会抛出 ClassNotFound 的异常,但是 RMI 支持动态类加载,如果设置了 java.rmi.server.codebase,则会尝试从其中的地址获取 .class 并加载及反序列化。

可使用 System.setProperty("java.rmi.server.codebase", "http://127.0.0.1:9999/"); 进行设置,或使用启动参数 -Djava.rmi.server.codebase="http://127.0.0.1:9999/" 进行指定。

接下来就是安全策略的设置,因为我们通过网络加载外部类并执行方法,所以我们必须要有一个安全管理器来进行管理,如果没有设置安全管理,则 RMI 不会动态加载任何类,通常我们使用:

if (System.getSecurityManager() == null) {
    System.setSecurityManager(new RMISecurityManager());
}

管理器应与管理策略相辅相成,所以我们还需要提供一个策略文件,里面配置允许那些主机进行哪些操作,这里为了方便测试,直接设置全部权限:

grant {
    permission java.security.AllPermission;
};

同样可以使用 -Djava.security.policy=rmi.policySystem.setProperty("java.security.policy", RemoteServer.class.getClassLoader().getResource("rmi.policy").toString()); 来进行设置。

二、源码分析

在大概简单了解上面的流程后,接下来我们来具体看一下实现。如果这部分你有基础,只关注攻击行为的话,请从第三章开始看。

1. 服务注册

① 远程对象创建

首先我们创建了一个远程对象:RemoteInterface remoteObject = new RemoteObject();,这个对象继承了 UnicastRemoteObject,这个类用于使用 JRMP 协议 export 远程对象,并获取与远程对象进行通信的 Stub。具体是什么意思呢?我们看一下流程。

在初始化时,会创建一个 UnicastServerRef 对象,并调用其 exportObject 方法来 export RemoteObject 这个远程对象。

这其中使用 sun.rmi.server.Util#createProxy() 方法使用 RemoteObjectInvocationHandler 来为我们测试写的 RemoteObject 实现的 RemoteInterface 接口创建动态代理。

然后创建 sun.rmi.transport.Target 对象,使用这个 Target 对象封装了我们远程执行方法和生成的动态代理类(Stub)。

并调用 LiveRef#exportObject 接着调用 sun.rmi.transport.tcp.TCPEndpoint#exportObject 监听本地端口。

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

上述流程总结成一张图,如下:

首先来看一下 RemoteObjectInvocationHandler 这个动态代理,继承 RemoteObject 实现 InvocationHandler,因此这是一个可序列化的、可使用 RMI 远程传输的动态代理类。既然是动态代理类,自然重点关注 invoke 方法,可以看到如果是 Object 的方法会调用 invokeObjectMethod 方法,其他的则调用 invokeRemoteMethod 方法。

而在 invokeRemoteMethod 中实际是委托 RemoteRef 的子类 UnicastRef 的 invoke 方法执行调用。

UnicastRef 的 invoke 方法是一个建立连接,执行调用,并读取结果并反序列化的过程。这里,UnicastRef 包含属性 LiveRef ,LiveRef 类中的 Endpoint、Channel 封装了与网络通信相关的方法。

反序列化方法在 unmarshalValue 中。

② 注册中心创建

在代码中,我们通常使用 LocateRegistry.createRegistry(1099); 来创建注册中心,那么这个过程又发生了什么呢?继续跟代码。

首先是可以看到 createRegistry 方法实际 new 了一个 RegistryImpl 对象。

RegistryImpl 的构造方法中创建 LiveRef 对象,然后创建 UnicastServerRef 对象,最后调用 setup 进行配置。

setup 方法中,依旧是使用 UnicastServerRef 的 exportObject 方法 export 对象,只不过这次 export 的是 RegistryImpl 这个对象。

在 exportObject 方法中,重要的一步就是使用 Util.createProxy() 来创建动态代理,之前提到对远程对象使用 RemoteObjectInvocationHandler 来创建,但是之前有一个 stubClassExists 的判断。

如果需要创建代理的类在本地有 _Stub 的类,则直接使用 createStub 方法反射调用 stub 类的构造方法创建类实例。

这里由于是 RegistryImpl 这个类,系统会找到 RegistryImpl_Stub 这个类并进行实例化,RegistryImpl_Stub 继承了 RemoteStub ,实现了 Registry。这个类实现了 bind/list/lookup/rebind/unbind 等 Registry 定义的方法,全部是通过序列化和反序列化来实现的。

创建完代理类之后,会调用 setSkeleton 方法调用 Util.createSkeleton() 方法创建 skeleton。

其实就是反射实例化 RegistryImpl_Skel 这个类并引用在 UnicastServerRef 的 this.skel 中。

RegistryImpl_Skel 类提供了 dispatch 方法来分发具体的操作。

后续的 export 流程相同。

注册中心与远程服务对象注册的大部分流程相同,差异在:

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

③ 服务注册

注册说白了就是 bind 的过程,通常情况下,如果 Server 端和 Registry 在同一端,我们可以直接调用Registry 的 bind 方法进行绑定,具体实现在 RegistryImpl 的 bind 方法,就是将 Remote 对象和名称 String 放在成员变量 bindings 中,这是一个 Hashtable 对象。

如果 Server 端和 Registry 端不在一起,那我们需要先获取 Registry 对象,无论是使用 Naming 或者 LocateRegistry 都是调用 LocateRegistry.getRegistry() 方法来创建 Registry,这部分的创建过程与注册中心注册时的过程是一致的。一些具体的逻辑放在下面服务发现来一起说。

2. 服务发现

服务发现,就是获取注册中心并对其进行操作的过程,这里面包含 Server 端和 Client 端两种。

如果 Server 端和 Registry 在同一端,那可以直接使用在创建 Registry 时使用的 RegistryImpl, 直接调用其相关方法,这没什么好说的。

如果 Server 端和 Registry 不同端,则在 Server 端或 Client 端使用 LocateRegistry.getRegistry() 方法获取注册中心时都是一样的流程:

  • 首先在本地创建了一个包含了具体通信地址、端口的 RegistryImpl_Stub 对象
  • 通过调用这个本地的 RegistryImpl_Stub 对象的 bind/list... 等方法,来与 Registry 端进行通信
  • 而 RegistryImpl_Stub 的每个方法,都实际上调用了 RemoteRef 的 invoke 方法,进行了一次远程调用链接
  • 这个过程使用 java 原生序列化及反序列化来实现

获取了注册中心后,如果是 Server 端,我们希望在注册中心上绑定(bind)我们的服务,如果是 Client 端,我们希望在注册中心遍历(list)、查找(lookup)和调用服务,查找的逻辑我们放在下一部分服务调用来说,这里主要关注绑定的过程。

对于 Server 端向注册中心上绑定(bind)来说,无论是 Registry 还是 Naming 的 bind 方法,实际上都是调用 Server 端生成的本地 RegistryImpl_Stub 的 bind 方法。这个方法比较简单粗暴,建立连接然后向流里 writeObject 。

实际通过调用 UnicastRef 的 invoke 方法来进行网络传输。这里有个关键的操作时 marshalCustomCallData 方法。

使用 sun.rmi.server.MarshalOutputStream 封装后会使用动态代理类来替换原始类。

以上就 Server 端执行 bind 方法后进行的操作,一句话总结就是,根据 Registry 的 host/port 等信息创建本地 RegistryImpl_Stub,然后调用其 bind 方法向 Registry 端使用 writeObject 写入 name 和生成的动态代理类。

那在 Registry 端都做了什么呢?

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

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

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

oldDispatch 方法调用 this.skel 也就是 RegistryImpl_Skel 类的 dispatch 方法。

RegistryImpl_Skel 的 dispatch 方法根据流中写入的不同的操作类型分发给不同的方法处理,例如 0 代表着 bind 方法,则从流中读取对应的内容,反序列化,然后调用 RegistryImpl 的 bind 方法进行绑定。

以上就是 Server 端向 Registry 端注册服务的整个流程。

3. 服务调用

之后就是 Client 端向 Registry 端查询和请求的过程了。客户端获取 Registry 的流程与上面分析的服务端一致,这里不再重复。还是通过调用本地创建的 RegistryImpl_Stub 对象。

在调用其 lookup 方法时,会向 Registry 端传递序列化的 name ,然后将 Registry 端回传的结果反序列化,很好理解。

这里还是关注 Registry 端的做法,依旧是 RegistryImpl_Skel 的 dispatch 方法,lookup 方法对应的值是 2 ,调用 RegistryImpl 的 lookup 方法,然后将查询到的结果 writeObject 到流中。

Client 拿到 Registry 端返回的动态代理对象并且反序列化后,对其进行调用,这看起来是本地进行调用,但实际上是动态代理的 RemoteObjectInvocationHandler 委托 RemoteRef 的 invoke 方法进行远程通信,由于这个动态代理类中保存了真正 Server 端对此项服务监听的端口,因此 Client 端直接与 Server 端进行通信。

Server 端由 UnicastServerRef 的 dispatch 方法来处理客户端的请求,会在 this.hashToMethod_Map 中寻找 Client 端对应执行 Method 的 hash 值,如果找到了,则会反序列化 Client 端传来的参数,并且通过反射调用。

调用后将结果序列化给 Client 端,Client 端拿到结果反序列化,完成整个调用的过程。

三、总结

上一章描述的有些乱,那么这里我们总结一下,进行一个完整的服务注册、发现、调用流程,都经历了哪些步骤?

现在能明白其他文章说的那些稀奇古怪的“存根和骨架”什么的花里胡哨的通信模式都代表什么了吧。

这部分流程在 Javasec 里说的也很清楚,如果你觉得上面我总结的过程过于复杂,可以看下面这部分,我这里直接引用:

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

有了以上的知识铺垫,相信你对 Java RMI 的具体调用过程已经有了相关的了解了,此时去看 Longofo 师傅或 threedr3am 师傅的相关文章应该没有问题了,那么接下来我们就开始讨论 RMI 攻击。

参与一次 RMI 调用的有三个角色,分别是 Server 端,Registry 端和 Client 端。严格意义上来讲,只有 Registry 端和使用 Registry 的端,因为 Registry 端只负责查询和传递引用,真正的方法调用是不需要经过 Registry 端的,只不过注册服务的我们称之为 Server 端,使用服务的我们称之为 Client 端。有一种我只负责帮你找到人,至于你找这个人做什么非法勾当我不管的感觉,不过为了更清晰的划分不同角色,我们还是将其分为三个角色,而通常情况下,Server 端和 Registry 端是同一端。

在上面的 RMI 调用过程中我们可以发现,全部的通信流程均通过反序列化实现,而且在三个角色中均进行了反序列化的操作。那也就说明针对三端都有攻击的可能,我们依次来看一下。

1. 攻击 Server 端

① 恶意服务参数

在 Client 端获取到 Server 端创建的 Stub 后,会在本地调用这个 Stub 并传递参数,Stub 会序列化这个参数,并传递给 Server 端,Server 端会反序列化 Client 端传入的参数并进行调用,如果这个参数是 Object 类型的情况下,Client 端可以传给 Server 端任意的类,直接造成反序列化漏洞。

例如,远程调用的接口 RemoteInterface 存在一个 sayGoodbye 方法的参数是 Object 类型。

那我们就直接可以传一个反序列化 payload 进去执行,这里我以 CC6 弹计算器为例:

直接弹计算器没商量。这部分就是纯纯的 Java 原生反序列化漏洞的利用过程,不多说。进入下一个思考,如果参数类型不是 Object 类型,那能否进行攻击?

答案也是可以的。

在一般条件下,通常保证 Server 端和 Client 端调用的服务接口是一样的,那如果不一致会怎么样?我们在服务端的接口 RemoteInterface 中定义一个 sayHello 方法,他接收一个在 Server 端存在的 HelloObject 类作为参数。

但是在 Client 端,我们却定义了一个接收 Object 参数的方法:

那这样能否触发反序列化漏洞呢?我们会发现在尝试调用过程中会抛出异常 unrecognized method hash: method not supported by remote object

其实就是在服务端没有找到对应的调用方法。这个找对应方法我们之前说过,是在 UnicastServerRef 的 dispatch 方法中在 this.hashToMethod_Map 中通过 Method 的 hash 来查找的。这个 hash 实际上是一个基于方法签名的 SHA1 hash 值。

那有没有一种可能,我们传递的是 Server 端能找到的参数是 HelloObject 的 Method 的 hash,但是传递的参数却不是 HelloObject 而是恶意的反序列化数据(可能是 Object或其他的类)呢?

答案是可以的,在 mogwailabs 的 PPT 中提出了以下 4 种方法:

  • 通过网络代理,在流量层修改数据
  • 自定义 “java.rmi” 包的代码,自行实现
  • 字节码修改
  • 使用 debugger

并且在 PPT 中还给出了 hook 点,那就是动态代理中使用的 RemoteObjectInvocationHandler 的 invokeRemoteMethod 方法。

接下来我们尝试一下,由于是学习和测试,这里将使用最方便的 debugger 方式。Afant1 师傅使用了 Java Agent 的方式,在这篇文章里,0c0c0f 师傅使用了流量层的替换,在这篇文章里,有兴趣的师傅请自行查看。

Server 端代码不变,我们在 Client 端将 Object 参数和 HelloObject 参数的 sayHello 方法都写上,如下:

调用时,依旧使用 Object 参数的 sayHello 方法调用。

在 RemoteObjectInvocationHandler 的 invokeRemoteMethod 方法处下断,将 Method 改为服务端存在的 HelloObject 的 Method。

发起调用,成功弹出计算器。

那么利用这种方式,就大大的扩展了利用链。RMI 的反序列化逻辑位于 sun.rmi.server.UnicastRef#unmarshalValue,如下:

可以看到,除了基础数据类型,其他的类型均会调用 readObject 进行反序列化,甚至原本 String 类型的参数也会走 readObject 反序列化,那么结合之前的替换手段,总结起来就是:

Server 端的调用方法存在非基础类型的参数时,就可以被恶意 Client 端传入恶意数据流触发反序列化漏洞。

② 动态类加载

之前讨论过,RMI 有一个重要的特性,就是动态类加载机制,当本地 ClassPath 中无法找到相应的类时,会在指定的 codebase 里加载 class。这个特性在 6u45/7u21 之前都是默认开启的。

为了能够远程加载目标类,需要 Server 加载并配置 SecurityManager,并设置 java.rmi.server.useCodebaseOnly=false

Server 端调用 UnicastServerRef 的 dispatch 方法处理客户端请求,调用 unmarshalParameters 方法反序列化客户端传来的参数。

反序列化过程由 RMI 封装类 MarshalInputStream 来实现,会调用 resolveClass 来解析 Class。

首先通过 this.readLocation() 方法读取流中序列化的 java.rmi.server.codebase 地址,这部分信息是 Client 端传来的,然后判断 this.useCodebaseOnly 的值必须为 false,最后调用 RMIClassLoader.loadClass() 方法加载类,这部分实际上是委托 sun.rmi.server.LoaderHandler 来实现的,最终调用 loadClassForName 方法,通过 Class.forName() 传入自定义类加载器 LoaderHandler$Loader 来从远程地址加载类。

LoaderHandler$Loader 是 URLClassLoader 的子类。

无论 Server 端还是 Client 端,只要有一端配置了 java.rmi.server.codebase,这个属性都会跟随数据流在两端流动。

因此 Client 端可以通过配置此项属性,并向 Server 端传递不存在的类,使 Server 端试图从 java.rmi.server.codebase 地址中远程加载恶意类而触发攻击。

③ 替身攻击

在讨论对 Server 端的攻击时,还出现了另外一种针对参数的攻击思路,我称其为替身攻击。依旧是用来绕过当参数不是 Object,是指定类型,但是还想触发反序列化的一种讨论。

大体的思路就是调用的方法参数是 HelloObject,而攻击者希望使用 CC 链来反序列化,比如使用了一个入口点为 HashMap 的 POC,那么攻击者在本地的环境中将 HashMap 重写,让 HashMap 继承 HelloObject,然后实现反序列化漏洞攻击的逻辑,用来欺骗 RMI 的校验机制。

这的确是一种思路,但是还不如 hook RMI 代码修改逻辑来得快,所以这里不进行测试。

2. 攻击 Registry 端

在使用 Registry 时,首先由 Server 端向 Registry 端绑定服务对象,这个对象是一个 Server 端生成的动态代理类,Registry 端会反序列化这个类并存在自己的 RegistryImpl 的 bindings 中,以供后续的查询。所以如果我们是一个恶意的 Server 端,向 Registry 端输送了一个恶意的对象,在其反序列化时就可以触发恶意调用。

可以看到这里我依旧是用了 CC6 ,因为 bind 的参数是需要是 Remote 类型的,所以这里使用了 AnnotationInvocationHandler 来代理了 Remote 接口,形成了反序列化漏洞。

这里需要 Registry 端具有相应的依赖及相应 JDK 版本需求,对于 JDK 版本的讨论将在后面进行。

这个攻击手段实际上就是 ysoserial 中的 ysoserial.exploit.RMIRegistryExploit 的实现原理。

除了 bind,由于 lookup/rebind 等方法均通过反序列化传递数据,因此此处的实际攻击手段不止 bind 一种。也就是说,名义上的 Server 端和 Client 端都可以攻击 Registry 端。

3. 攻击 Client 端

如果攻击的目标作为 Client 端,也就是在 Registry 地址可控,或 Registry/Server 端可控,也是可以导致攻击的。客户端主要有两个交互行为,第一是从 Registry 端获取调用服务的 stub 并反序列化,第二步是调用服务后获取执行结果并反序列化。

这部分攻击实战意义较少,并且与上述讨论的攻击 Server 端和 Registry 端的攻击都是镜像行为,所以这里简单描述一下流程就不再演示了。

① 恶意 Server Stub

同攻击 Registry 端,Client 端在 Registry 端 lookup 后会拿到一个 Server 端注册在 Registry 端的代理对象并反序列化触发漏洞。

② 恶意 Server 端返回值

同攻击 Server 端的恶意服务参数,Server 端返回给 Client 端恶意的返回值,Client 端反序列化触发漏洞,不再赘述。

③ 动态类加载

同攻击 Server 端的动态类加载,Server 端返回给 Client 端不存在的类,要求 Client 端去 codebase 地址远程加载恶意类触发漏洞,不再赘述。

4. 攻击 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 来处理。

可以在 Server 端的 ObjectTable 中找到由 Target 封装的 DGCImpl,在 Registry 端的 ObjectTable 中找到由 Target 封装的 DGCImpl_Stub。

DGC 通信的处理类是 DGCImpl_Skel 的 dispatch 方法,依旧通过 Java 原生的序列化和反序列化来处理对象。

看到这里就明白了,伴随着 RMI 服务启动的 DGC 通信,也存在被 Java 反序列化利用的可能。我们只需要构造一个 DGC 通信并在指定的位置写入序列化后的恶意类即可。

由于 DGC 通信和 RMI 通信在 Transport 层是同样的处理逻辑,只不过根据 Client 端写入的标记来区分是是由 RegistryImpl_Skel 还是 DGCImpl_Skel 来处理,因此我们可以使用 DGC 来攻击任意一个由 JRMP 协议监听的端口,包括 Registry 端监听端口、RegistryImpl_Stub 监听端口、DGCImpl_Stub 监听端口。

不过由于后两者的端口号是随机的,因此通常使用 DGC 层来攻击 Registry 端。

这个攻击手段实际上就是 ysoserial 中的 ysoserial.exploit.JRMPClient 的实现原理。

五、反序列化 Gadgets

学会了 RMI 的相关实现和漏洞利用方式,可以发现 RMI 中的一部分类可以用来组成反序列化的 Gadgets。

1. UnicastRemoteObject

java.rmi.server.UnicastRemoteObject 类通常是远程调用接口实现类的父类,或直接使用其静态方法 exportObject 来创建动态代理并随机监听本机端口以提供服务。

因此不难理解,在反序列化此类以及其子类后,依旧需要执行 exportObject 的相关操作,直接来看一下 UnicastRemoteObject 的 readObject 方法:

会执行 this.reexport() 方法,可以看到是直接执行了 exportObject 方法。

那毫无疑问这个方法会触发 JRMP 监听端口,并会对请求进行解析和反序列化操作,那就可以配合 DGC 的处理逻辑来进行攻击。

利用代码为:

public class UnicastRemoteObject1 {

	public static void main(String[] args) throws Exception {
		int port = 12233;

		// 使用
		Object uro   = ClassUtil.createInstanceUnsafely(UnicastRemoteObject.class);
		Field  field = UnicastRemoteObject.class.getDeclaredField("port");
		field.setAccessible(true);
		field.set(uro, port);

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

		SerializeUtil.writeObjectToFile(uro);
		SerializeUtil.readFileObject();

		// 保持进程
		Thread.sleep(100000);
	}
}

反序列化调用链为:

UnicastRemoteObject.readObject()
    UnicastRemoteObject.reexport()
        UnicastRemoteObject.exportObject()
            UnicastServerRef.exportObject()
                LiveRef.exportObject()
                    TCPEndpoint.exportObject()
                        TCPTransport.exportObject()
                            TCPTransport.listen()

这部分对应的就是 ysoserial.payloads.JRMPListener 这个 gadget,可以结合 ysoserial.exploit.JRMPListener 来使用。

但 ysoserial 是使用了 UnicastRemoteObject 的子类 ActivationGroupImpl 作为实例,我们是直接使用 unsafe 直接创建了 UnicastRemoteObject 对象,没有使用子类,大同小异。

2. UnicastRef

sun.rmi.server.UnicastRef 类实现了 Externalizable 接口,因此在其反序列化时,会调用其 readExternal 方法执行额外的逻辑。

UnicastRef 的 readExternal 方法调用 LiveRef.read(var1, false) 方法来还原成员变量 LiveRef ref 属性。

LiveRef 的 read 方法在创建 LiveRef 对象后,调用 DGCClient 的 registerRefs 方法来将其在环境中进行注册。

调用 DGCClient$EndpointEntry#registerRefs 方法

继续调用 makeDirtyCall 方法

最后是调用 DGC 实现类实际是 DGCImpl_Stub 的 dirty 方法进行通信触发反序列化。

因此可以看出,在 UnicastRef 进行反序列化时,会触发 DGC 通信及 dirty 方法调用,此时如果与一个恶意服务通信,返回恶意数据流,则会造成反序列化漏洞。

利用代码:

public class UnicastRef1 {

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

		String host = "127.0.0.1";
		int    port = 12233;

		ObjID       id  = new ObjID(new Random().nextInt()); // RMI registry
		TCPEndpoint te  = new TCPEndpoint(host, port);
		UnicastRef  ref = new UnicastRef(new LiveRef(id, te, false));

		SerializeUtil.writeObjectToFile(ref);
		SerializeUtil.readFileObject();
	}
}

反序列化调用链为:

UnicastRemoteObject.readObject()
    UnicastRemoteObject.reexport()
        UnicastRemoteObject.exportObject()
            UnicastServerRef.exportObject()
                LiveRef.exportObject()
                    TCPEndpoint.exportObject()
                        TCPTransport.exportObject()
                            TCPTransport.listen()

恶意服务端可以结合 ysoserial.exploit.JRMPListener 来使用。

这条链是 lpwd 师傅提交的利用链,是在 ysoserial 的精简,也就是下面要说的链。

3. RemoteObject

RemoteObject 是几乎所有 RMI 远程调用类的父类。这个类也可以用来触发反序列化漏洞。

RemoteObject 的 readObject 方法会先反序列化成员变量 RemoteRef ref ,最后调用其 readExternal 方法,可以用来触发上一条 UnicastRef 链。

因此我们随便找一个 RemoteObject 的子类,在其实例中放入 UnicastRef 对象,反序列化时均可触发利用链。例如如下利用代码,

public class RemoteObject1 {

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

		String host = "127.0.0.1";
		int    port = 12233;

		ObjID       id  = new ObjID(new Random().nextInt()); // RMI registry
		TCPEndpoint te  = new TCPEndpoint(host, port);
		UnicastRef  ref = new UnicastRef(new LiveRef(id, te, false));

		RMIServerImpl_Stub stub = new RMIServerImpl_Stub(ref);

		//  ysoserial 中使用 RemoteObjectInvocationHandler
//		RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref);
//		Registry proxy = (Registry) Proxy.newProxyInstance(RemoteObject1.class.getClassLoader(), new Class[]{Registry.class}, obj);

		SerializeUtil.writeObjectToFile(stub);
		SerializeUtil.readFileObject();
	}

}

ysoserial 使用 RemoteObjectInvocationHandler 的代理类作为反序列化的入口点,相当于是 UnicastRef 的延长链。

这部分对应的就是 ysoserial.payloads.JRMPClient 这个 gadget,恶意服务端可以结合 ysoserial.exploit.JRMPListener 来使用。

六、入深

深入之前我先声明一下:其实还有利用 Registry 和 Server 之间相互攻击的情况,但是由于实际环境中二者往往是一起的,因此探究此类攻击行为意义不大,在本文中将会省略。

本章将会继续深入讨论一下在 RMI 攻击中的一些攻防和绕过的相关技术细节,对于一些 RMI 自身逻辑的更新细节,啦啦师傅的两篇文章有所涉猎,这里也不进行复制粘贴了,主要讨论一下 JEP 290。

JEP 290

JEP290 是 Java 底层为了缓解反序列化攻击提出的一种解决方案,描述网址点这里。这是一个针对 JAVA 9 提出的安全特性,但同时对 JDK 6,7,8 都进行了支持,在 JDK 6u141、JDK 7u131、JDK 8u121 版本进行了更新。

JEP 290 主要提供了几个机制:

  • 提供了一种灵活的机制,将可反序列化的类从任意类限制为上下文相关的类(黑白名单);
  • 限制反序列化的调用深度和复杂度;
  • 为 RMI export 的对象设置了验证机制;
  • 提供一个全局过滤器,可以在 properties 或配置文件中进行配置。

JEP 290 在我的历史文章里没有讨论过,这里我也是边学边写,主要是看了 kejaly 师傅的这篇文章 、隐形人真忙师傅的这篇文章、Y4er 师傅的这篇文章,对此没有基础的读者可以先看这三篇文章学习,这里重点关注对于 RMI 相关利用的影响。

随着 JEP 290 的更新,RMI 首先在 RegistryImpl 类中引入了一个 registryFilter 方法,用来过滤在 RMI 调用产生的反序列化过程中允许的序列化类,判断代码如下,可以看到,除了基础类型之外,RegistryImpl 采用了白名单的方式限制了允许序列化的类型。

DGC 层对应的 DGCImpl 也引入了 checkInput 方法:

在这种情况下,直接使用 CC 一类的 gadget 就完全失效了,但是在第五章反序列化 Gadgets 里提到的 UnicastRef/RemoteObject 利用链配合 ysoserial.exploit.JRMPListener 依旧是可以使用的。

这个利用链的大致流程就是:攻击者发送 payload 让目标服务器发起一个 JRMP 请求去链接我们的 JRMP 服务器,然后接受并反序列化我们 JRMP 服务器返回的报错信息,反序列化的时候通过 RMI 注册端内部的利用链(比如 CC)完成命令执行。

除此之外 An Trinh 师傅还公布了他对 JEP 290 的绕过方式,被收录在这篇文章中。

我没细看,但感觉是个套娃行为,最终触发点还是 UnicastRef。有兴趣的师傅可以跟一下。啦啦师傅的文章里也有分析。

七、扩展

以上关于 RMI 攻击的分析,大多数都是白盒的,理想式的分析,那在实际情况中,如果遇到了一个 RMI 服务,究竟该如何攻击呢?

在学习 RMI 攻击的相关过程中,发现两个攻击 RMI 的开源项目,先来学习一下这些项目。

BaRMIe

BaRMIe 由 Nicky Bloor (@NickstaDB) 编写,主要提供了两种功能: enum 和 attack。程序里的注释写的很详细,重点关注一下核心实现。

首先来看下 enum 功能,此功能由 nb.barmie.modes.enumeration.EnumerationTask#run 方法实现,核心方法在 nb.barmie.modes.enumeration.RMIEnumerator#enumerateEndpoint 中:

首先还是利用 LocateRegistry.getRegistry() 方法创建本地的 Registry 代理。

获取一个随机名称,然后调用 unbind 方法去解绑,如果抛出 NotBoundException 异常,则证明我们存在远程对 registry 进行操作的可能。

创建一个 TCP 代理,用来获取在 RMI 通信过程中产生的数据包,并重新通过代理与 Registry 端进行通信,BaRMIe 从代理中读取流数据并自行实现解析逻辑,从而避免攻击者端在反序列化时由于没有具体接口而导致 "Class.forName" 报错。

调用 list 方法获取 Registry 端绑定的服务名,并且循环使用 lookup 方法去获取对应的服务对象动态代理,这中间产生的流量会被 RMIReturnDataCapturingProxy 这个代理类捕获到,然后通过 RMIReplyDataParser 的 extractObjectDetails 方法解析远程服务对象的相关信息。

解析后 EnumerationTask#run 会整理及打印远程服务对象的相关信息,以及是否能够对此 Registry 进行远程操作(bind/unbind/rebind)。

接下来会尝试匹配 BaRMIe 内置的一些攻击手段,如果匹配到了将会打印信息:

主要是包括 Axiom 文件操作、SpringFramework 里的反序列化、JMX 反序列化、非法 bind 等,循环调用这些内置 payload 中的 canAttackEndpoint 方法进行测试,在这一步不会直接实施攻击,在这一步均是测试 payload。

如果判断可能存在反序列化攻击,则继续尝试查找可用的反序列化 gadget:

BaRMIe 支持的反序列化 payload 支持如下:

最后是整合信息及打印,效果如下:

接下来看下 attack 功能,此功能由 nb.barmie.modes.attack.AttackMode#run 方法实现,首先依旧是调用 RMIEnumerator#enumerateEndpoint 方法来枚举并尝试攻击,获取可用的攻击手段。

接下来就是根据选择不同的攻击方式,进入不同的菜单,输入不同的参数,发起不同的攻击,最后都是调用 nb.barmie.modes.attack.RMIAttack 各个实现类的 executeAttack 方法。

使用流程如下,首先选择攻击目标:

选择可用的攻击方式,例如我这里是反序列化:

选择反序列化 gadget :

输入命令,拼接 payload 并执行,弹出计算器。

可以看到,BaRMIe 针对我们在本文讨论的 RMI 攻击主要是提供了使用 bind 方式攻击 Registry 的攻击,除此之外 BaRMIe 提供了利用一些框架和组件注册的服务进行攻击。

RmiTaste

RmiTaste 是 @_mzer0 在参考了 BaRMIe 之后编写的攻击工具,并且结合 ysoserial 生成利用 gadget。其实 BaRMIe 也是用的 ysoserial 的 payload,但是 RmiTaste 是直接调用。

RmiTaste 提供了 4 种模式:conn,enum,attack,call:

  • conn:测试与目标 Registry 的连接
  • enum:枚举 Registry 中注册的服务
  • attack: 指定反序列化 payload 攻击 Server 端
  • call:调用服务中的方法,需要在本地有跟服务端一样的接口

RmiTaste 的代码清晰可读,并且有部分实现思路与 BaRMIe 相同,这里我就不一一解读,感兴趣的读者自行阅读和测试。

最关键的 attack 逻辑在 m0.rmitaste.rmi.exploit.Attack#invokeMethodPayload 方法中:

这跟我们在攻击 Server 端时下断点修改的思路是一样的,所以 RmiTaste 是攻击 Server 端的实现逻辑。

以上两个工具都提供了攻击 RMI 的一部分能力,但是很显然没有覆盖完全本章的涉及到的全部内容,也并不支持 JEP 290 的 bypass,在测试过程中也发现了若干 BUG,不过依旧都是非常优秀的工具。

八、总结

本篇文章介绍了 RMI ,测试了 RMI 的使用,分析了 RMI 实现的部分流程和源码,针对 RMI 中不同角色的端攻击进行了攻击测试及漏洞成因分析,然后分析了 RMI 包下一些类的实现机制导致的反序列化 gadget,然后简单深入了一下 JEP 290 的影响和绕过,最后学习了两个攻击 RMI 的项目的实现方式。

作为入门及了解目前学到这部分感觉就差不多了,但是这里还有几个点的分析没有做:

  1. JEP 290 详解。
  2. 为什么 UnicastRef 的 payload 能绕 JEP 290 ?
  3. 目前能绕 JEP 290 的 POC 貌似都需要反连,服务器不出网,能不能绕?
  4. JRMP 协议解析及实现。
  5. DGC 层。
  6. 攻击 Client 端实战 —— 反制红队 or 蜜罐。

日后有时间慢慢细说吧。

九、使用 RASP 防御

我是一条小青龙,小青龙,小青龙,我有许多小秘密,小秘密,小秘密,我有许多的秘密,就不告诉你,就不告诉你,就~不~告~诉~你~~

十、引用

https://paper.seebug.org/1091/

https://www.cnblogs.com/binarylei/p/12115986.html

https://xz.aliyun.com/t/7079

https://www.oreilly.com/library/view/learning-java/1565927184/ch11s04.html

http://www.codersec.net/2018/09/%E4%B8%80%E6%AC%A1%E6%94%BB%E5%87%BB%E5%86%85%E7%BD%91rmi%E6%9C%8D%E5%8A%A1%E7%9A%84%E6%B7%B1%E6%80%9D/

https://lalajun.github.io/2020/06/22/RMI%20%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96-%E6%B7%B1%E5%85%A5-%E4%B8%8A/

https://lalajun.github.io/2020/06/22/RMI%20%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96-%E6%B7%B1%E5%85%A5-%E4%B8%8B/

https://github.com/lalajun/RMIDeserialize

https://github.com/NickstaDB/BaRMIe

https://javasec.org

https://mogwailabs.de/en/blog/2019/03/attacking-java-rmi-services-after-jep-290/

https://mogwailabs.de/blog/2020/02/an-trinhs-rmi-registry-bypass/

https://github.com/mogwailabs/rmi-deserialization

https://www.anquanke.com/post/id/200860

https://mp.weixin.qq.com/s/TbaRFaAQlT25ASmdTK_UOg

http://www.code2sec.com/cve-2017-3241-java-rmi-registrybindfan-xu-lie-hua-lou-dong.html

https://mp.weixin.qq.com/s/5xHPCklm3IyBn7vc5_OiUA

https://github.com/threedr3am/ysoserial

https://y4er.com/post/bypass-jep290/

https://openjdk.java.net/jeps/290

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

https://forum.butian.net/share/709