最近分析了下RMI相关的反序列化漏洞,查资料时发现许多分析文章都只关注漏洞点,对功能本身语焉不详。所以这篇文章先具体分析下RMI整体的调用流程,并且点出一些可能出现反序列化问题的地方。下篇写具体的漏洞利用。
RMI全称Remote Method Invocation,远程方法调用,功能是跨jvm调用远程方法。实现RMI的协议叫JRMP,RMI实现的过程中进行了java对象的传递,自然使用了序列化和反序列化,也自然产生了反序列化漏洞。
概述下RMI的流程,RMI里有三个角色:客户端、服务端、注册中心。设计上客户端和服务端不直接通信,而是通过注册中心通信。简单理解三者的关系如下:服务端创建远程对象,并将远程对象在注册中心注册,客户端到注册中心端查找并获取对应的远程对象,最终在服务端调用其方法。
具体实现时,客户端没有直接调用服务器上的对象,也没有直接调用注册中心上的对象,而是操作一个进行网络通信的代理类叫Stub,服务端也一样有一个类似的代理类叫Skel,具体操作都是这两个代理类进行的。RMI的大致流程可以参考https://www.ibm.com/docs/en/sdk-java-technology/8?topic=iiop-rmi-implementation
分析之前先写一个最简单的RMI Demo,直观的感受一下使用的方式。
首先需要定义远程对象类。远程方法调用不是所有类都可以,想进行远程方法调用的类,需要实现一个继承Remote接口的接口,远程方法要抛RemoteException。先定义这个接口:
1 | public interface IRemoteObj extends Remote { |
然后创建远程对象的实现类。为了后续的远程调用,服务端需要把远程对象发布出去,发布的方法有两种:
1、继承UnicastRemoteObject对象
2、自己调用UnicastRemoteObject.exportObject方法
实际上两种方法是一样的,UnicastRemoteObject的构造方法最后也是调用了exportObject
那么定义远程对象类:
1 | public class RemoteObjImpl extends UnicastRemoteObject implements IRemoteObj { |
然后就可以创建服务端和客户端了,服务端一般和注册中心写在一起,做如下几件事:
1、首先创建远程对象,创建时也会进行远程对象的发布。
2、创建注册中心
3、将远程对象绑定到注册中心
1 | public class RMIServer { |
然后是客户端,做以下几件事:
1、获取“注册中心”对象
2、利用注册中心对象获取远程对象
3、调用远程对象上的方法
1 | public class RMIClient { |
执行客户端的main方法后,代码实际是在服务端执行的,客户端可以获取执行结果。
一共有六行代码,那么接下来开始调试观察整个过程,调试时jdk版本是8u65和8u141交替用的,可能有部分代码对不上,但大致逻辑不变。另外由于rmi大部分代码在sun包下面,需要去openjdk下载对应的源码,不然就只能看反编译的var1变量名猜谜语了。
创建远程对象
首先是RemoteObjImpl remoteObj = new RemoteObjImpl();这一行,调用父类UnicastRemoteObject的构造方法。注意这里并没有接收返回值,只是将远程对象发布出去。
1 | protected UnicastRemoteObject() throws RemoteException |
跟进exportObject
1 | public static Remote exportObject(Remote obj, int port) |
出现了一个新的类UnicastServerRef,它代表远程对象的引用。这个类继承了Dispatcher接口,代表由它分发客户端的操作给远程对象。跟进这个类的构造方法:
1 | public UnicastServerRef(int port) { |
又出现了一个新的类LiveRef,那再跟进它的构造方法:
1 | public LiveRef(int port) { |
objID就是一个数字id,不重要。主要看又出现的一个类TCPEndpoint,这个名字很明显和网络通信有关了,跟进TCPEndpoint#getLocalEndpoint方法:
1 |
|
这里比较绕,其实关注主要逻辑就行,最后返回的是一个TCPEndPoint,看下这个类的属性
1 |
|
很明显是一个和网络请求有关的类,主要属性就是域名,端口,还有一个TCPTransport。TCPTransport是真正的处理网络请求的类,这里实际上将TCPEndPoint和TCPTransport进行了绑定。
所以LiveRef里面就是放了一个TCPEndPoint。那么LiveRef可以理解为一个封装了ip和端口的辅助类。另外注意LiveRef是不能序列化的,也就是说并不需要通过序列化方式传递它,只需要传递里面的id、ip和端口信息就行。实际上实例化一个LiveRef只需要ip和端口就够了:
1 | new LiveRef(new ObjID(),new TCPEndpoint(ip,port),true) |
之后回到UnicastServerRef的构造函数那里,调用了父类构造函数
1 | public UnicastServerRef(int port) { |
也就是UnicastRef的构造函数,一个单纯的赋值。
1 | public UnicastRef(LiveRef liveRef) { |
然后创建这一大堆类的过程就结束了,回到了exportObject中来。这里可以简单总结一下前面这些类的关系,有点乱。这里用idea生成个类图:
LiveRef、TCPEndoPint、TCPTransport是处理网络通信的类。UnicastRef、UnicastServerRef是远程对象的引用对象,是宏观上处理网络请求的类,也是实现RMI调用的核心逻辑的类。UnicastRemoteObject就是远程对象的基础类,它并不直接处理网络请求,而是通过里面的UnicastServerRef处理。
目前其实就创建了一个UnicastServerRef,它的ref是了一个LiveRef,ref里面有一个TCPEndpoint叫ep,ep里面有个TCPTransport叫transport。
继续跟进UnicastRemoteObject#exportObject(Remote obj, UnicastServerRef sref)
1 | private static Remote exportObject(Remote obj, UnicastServerRef sref) |
这里把远程对象的ref赋值成刚才新建的那个UnicastServerRef,然后调用了UnicastServerRef#exportObject
1 | public Remote exportObject(Remote impl, Object data, |
这里出现了stub这个变量,很明显这是一个代理类,先看下getClientRef,其实就是新建个UnicastRef,把刚才那个LiveRef放进去。
1 | protected RemoteRef getClientRef() { |
跟进Util.createProxy方法
1 | public static Remote createProxy(Class<?> implClass, |
这是创建stub的逻辑,比较重要。首先判断当前的RemoteObjImpl这个类是否实现了Remote接口,然后判断是否存在该类的Stub类,也就是是否存在RemoteObjImpl_Stub名称的类,如果存在就调用createStub直接实例化一个。
1 | private static RemoteStub createStub(Class<?> remoteClass, RemoteRef ref) |
目前系统里是没有这个类的,所以往下走,看到实际是创建了一个动态代理类,调用处理器用的是RemoteObjectInvocationHandler,把刚才创建那个UnicastRef放了进去。之前创建的远程对象里有个UnicastServerRef,这个stub里面有个UnicastRef,但它们两个里面是同一个LiveRef。毕竟这个代理对象就是代表远程对象的。
所以远程对象的Stub其实就是个动态代理,那么远程调用方法时就是调它的invoke方法,后续调用到的时候再分析。
stub是要传给客户端使用的,客户端通过操作stub的UnicastRef,来调用服务端远程对象的UnicastServerRef,是一个对称关系。
接下来判断这个Stub是否属于RemoteStub,如果是就调用setSkeleton。RemoteStub是jdk里内置的几个通用类,有
Activation$ActivationSystemImpl_Stub
ActivationGroup_Stub
DGCImpl_Stub
RMIConnectionImpl_Stub
RMIServerImpl_Stub
ReferenceWrapper_Stub
RegistryImpl_Stub
这几个,那么这里显然会跳过。
之后会创建Target,这又是一个新的类,跟进它的构造方法:
1 | public Target(Remote impl, Dispatcher disp, Remote stub, ObjID id, |
Target可以理解为是一个远程服务实例,一个Target对应一个远程对象。里面保存了远程对象实例、对应的Stub、对应的远程引用对象UnicastServerRef,之前创建的LiveRef的ObjID。实际上Target就是用LiveRef的ObjID代表了这个远程对象,一个远程对象和一个LiveRef是绑定的。这里就包括了远程调用所需的全部对象了,远程调用就是Stub使用远程引用来调用远程对象上的方法。
创建完Target后调用到ref也就是LiveRef#exportObject
1 | public void exportObject(Target target) throws RemoteException { |
调用了TCPEndPoint#exportObject
1 | public void exportObject(Target target) throws RemoteException { |
又调用了TCPTransport#exportObject
1 | public void exportObject(Target target) throws RemoteException { |
上来调用了一个listen方法,看名字是开一个socket监听
1 | private void listen() throws RemoteException { |
首先获取了之前保存的TCPEndpoint和端口,然后调用了TCPEndpoint.newServerSocket,跟进去实际最后就是创建了个普通的ServerSocket。然后创建了一个新的AcceptLoop类的监听线程并开启。这里实际上就是客户端最后连接的socket,等后续分析客户端调用时再具体分析。
那么实际上服务就已经发布出去了。最后还有一步记录已经发布的Target,调用了TransPort的exportObject
1 | public void exportObject(Target target) throws RemoteException { |
setExportedTransport就是把对应的TCPTransport保存进Target
1 | void setExportedTransport(Transport exportedTransport) { |
putTarget是把对象和target绑定,放进ObjectTable这个类的静态变量objTable里面。
1 | static void putTarget(Target target) throws ExportException { |
实际上在第三行的if语句里,因为获取了DGCImpl.dgcLog变量,触发了DGCImpl类的实例化。这是垃圾回收相关的类,跟进static代码块
1 | static { |
使用单例模式创建了一个DGCImpl对象,这个对象就是RMI的分布式垃圾处理对象,一旦有远程对象被创建,就会实例化这个对象,但也只会创建这一次。后面的代码和UnicastServerRef#exportObject里很像,创建一个代理。但这里和前面不同的是这是一个系统内置类,所以是直接创建了DGCImpl_Stub类,而不是创建的动态代理。并且设置了disp的skeleton是DGCImpl_Skel。最后同样把这些放进Target,把Target保存进ObjectTable。按注释所写,这里没有调用TCPTransport#listen,所以没有开启监听。实际上根据TCPEndpoint#getLocalEndpoint,这个时候这个DGCImpl和刚才创建的远程对象用的是同一个TCPEndPoint。
到这里服务发布就结束了。最后return了一个stub,但代码里并没有接收。
这就是整个的远程对象RemoteObjImpl的创建过程,中间比较乱的地方是创建了一大堆对象,互相包含又互相引用。大概描述一下:
最外层对象是Target,里面保存了远程对象、远程对象的代理对象stub、远程对象的引用对象UnicastServerRef、还有代表这个对象的ObjID。
而远程对象里也保存了服务端远程引用对象UnicastServerRef,stub里保存了一个客户端远程引用UnicastRef,这两个引用里实际都保存了同一个LiveRef对象,里面保存了远程对象对应的ip和端口。
而服务端上还有一个ObjectTable,每创建一个远程对象,就会把对应的Target保存到里面。目前已经创建了两个远程对象,分别是自定义远程对象和系统内置远程对象DGCImpl。
那么第一行就分析完了。没错,目前就分析了一行。
那么接下来分析第二行,LocateRegistry.createRegistry(1099)
创建注册中心
跟进
1 | public static Registry createRegistry(int port) throws RemoteException { |
进构造函数
1 | /** |
System.getSecurityManager()默认是null,走到else里面。其实主要就是创建了一个UnicastServerRef,里面放一个LiveRef,之后调用setup函数。
跟进setup函数:
1 | private void setup(UnicastServerRef uref) |
这里其实和前面UnicastRemoteObject#exportObject一样,唯一区别就是第三个参数变成了true,虽然之前调用过这函数,还是粘一下
1 | public Remote exportObject(Remote impl, Object data, |
这次调用和发布远程对象时有点不同,和创建DGCImpl那里是一样的。首先创建的stub对象不是动态代理生成的了,而是直接找到了sun.rmi.registry.RegistryImpl_Stub,反射创建的。并且RegistryImpl_Stub是继承了RemoteStub的,所以会调用setSkeleton,跟进看一下
1 | public void setSkeleton(Remote impl) throws RemoteException { |
看下Util.createSkeleton
1 | static Skeleton createSkeleton(Remote object) |
其实和createStub一样,直接反射生成了sun.rmi.registry.RegistryImpl_Skel。
接下来和之前一样了,创建Target然后发布。最后把Target放到ObjectTable里面,目前里面有RemoteObjImpl的Stub和RegistryImpl_Stub,还有个DGCImpl_Stub。
那么注册中心的创建就结束了,实际上注册中心也是一个远程对象,整体过程和远程对象的发布基本是一样的,只是有些细节不太一样,主要就是没有用动态代理生成Stub,而是直接创建了jdk里面的类。
接下来就是绑定了,来到第三行
绑定远程对象到注册中心
RegistryImpl#bind
1 | public void bind(String name, Remote obj) |
这里其实非常简单,因为这里并不是远程绑定,就直接调用了RegistryImpl类,把名字和远程对象放到一个叫bindings的HashTable里面。
到这里服务端的流程就走完了,接下来看客户端。
获取注册中心远程对象
跟进LocateRegistry.getRegistry(“127.0.0.1”, 1099)
1 | public static Registry getRegistry(String host, int port) |
接着跟
1 | public static Registry getRegistry(String host, int port, |
通过host和端口创建了一个LiveRef,用它又创建了一个UnicastRef,然后又调用Util.createProxy,根据前面分析这里创建的是一个RegistryImpl_Stub对象。和服务端不同的是这里的ref是一个UnicastRef而不是UnicastServerRef,因为一个对应客户端一个对应服务端。
注意到实际上这个stub并不是从注册中心或者服务端传递过来的,而是直接根据host和port两个参数在客户端生成的,所以并不是rmi中所有stub都是通过序列化传递的。而且创建过程甚至不需要和注册中心通信,也就是说就算没有注册中心也可以创建这个注册中心stub,只不过后续肯定用不了。
最后创建了这个RegistryImpl_Stub对象,接下来通过它去查找注册中心中的远程对象。
查找远程对象
来到IRemoteObj remoteObj = (IRemoteObj) registry.lookup(“remoteObj”);这一行,跟进RegistryImpl_Stub#lookup
openjdk从jdk8u141版本才有RegistryImpl_Stub的源码,之前的jdk版本都调试不了这个类,但代码逻辑基本没变。
1 | public java.rmi.Remote lookup(java.lang.String $param_String_1) |
首先调用UnicastRef#newCall发起一个请求和注册中心建立连接。
1 |
|
把要获取的远程对象名称写入输出流,之后调用了UnicastRef#invoke
1 | public void invoke(RemoteCall call) throws Exception { |
调用了StreamRemoteCall#executeCall
1 | public void executeCall() throws Exception { |
发起网络调用,和服务端进行通信并获取结果,此时也触发了服务端监听线程的run方法,暂时不分析。特别注意当服务端返回异常时,会对输入流调用readObject,也就是说可以创建恶意服务端从这里攻击客户端。也就是只要调用了StreamRemoteCall#executeCall就可能被攻击,再往上一层,就是只要调用UnicastRef#invoke就会被攻击。实际上所有的Stub中都调用了UnicastRef#invoke,也就是RMI中所有的客户端调用都可能被攻击。
回到调用逻辑,没有异常的话,就正常返回RegistryImpl_Stub#lookup,获取到数据流in后,调用了in.readObject获取远程对象的动态代理对象RemoteObjStub,所以这里就是一个反序列化触发点。那么如果注册中心可控,客户端lookup注册中心的时候就会被攻击。
分析完了客户端的调用流程,分析下此时注册中心的调用流程,看看注册中心是怎么找到对应的远程对象的:
获取注册中心远程对象(注册中心端)
之前分析过,发布远程对象时会调用TCPTransprt#listen,实际上listen里面开启了一个AcceptLoop线程,接收到网络请求时会调用它的run方法
1 | public void run() { |
然后调用TCPTransport#executeAcceptLoop,会开启新线程,跟进ConnectionHandler#run
1 | public void run() { |
跟进run0
1 | private void run0() { |
大部分都是在解析网络包之类的,看到有根据POST和JRMI关键字解析的代码。继续跟进调用流程,走进handleMessages
1 |
|
根据读取的op不同进行不同的操作,一般就是走进第一个case,跟进serviceCall
1 |
|
读取id,根据id到IbjectTable中查找对应的Target,获取Target中的远程引用disp,然后调用disp.dispatch,也就是UnicastServerRef#dispatch
1 | public void dispatch(Remote obj, RemoteCall call) throws IOException { |
skel != null时会调用oldDispatch,跟进
1 | private void oldDispatch(Remote obj, RemoteCall call, int op) |
unmarshalCustomCallData是空函数,所以来到了skel.dispatch,实际上也就是根据Skel的不同调用对应的dispatch方法,那这里就会调用RegistryImpl_Skel#dispatch
这是jdk8u141里面的代码,旧版本是没有RegistryImpl.checkAccess的,也就是可以远程调用所有方法。加上这个限制后只能远程调用lookup和list方法了,其他方法只能本地调用。
1 | public void dispatch(java.rmi.Remote obj, java.rmi.server.RemoteCall call, int opnum, long hash) |
可以看到到处都是readObject,也就是说客户端可以通过传输恶意对象攻击注册中心。实际上这里也能看出,对注册中心来说,服务端和客户端没什么本质区别,只是一个调用lookup一个调用bind。所以没有ip限制的版本里,服务端也能直接远程攻击注册中心。
这里还有个细节,server.lookup得到的是一个RemoteImplObj对象,通过writeObjecct写入输出流,但最后客户端反序列化得到的却是一个动态代理对象。这是因为这里的out是ConnectionOutputStream,父类是MarshalOutputStream,MarshalOutputStream的构造方法里调用了enableReplaceObject(true),这样在序列化时会调用它的replaceObject
1 | /** |
所以传递过去的是代理对象而不是远程对象本身。
分析完了在注册中心查找远程对象的部分,接下来分析最后一行,remoteObj.sayHello()
远程方法调用(客户端)
之前分析已经知道remoteObj是一个动态代理了,那么调用它的方法时自然是走到了调用处理器类里面,跟进RemoteObjectInvocationHandler#invoke
1 | public Object invoke(Object proxy, Method method, Object[] args) |
调用的是else里面的invokeRemoteMethod
1 | private Object invokeRemoteMethod(Object proxy, |
最后调用的是UnicastRef里面另一个重载的invoke,一样调用了executeCall,那么这里客户端也可能被攻击。
1 | public Object invoke(Remote obj, |
可以注意到如果返回值type不是void会调用unmarshalValue
1 | protected static Object unmarshalValue(Class<?> type, ObjectInput in) |
当返回值类型不是这几种基础类型时就会调用反序列化,也就是服务端可以通过返回恶意对象来攻击客户端。
接下来分析客户端调用时服务端的处理流程。
远程方法调用(服务端)
和在注册中心查找远程对象时候的前半段一样,来到UnicastServerRef#dispatch
1 | public void dispatch(Remote obj, RemoteCall call) throws IOException { |
不同的是这回不会进入old Dispatch,而是继续向下走,之后调用到unmarshalValue,前面写过这个方法会触发反序列化,那么客户端可以通过在参数值传payload来攻击服务端。
目前为止分析了客户端、注册中心、服务端之间的调用过程,但实际上服务端上还有DGC服务,接下来分析下DGC服务的调用过程。
DGC调用流程(客户端)
DGC(Distributed Garbage Collection)指分布式垃圾回收,前面的流程也提到了,远程对象发布时就会伴随着DGC对象的创建。前面介绍了在远程对象发布时服务端会生成一个DGCImpl_Stub对象,那么这个对象是也会像普通远程对象一样反序列化传递给客户端,还是像RegistryImpl_Stub一样在客户端本地生成呢?直觉上的感觉应该是后者,首先因为DGC和注册中心一样都是jdk自带类,第二是刚才分析了整个调用流程并没有发现通过序列化传递DGC代理对象的地方。
那么看看DGCImpl_Stub的是怎么创建的,直接对DGC接口find usage,找到两处创建Stub的地方,第一个是之前说的DGCImpl的静态代码块,第二个在DGCClient$EndpointEntry的构造函数里
1 | private EndpointEntry(final Endpoint endpoint) { |
下断点,发现入口是在RegistryImpl_Stub#lookup的ref.done:
1 | public void done(RemoteCall call) throws RemoteException { |
跟进StreamRemoteCall#done
1 | public void done() throws IOException { |
调用了ConnectionInputStream#registerRefs
1 | void registerRefs() throws IOException { |
这里有个if,目前不需要关注细节,总之客户端调用时是可以进去的,那么跟进DGCClient#registerRefs
1 | static void registerRefs(Endpoint ep, List<LiveRef> refs) { |
这是一个do while循环,先进入EndpointEntry#lookup
1 | public static EndpointEntry lookup(Endpoint ep) { |
可以看到触发了EndpointEntry的实例化。那么这里就创建了DGCImpl_Stub,接下来看下哪里调用了DGCImpl_Stub里面具体的方法。实际上就在EndpointEntry的构造函数里开启的新线程,看看RenewCleanThread#run
1 | public void run() { |
可以看到里面循环调用makeDirtyCall
1 | private void makeDirtyCall(Set<RefEntry> refEntries, long sequenceNum) { |
dgc.dirty这里调用了DGCImpl_Stub#dirty
1 | public java.rmi.dgc.Lease dirty(java.rmi.server.ObjID[] $param_arrayOf_ObjID_1, long $param_long_2, java.rmi.dgc.Lease $param_Lease_3) |
调用了in.readObject(),也就是说DGC客户端调用dirty时有可能被DGC服务端攻击。而前面的每个stub的调用都会触发ref.done,也就是每次stub的调用都可能被DGC服务端攻击。为了看源码用的是8u141这个版本,有个过滤器,但是低版本没有。
接下来分析下服务端的调用流程
DGC调用流程(服务端)
实际上这几个服务端的前半部分都是一样的,都是到UnicastServerRef#dispatch。然后DGCImpl_Stub也是jdk内置类,所以进入oldDispatch,最后进入DGCImpl_Skel#dispatch。clean方法基本不会调用,就看dirty部分
1 | case 1: // dirty(ObjID[], long, Lease) |
服务端也调用了in.readObject,也就是说DGC客户端可以攻击DGC服务端。
目前为止就分析完了一次正常的RMI调用的完整流程,那么总结下整个过程中的反序列化点以及导致的攻击,这里不具体区分触发的服务是注册中心还是DGC之类的,只按客户端/服务端/注册中心分类:
1、攻击客户端:
RegistryImpl_Stub#lookup->注册中心攻击客户端
DGCImpl_Stub#dirty->服务端攻击客户端
UnicastRef#invoke->服务端攻击客户端
StreamRemoteCall#executeCall->服务端/注册中心攻击客户端
2、攻击服务端
UnicastServerRef#dispatch->客户端攻击服务端
DGCImpl_Skel#dispatch->客户端攻击服务端
3、攻击注册中心
RegistryImpl_Skel#dispatch->客户端/服务端攻击注册中心
通过完整的分析RMI调用流程,发现了上述反序列化攻击点,下篇文章会实现具体的攻击方式。
参考链接
https://docs.oracle.com/javase/tutorial/rmi/implementing.html
https://docs.oracle.com/javase/8/docs/technotes/guides/rmi/index.html
http://scz.617.cn:8/network/202002221000.txt
https://www.jianshu.com/p/2c78554a3f36
https://last-las.github.io/2020/12/10/%E6%B7%B1%E5%85%A5%E5%AD%A6%E4%B9%A0rmi%E5%B7%A5%E4%BD%9C%E5%8E%9F%E7%90%86
https://www.cnblogs.com/binarylei/p/12115986.html