攻防技术,攻击JavaRMI概述

  • 作者:
  • 时间:2022-03-11 09:43:25
简介 攻防技术,攻击JavaRMI概述

可能师傅们也遇到过这种情况:因为问题A跟进某个类方法,调试期间遇到一个更纠结的问题B,在费尽周折终于搞清楚后,蓦然回首却忘了自己在干什么(问题A)......

JavaRMI与相关攻击面错综复杂,因此本文仅以时间线梳理各种绕过方法与缓解措施的主要逻辑,而不会详述具体调用栈(但会指出关键类方法)。大家可以自行跟进加深印象,也可以参考其它师傅的文章云调试。

RMI全称是Remote Method Invocation(远程方法调用),可以理解为远程过程调用(RPC)的Java实现版本。它使得应用程序员可以像调用本地方法一样(stub.func(arg)),调用远程主机上提供的方法,方法实际最终在远程主机上执行(serv.func(arg)),原因是本地类封装了与远程类基于序列化的网络通信(socket)。

我们将上述提供业务方法的远程主机称为服务端,将发起方法调用的本地主机称为客户端。服务端会监听在一个随机端口上,将自己的通信地址等信息封装后(stub)由某种途径交给客户端。这种途径就是默认监听在1099端口上的注册端。

RMI全称是Remote Method Invocation(远程方法调用),可以理解为远程过程调用(RPC)的Java实现版本。它使得应用程序员可以像调用本地方法一样(stub.func(arg)),调用远程主机上提供的方法,方法实际最终在远程主机上执行(serv.func(arg)),原因是本地类封装了与远程类基于序列化的网络通信(socket)。

我们将上述提供业务方法的远程主机称为服务端,将发起方法调用的本地主机称为客户端。服务端会监听在一个随机端口上,将自己的通信地址等信息封装后(stub)由某种途径交给客户端。这种途径就是默认监听在1099端口上的注册端。

攻击业务危险方法

如果远程方法实现中存在输入流可控的危险方法:

clazz {
    func(arg) {
        exec(arg)
    }
}

理论上就跟利用Web应用漏洞一样,RMI此时就是一个毫无存在感的中间层。实际情况是基本没有能直接调用到的危险方法,而且也不知道危险方法的调用接口(clazz.func(arg))。

  • java.rmi.server.useCodebaseOnly在7u21|6u45时由默认false改为true,下文均需要被攻击端存在gadgets。另外为了行文流畅,8u241对String类型反序列化作的变动放在文末统一说明。

攻击远程方法参数

当服务端并不存在危险业务方法,但我们可以拿到远程方法接口类(比如开源应用)。发现它接收一个Object类型参数(func(Object arg, ...)),在此处传入恶意payload对象,服务端从网络接收数据并试图反序列化还原对象时,就会触发gadgets。

相比Object类型,远程方法入参更有可能是int、boolean等基本数据类型,或是String以及其它封装类。客户端会根据方法名及参数类型生成哈希,服务端收到这个哈希就能知道调用的是哪一个方法(sun.rmi.server.UnicastServerRef#hashToMethod_Map)。

那么把恶意对象强行塞给非Object参数能否触发反序列化执行呢?当远程方法参数为非基本数据类型时,sun.rmi.server.UnicastRef#unmarshalValue就会进入readObject所在分支。通过修改网络数据、修改字节码、修改内存对象、修改RMI客户端实现 任意一种方法,将方法哈希修改为服务端存在且符合类型要求的远程方法哈希,即可触发强行塞入的恶意对象。

攻击RMI注册端

抛开具体业务远程方法,Registry也提供了基本方法:

  • bind(String, Remote)
  • list()
  • lookup(String)
  • rebind(String, Remote)
  • unbind(String)

除了list方法不会向注册端传递参数,其余四个方法的任意参数都会被注册端接收并readObject(sun.rmi.registry.RegistryImpl_Skel#dispatch),要解决的问题是如何让恶意对象符合类型要求。

BaRMIe采用代理注册端,通过handleData方法替换字节流数据,最终形成bind(payload, null)。

ysoserial.exploit.RMIRegistryExploit采用动态代理,通过createMemoitizedProxy方法封装为Remote,最终形成bind("pwnedXXX", payload)。

我们也可以自己创建一个类并实现Remote, Serializable,将payload对象赋值放入任意类属性。虽然注册端递归反序列化后最终会找不到自定义类报错,但在此之前会先触发gadgets。

在JEP290(8u121,7u131,6u141)加入的sun.rmi.registry.RegistryImpl#registryFilter会限制递归深度、数组长度,并基于白名单递归检查类型,会拦截payload的最终执行。

此外在8u141将dispatch中原来的先readObject再RegistryImpl.checkAccess(默认仅允许注册端与服务端同地址),修正为了先作检查。会使攻击者伪装成服务端向注册端发起bind/rebind/unbind的攻击失效,但不会影响攻击者作为客户端发起lookup。

攻击DGC服务

DGC全称是Distributed Garbage Collection,顾名思义其实就是GC(垃圾回收)的分布式方案。与Registry类似,DGC服务也提供了基本方法dirty()和clean()。客户端需要用到注册端服务端的远程对象时,会通过dirty申请。相应的,当客户端不再需要时则会通过clean注销。

而同样与Registry类似,注册端服务端上的sun.rmi.transport.DGCImpl_Skel#dispatch,会接收DGC客户端经由dirty或clean传过来的部分数据并readObject,要解决的问题是sun.rmi.transport.DGCImpl_Stub#dirty并没有一个参数接口用于传递对象。

对此,ysoserial.exploit.JRMPClient按照DGC通信的固定格式直接走socket通信在相应位置写入了payload,最终形成[0x4a524d49, 2, 0x4c, 0x50| 2, 0, 0, 0, 1, -669196253586618813L, payload]的数据流。

在JEP290加入的sun.rmi.transport.DGCImpl#checkInput逻辑也与之前相同,会拦截payload的最终执行。

攻击JRMP客户端

RMI和DGC服务都是基于JRMP协议通信,就像HTTP应用与TCP之间会有Web服务器处理HTTP协议一样,JRMP也有相应的处理模块。我们将主动发起JRMP请求的一方称为JRMP客户端,将监听JRMP请求的一方成为JRMP服务端。

JEP290主要通过白名单限制了RMI服务端与DGC服务(注册端或服务端)readObject时能使用的类,却没有限制JRMP客户端处理JRMP服务端返回的异常信息readObject(sun.rmi.transport.StreamRemoteCall#executeCall)。

同上文所述,JRMP服务端的sun.rmi.transport.Transport#serviceCall等位置都是直接writeObject(Exception),所以需要实现恶意JRMP服务端在JRMP客户端发起连接时,将payload走异常接口给抛回去。

ysoserial.exploit.JRMPListener就是这种实现,构造为[81, 2, payload]的数据返回给JRMP客户端,使其进入异常处理触发payload。

寻找Gadgets

  • DGCClient implements the client side of the RMI distributed garbage collection system. The external interface to DGCClient is the registerRefs() method. When a LiveRef to a remote object enters the JVM, it must be registered with the DGCClient to participate in distributed garbage collection. When the first LiveRef to a particular remote object is registered, a dirty() call is made to the server-side DGC for the remote object.

可以知道当LiveRef加载进JVM后,会通过registerRefs注册并发起dirty请求。ysoserial.payloads.JRMPClient以sun.rmi.server.UnicastRef#readExternal充当反序列化入口,在其中装填了sun.rmi.transport.LiveRef,借由DGC机制便可主动发起JRMP请求。

如果有地方能够触发这个反序列化入口,就可以让它成为JRMP客户端向恶意JRMP服务端发起请求,进而走没被限制的异常readObject触发最终payload。刚好UnicastRef是RegistryImpl#registryFilter的白名单类,原汤化原食了。

触发反序列化

ysoserial.exploit.RMIRegistryExploit利用的registry.bind(name, remote)在8u141后失效了,ysomap.exploits.rmi.RMIRegistryExploit使用的Naming.lookup(registry, remote)则依然有效。

同样要解决让第一步的payload符合类型要求的问题。要么依托原RMI客户端发起lookup,找一个实现了Remote且能存放LiveRef的类(比如RemoteObjectInvocationHandler),或者利用递归反序列化特性自己构造类;要么重新实现RMI客户端强行发送数据。

  • 前者找类并测试会遇到一个与类型相关很有意思的问题

8u231捕获了在加载Ref(第一步的payload)后会触发的异常,在发起JRMP请求(releaseInputStream)之前清除了Ref。

并且将dirty和clean中的setObjectInputFilter(DGCImpl_Stub::leaseFilter)过滤器提到了JRMP请求发起之后、恶意JRMP服务端最终gadgets触发之前(ref.invoke(call))的位置。

细看可以发现缓解措施针对的都是加载Ref(第一步的payload)之后的过程,当有一个gadgets能在加载自身就触发JRMP请求,就能绕过这些过滤,An Trinhs找到了一条这样的链:

1. UnicastRemoteObject#readObject

2.RMIServerSocketFactory#createServerSocket

3.RemoteObjectInvocationHandler#invoke

利用动态代理机制封装了类,同时gank了本来的方法调用,将其引入invoke。

从OracleJDK-8u241/OpenJDK-8u242开始将多处readObject再转型String的地方修改为了SharedSecrets.getJavaObjectInputStreamReadString().readString,修复了lookup时的反序列化入口,并且影响上文攻击远程方法参数攻击RMI注册端中涉及String类型参数的地方。

反制攻击方

最后稍微聊一下喜闻乐见的反制问题,ysoserial在477ecb8更新了一个无限制的registry.list(),哪怕在8u261中依然是写着赤果果的readObject:

而在那个commit之前,也许作者是想借由原生的registry.bind(name, remote)得到注册端回显,方便攻击时判断gadgets环境,为此其戴了ExecCheckingSecurityManage作为安全措施(/doge),但还是可以通过CC链读写文件等方式间接溯源或者RCE。

参考内容

Attacking Java RMI services after JEP 290

针对RMI服务的九重攻击 - 下

JEP 290: Filter Incoming Serialization Data

Understanding distributed garbage collection

30行代码透彻解析RPC