前面总结了几种针对RMI的攻击方式,包括以下几种:
客户端攻击注册中心
服务端攻击注册中心
注册中心攻击客户端
服务端攻击客户端
客户端攻击服务端
DGC客户端攻击DGC服务端
DGC服务端攻击DGC客户端
JRMP服务端攻击JRMP客户端
但是在jdk8u121之后,java引入了新的安全机制JEP290,这篇文章分析下该机制对RMI相关攻击的影响以及后续的绕过方法
在8u121之后,RMI做了很多安全修复,这里列举一下以及分析下对各种攻击的影响,这里不分析具体的代码,只说现象:
1、RegistryImpl_Skel强制RegistryImpl.checkAccess验证
限制服务端和注册中心必须在同一host,相当于强制将服务端和注册中心绑定在一起,也就没有这两者之间的远程互相攻击了。
2、配置了registry过滤器
RegistryImpl_Skel里面的对象反序列化时会进行白名单校验,内容如下:
1 | if (String.class == clazz |
没有任何一条完整的反序列化攻击链能通过这个白名单,这样前面攻击注册中心的方法都失效了。
但RegistryImpl_Stub里面的方法没有过滤,毕竟为了功能正常使用是没办法白名单的,所以注册中心攻击客户端依然可行。
3、配置了DGC过滤器
DGCImpl_Skel和DGCImpl_Stub里面的对象反序列化时会进行白名单校验,内容如下:
1 | return (clazz == ObjID.class || |
同理,前面攻击DGC的方法也失效了。
目前不受影响的攻击方法还剩下:
客户端攻击服务端
服务端攻击客户端
注册中心攻击客户端
JRMP服务端攻击JRMP客户端
攻击客户端的场景不常见,而客户端攻击服务端需要知道远程接口。有没有办法不受限制的攻击服务端呢?
从正常的调用过程来看,所有直接的反序列化点都已经记录下来了,那么可以找一下是否有上层调用。找了下发现只有一个点有,就是StreamRemoteCall#executeCall,其实也好理解,因为这个方法是JRMP层的,相对更底层一点,和具体的Stub/Skel类没有关系,调用的地方更多。
并且这里触发点不在RegistryImpl/DGCImpl中,所以不受前面说的过滤器的影响,也就是说可以绕过JEP290对RMI反序列化攻击的过滤。接下来分析具体如何利用。
绕过JEP290攻击服务端/注册中心
StreamRemoteCall#execute函数只在UnicastRef里面的两个invoke调用了。其实invoke就是调用远程方法,只有Stub才会调用这个函数,看看前面的出现的几种Stub里面调用invoke的地方:
1、RegistryImpl_Stub中的list/lookup/bind/rebind/unbind方法
2、RemoteObjectInvocationHandler#invokeRemoteMethod
3、DGCImpl_Stub中的dirty/clean
如果能在服务端/注册中心上创建上述Stub并且调用对应的方法,那么服务端就变成JRMP客户端,导致被攻击。天秀。
那么有没有机会呢,首先分别看一下这三个Stub的创建流程。前面分析知道实际上Stub对象都是由Util#createProxy创建的,那么往上找调用链看看:
1、RegistryImpl_Stub是由LocateRegistry#getRegistry创建的,服务端不会调用。
2、远程对象Stub是在UnicastServerRef#exportObject创建,保存在Target的stub参数里面。但是没有找到服务端使用这个stub的地方。
3、DGCImpl_Stub有两个地方创建,前面分析过,第一个是DGCImpl的静态代码块。第二处是在DGCClient$EndPointEntry的构造函数,再往上是DGCClient$EndPointEntry#lookup,然后是DGCClient#registerRefs。
再往上找到两处
1)LiveRef#read
DGCClient.registerRefs在一个else里面
1 | if (in instanceof ConnectionInputStream) { |
判断输入流是不是ConnectionInputStream,在RMI流程里面是进不去的。
2)ConnectionInputStream#registerRefs
1 | void registerRefs() throws IOException { |
这里有个if,需要incomingRefTable不为空才能进入。它的上层调用是StreamRemoteCall#releaseInputStream,再往上找调用点
1 UnicastServerRef#dispatch
2 RegistryImpl_Skel#dispatch
3 DGCImpl_Skel#dispatch
4 StreamRemoteCall#done
前三个点在服务端,跟了下正常调用流程时incomingRefTable都是空,不会触发DGC#registerRefs创建DGCImpl_Stub。而StreamRemoteCall#done的调用点都和UnicastRef#invoke重复了,没啥意义。
那么正常流程在服务端创建不了DGCImpl_Stub。所以现在需要想办法改变代码执行逻辑,让incomingRefTable不为空,搜索修改它的地方,发现只有一处ConnectionInputStream#saveRef
1 |
|
并且它的调用也只有一处LiveRef#read
1 | public static LiveRef read(ObjectInput in, boolean useNewFormat) |
再往上发现LiveRef#read在UnicastRef#readExternal里调用了,这就有意思了。readExternal就是实现Externalize接口的类反序列化时触发的方法,那也就是说如果同一个输入流里有UnicastRef对象反序列化了,并且之后调用了releaseInputStream就能触发DGCClient$EndPointEntry的构造函数,最终触发makeDirtyCall发起UnicastRef#invoke,导致被攻击。顺便也发现了某些情况UnicastRef可以作为一个连接readExternal和readObject的gadget。
前面分析过,正常调用流程里,当客户端调用RegistryImpl_Stub#lookup,对远程代理对象反序列化时,会创建DGCImpl_Stub。实际上就是因为当时反序列化了远程对象stub里面的RemoteObjectInvocationHandler,调用其父类RemoteObject#readObject,里面反序列化了UnicastRef。
那么目前就知道了如何在服务端上创建一个DGCImpl_Stub并且调用dirty函数发送JRMP请求,但是如何把代码逻辑跳转到这里呢?实际上Java里面改变运行着的代码的逻辑的方法就那么几种:动态反射、动态代理、动态类加载、反序列化。这里很明显了,就是通过反序列化改变代码执行逻辑,相当于把原本分析的代码执行终点,也就是反序列化,变成了新的起点。正好UnicastRef是在注册中心反序列化白名单里面的。
为什么要这么折腾呢?还不是因为被过滤了,只能想办法把一个受限的反序列化转变成不受限的反序列化。
重新看看RegistryImpl_Skel
1 | case 2: // lookup(String) |
由于添加了ip校验,虽然别的case逻辑也差不多,但实际上远程攻击只有lookup这一个反序列化点能用了。注意这里的逻辑,首先进行反序列化,然后finally里面调用call.releaseInputStream()。那么如果我传一个UnicastRef进去,这不就是上面的先反序列化UnicastRef修改incomingRefTable,然后releaseInputStream触发makedirtyCall的路径吗。
那么现在知道,用Reigstry_Skel#dispatch里面的反序列化做入口点,就可以在注册中心成功创建一个可控的DGCImpl_Stub并触发JRMP请求。并且之前分析过是一直循环调用的,相当于一个自动回连的后门。
具体的实现分两部分,第一部分是构造恶意对象,让注册中心发起dirty请求,这里和前面客户端攻击注册中心类似
1 | public class JRMPRegistryExploit { |
第二部分是恶意服务端,这部分就是ysoserial/exploit/JRMPListener了。
1 | java -cp ysoserial.jar ysoserial.exploit.JRMPListener 7777 CommonsCollections6 calc |
那么是不是也能用这种方法打服务端呢?看看服务端反序列化的地方:
1 | try { |
unmarshalParameters里面反序列化,最后调用releaseInputStream,所以理论上也是可以的,但是没什么意义,这个方法的优势在于bypass JEP290,打服务端本来也不受影响。而且这里一样需要知道远程接口才可以。
其他尝试
再找找还有没有其他触发readObject的点,在RMI相关的包里搜索发现还有一个TCPEndpoint#read
1 |
|
找调用,发现在LiveRef#read里,看看怎么进这个case,发现在UnicastRef2#readExternal
1 | public void readExternal(ObjectInput in) |
看上去和前面的JRMP反打差不多,也是一个反序列化触发的反序列化,尝试攻击这里。
1 | public class TCPEndPointExploit { |
失败了,原因是虽然UnicastRef2继承了UnicastRef,可以通过过滤器。但之后在TCPEndPoint#read里对输入流反序列化时实际上和前面用的是同一个输入流,也就是TCPEndPoint中的反序列化依然会被registryfilter拦截,无法Bypass JEP290。低版本还是可以打的,但多少有点多此一举。反正之前没见别人发过,记录下。
实际上在整个调用流程中,我们能控制和改变代码执行走向的只有反序列化点。而前面介绍的JRMP反打就是将受限反序列化转化成不受限反序列化,果然反序列化漏洞就是戴着镣铐跳舞。
JDK8u231修复与绕过
而在jdk8u231中,RMI又增加了新的安全措施。
首先是对注册中心进行了加固,更新后的RegistryImpl_Skel#dispatch
1 | case 2: // lookup(String) |
在反序列化异常后会进入call.discardPendingRefs(),其实就是把incomingRefTable清空。那么lookup的时候类型转换肯定会抛异常,也就没办法攻击了。
还有一处修复在DGCImpl_Stub
1 | public void clean(java.rmi.server.ObjID[] $param_arrayOf_ObjID_1, long $param_long_2, java.rmi.dgc.VMID $param_VMID_3, boolean $param_boolean_4) |
把过滤器放在了invoke之前,这样invoke里面触发的反序列化也被拦截了。这两处哪一个对之前的JRMP攻击方式来说都是致命的,也就是说8u231之后原本用DGCImpl_Stub#dirty触发的JRMP反打的攻击也失效了。
永不言败的安全人继续尝试绕过。从前面的分析可以知道,如果想在不知道远程接口的情况想攻击注册中心/服务端,目前能控制的最大范围就是注册中心和DGC的filter里面限制的几个类。而如果想实现攻击,要满足几个条件:
1、找到一处不受限制的反序列化
2、白名单类可以通过反序列化触发上述不受限的反序列化
3、触发点就在readObject中
之前的JRMP攻击方式满足前两点,但不满足第三点,因为它的触发点实际在releaseInputStream
那么开始找,第一点不好说,从第二点入手。只能硬趟白名单,看看都有哪些:
1、RegistryImpl_Skel,允许Remote/UnicastRef/RMIClientSocketFactory/RMIServerSocketFactory/ActivationID/UID
2、DGCImpl_Skel,允许ObjID/UID/VMID/Lease
看看这些类哪些是可以序列化并且重写了readObject/readExternal之类改变调用流程的,找了下有这些:
UnicastRef
UnicastRef2
UnicastServerRef
ActivationID
RemoteObject
UnicastRemoteObject
就这么几个,只能指着这几个类出菜了。
UnicastRef和UnicastRef2,已经在前面的逻辑用过了,并且也走不出新的路径。
UnicastServerRef反序列化基本什么也没做,忽略。
ActivationID反序列化时也是直接从输入流反序列化,一样会被过滤。
1 | private void readObject(ObjectInputStream in) |
但可以创建RemoteRef对象并调用readExternal,那么代码执行路径扩展到所有有无参构造的RemoteRef的无参构造方法和readExternal。但看了下还是没找到能利用的地方。
RemoteObject和ActivationID差不多,都是只能走到UnicastRef的反序列化。
最后的希望是UnicastRemoteObject,看看它的readObject
1 |
|
reexport
1 | /* |
这是一个发布的过程,把这个对象自己发布了出去。
那直觉上感觉这里不就相当于在服务端发布一个已知的远程对象吗,尝试用客户端攻击服务端的方式攻击,试验下:
1 | public class ServerExploit { |
果不其然,失败了。其实服务是发布出去了,失败的原因是找不到远程方法。分析发现是因为服务端获取远程方法的逻辑是读取远程接口,也就是继承Remote的那个接口里的方法。但实际上UnicastServerObject只继承了Remote一个接口,而Remote又是一个空接口。也不知道是不是巧合,反正这个攻击思路走不通。
再尝试新的攻击可能,现在只有一条路就是UnicastRemoteObject的反序列化流程,而能控制的数据只有里面的这几个变量:
1 | private int port = 0; |
以及父类RemoteObject里面的ref。但是ref在对象发布时会被赋值成一个新建的UnicastServerRef,也用不了。
所以能用的就俩接口变量,目标是在反序列化流程里找到触发任意反序列化的点并且把payload传进去。
咋看都是个不可能的任务,但是有猛人完成了,就是An Trinh在2019年发现的https://mogwailabs.de/en/blog/2020/02/an-trinhs-rmi-registry-bypass/
其实说难也难,说直接也直接。因为已经被过滤的没有别的选择了,目前能控制的只有两个接口,而我们还需要放自己的payload等信息,那么往接口里放东西只有一种方法,就是用动态代理。而满足白名单过滤的动态代理也只有一个,就是RemoteObjectInvocationHandler。
1 | public Object invoke(Object proxy, Method method, Object[] args) |
可以看到RemoteObjectInvocationHandler的invoke最后还真就会调用UnicastRef#invoke,只能说世间充满了巧合。
那没啥说的了,就找调用ccf/ssf这两个变量的方法了,跟了一下就找到了。在TCPTransport#listen里面调用了TCPEndPoint#newServerSocket
1 | ServerSocket newServerSocket() throws IOException { |
对ssf调用了函数,那么把ssf设置成一个代理RMIServerSocketFactory接口的动态代理,里面放RemoteObjectInvocationHandler,调用这里时最终就触发了executeCall,造成不受限的反序列化。借用动态代理的方法和CC1的AnnotationInvocationHandler异曲同工。实现下:
1 | public class UnicastRemoteObjectExploit { |
一样是用JRMPListener监听。不像DGC那种循环触发的,这个是一次性的
这个方法其实比之前的反序列化+releaseInputStream触发条件更简单,不受那个incomingRefTable的限制,因此也绕过了jdk8u231的第一处过滤。触发点也没走DGCImpl_Stub,绕过了jdk8u231的第二处过滤。
JDK8u241修复
当然最后这个绕过方法也是惨遭毒手,在jdk8u241又进行了修复,直接在ObjectInputStream里加了个readString方法,用在了RegistryImpl_Skel里面
1 | ObjectInputStream in = (ObjectInputStream)call.getInputStream(); |
这样一来注册中心里唯一的远程反序列化点也没有了,基本上把反序列化攻击注册中心的棺材板盖严实了。前面所有攻击注册中心的方法也都失效,应该也不会有新的方法了,RMI注册中心已安全。
但其实RegistryImpl_Skel#bind之类的还是有反序列化点的,那里是不可能加这个类型限制的,退而求其次,假如是提权之类的场景,能不能本地打呢。
但其实还是不行,因为8u241顺便修了RemoteObjectInvocationHandler#invokeRemoteMethod
1 | Class<?> decl = method.getDeclaringClass(); |
如果调用方法的类没有实现Remote就不会调用ref.invoke,前面的RMIServerSocketFactory就被拦住了。寄了寄了。
而在jdk8u231,能远程攻击服务端或注册中心的方法,除了上面说的An Trinh的方法,就只有在知道远程接口的情况下攻击服务端了。相同的道理UnicastRef#unmarshalValue方法也在这8u241版本做了一些修复:
1 | protected static Object unmarshalValue(Class<?> type, ObjectInput in) |
和RegistryImpl_Skel一样的修复逻辑,如果参数类型是String就调用readString方法,而不是直接调用readObject。不过如果远程方法参数不是String而是其他类型依然可以攻击。
那么目前已有的RMI反序列化攻击方式就整理完了,分析的过程里有几次觉得找到了新的bypass,最后发现都有各种原因不能实现,但分析后觉得以后应该也没有新的bypass了。
最后整理下各种攻击方法和对应的版本,做了个表:
参考链接
https://xz.aliyun.com/t/7932
https://mogwailabs.de/en/blog/2019/03/attacking-java-rmi-services-after-jep-290/
https://mogwailabs.de/en/blog/2020/02/an-trinhs-rmi-registry-bypass/
https://blog.0kami.cn/2020/02/06/java/rmi-registry-security-problem/
http://blog.nsfocus.net/registry-whitelist-bypass-0424/