源码层面梳理Java RMI交互流程
0x00 RMI简述
RMI(Remote Method Invocation)是Java语言执行远程方法调用的Java API,相当于面向对象的远程过程调用(RPC),支持直接传输序列化的Java类和分布式垃圾回收。
整体时序图如下:
由于其内部直接以来序列化机制,近些年来部分低版本JDK的RMI交互过程中存在反序列化漏洞,想必大家都有所耳闻。最近抽空把RMI议的源码仔细分析了一遍,发现协议内部还是有很多细节很有趣,值得仔细梳理一下。本文将包含大量调试截图以及我自己画的对象结构图,旨在帮助理清RMI协议的交互细节,权当抛砖引玉,如有不足之处,欢迎师傅们多多指教。
0x01 测试Demo
远程服务接口及实现类:
public interface RemoteInterface extends Remote {
public String sayHello() throws RemoteException;
public String sayHello(Object name) throws RemoteException;
public String sayGoodbye() throws RemoteException;
}
public class RemoteObject extends UnicastRemoteObject implements RemoteInterface {
protected RemoteObject() throws RemoteException {
}
@Override
public String sayHello() throws RemoteException {
return "Hello";
}
@Override
public String sayHello(Object name) throws RemoteException {
return name.getClass().getName();
}
@Override
public String sayGoodbye() throws RemoteException {
return "Bye";
}
}
服务端+注册中心:
public class Server {
public static void main(String[] args) throws Exception{
// 创建远程对象 RemoteSeviceImpl
RemoteInterface remoteObject = new RemoteSeviceImpl();
// 创建注册中心 RegistryImpl
Registry registry = LocateRegistry.createRegistry(1099);
registry.bind("Hello", remoteObject);
System.out.println("Server Start");
}
}
客户端:
public class RMIClient {
public static void main(String[] args) throws RemoteException, NotBoundException {
// RegistryImpl_Stub
Registry registry = LocateRegistry.getRegistry("localhost", 1099);
// $Proxy
RemoteInterface stub = (RemoteInterface) registry.lookup("Hello");
System.out.println(stub.sayHello());
}
}
接下来按照协议流程逐步调试。
0x02 创建远程对象
RemoteInterface remoteObject = new RemoteSeviceImpl();
开启调试,会停在UnicastRemoteObject#exportObject方法处:
暴露远程对象,obj是我们的服务实现类,new UnicastServerRef(port)
就是处理网络请求的逻辑实现,port初始默认值是0,最后是随机端口。
注意,这里的端口是指远程对象的端口,而不是注册中心的1099。
跟进UnicastServerRef方法:
跟进LiveRef方法:
跟进TCPEndpoint.getLocalEndpoint(port)方法:
TCPEndpoint处理网络请求,没必要继续往深跟了。 回到上层: 构造函数执行完毕,当前liveRef的属性,之后会经常使用到。 然后一路返回,回到这里: sun.rmi.server.UnicastServerRef#UnicastServerRef(int)这里其实可以理解为:UnicastRef对应客户端,UnicastServerRef对应服务端,二者是神奇的继承的关系。
一路返回,终于进到exportObject这里:
这里的sref其实就是包含了刚创建的liveref的UnicastServerRef,意思就是借助sref把远程对象暴露出去;
往下走,由于我们的远程对象实现了UnicastRemoteObject接口,所以进入分支;
向上转型,将当前的UnicastServerRef赋值给了ref属性:
接下来进到sref的exportObject方法处:
看到了熟悉的stub变量,原来在这里:
那stub是怎么创建的呢?
进入Util.createProxy方法:
伏笔1: 先记住这个判断,创建注册中心时会细说:
创建动态代理:
创建好动态代理,返回这个动态代理,来到上层。
可以看到,stub就是个动态代理。
继续走,这里如果是系统内置的Stub(RemoteStub及其子类),就创建skeleton,目前不是,所以不进if。
继续走,来到下面这里:
看参数,顾名思义,可以理解为一个总封装,把目前我们创建的零件封装到一起。
看一下target的属性:
仔细观察这里的参数:
disp:UnicastServerRef 服务端引用
stub:代理(动态代理)
impl:远程服务实现类(后面的注册中心实现类也是这个参数)
id:内部liveRef的编号
关键是二者都封装了同一个liveRef,并且该liveRef的id就是整体target的id,可见其重要性。(不要小看这个id,后面会发挥大作用)
一路返回,回到target建立之后这里:
这里的ref其实还是那个liveRef,调用LiveRef的exportObject方法,把target暴露出去:
一路走:
一直来到这里:
sun.rmi.transport.tcp.TCPTransport#exportObject
直接监听网络请求,跟进去:
开启了一个新的ServerSocket,开启了一个新的线程,监听客户端请求。
newServerSocket中将创建随机端口。
进入Accept这个线程代码中:
executeAcceptLoop()都是网络流操作,也就是说,服务端将会开启一个新的线程,等待客户端连接。
网络请求的线程和代码逻辑已经分开,我们代码可以继续走,处理网络请求的线程将持续等待。
这时,远程对象已经在服务端上的随机端口发布出去了。
回到上层,需要记录一下:
来到Transport#exportObject中,进入putTarget:
发现内部有这么两行:
就是把一些琐碎对象存储到ObjectTable
类的两个静态Map中:
相当于在服务端自身做了一下备份,保留案底。
到这里,整个远程对象就发布了,socket监听。
提示:这里的ObjectEndpoint和liveRef一样具有id,后面还会看到。
整体流程:
0x03 创建注册中心
Registry registry = LocateRegistry.createRegistry(1099);
正常跟:
进到这里:
可以看到不管是否进行安全检查,核心代码都是一样的。
熟悉的感觉,创建一个新的LiveRef(这次端口强制是1099),作为注册中心,id默认为0,套在UnicastServerRef里面。
注意,这里创建liveRef的id为默认值,也就是0,后续会用到。
这里可以看到,熟悉的UnicastServerRef套LiveRef,和上一步创建远程对象时的方式是一样的。
也就说这里又创建一个服务端引用,作为参数交给了setup方法,进入setup方法:
属性赋值之后,一样调用了UnicastServerRef的exportObject方法。
闪回一下,看一下当时创建远程对象的时候:
可以看到,两次调用的是同一个方法。
不同点:
第一个参数代表远程对象,创建远程对象就是自己实现的Impl,创建注册中心就是RegistryImpl(目前还没完善,但已经被传进来了);
第三个参数代表时效选项,上次是false,这次变成了true;
也就是说:
1. 远程对象的创建依赖于我们自定义的实现类,是一个临时的;
2. 注册中心是JDK原生支持的,所以是永久的;
我们进入uref.exportObject方法:
发现一样还是熟悉的感觉,创建代理,进入createProxy方法中看:
伏笔1补全:还记得上一步的伏笔1么(cmd+f “伏笔1”回溯一下),这里判断结果出现了变化;
首先,这里的remoteClass已经不是上一步的服务实现类了,因为我们现在要创建注册中心,所以变量remoteClass是注册中心的实现类,也就是RegistryImpl类对象,进入最后一个分支判断函数:
可以看到,这里出现了反射类加载,也就是说,如果当前classpath中存在remoteClass_Stub类,那么就返回true;
对比一下,我们创建远程对象的时候,我们的remoteClass是RemoteSeviceImpl
,classpath路径下根本不存在RemoteSeviceImpl_Stub
类,所以当时返回的是false;
上次不进分支,这次进分支:
进入了createStub方法,参数是RegistryImpl,和UnicastRef类型的客户端引用:
只不过这里的cons使用的ref是UnicastRef,就是客户端引用:
之后将RegistryImpl_Stub对象返回。
回想一下:
1. 创建远程对象时,stub是动态代理,Proxy对象,内部封装了RemoteObjectInvocationHandler(clientRef);
2. 创建注册中心时,stub是RegistryImpl_Stub对象,内部也需要clientRef参与构造;
二者创建代理时,都将clientRef包含进去了
clientRef是UnicastRef,内部封装了LiveRef属性,liveRef在创建远程对象时id是随机的,创建注册中心时是0
继续往下走,来到这里:
我们这时的RegistryImpl_Stub对象确实是RemoteStub的子类,所以满足条件,进入setSkeleton方法:
进入createSkeleton方法:
把skeleton返回出来了:
这里的skel是UnicastServerRef的内部属性;
也就是说,我们创建好的skeleton,会被放在注册中心实现类中。具体来说,创建好的skeleton其实会存储在UnicastServerRef的skel属性中;
RegistryImpl的结构图:
到这步梳理一下:
看一下各个属性值,当前上下文是在UnicastServerRef中:
看到这大致看出来一些细节了:
1. 注册中心本体RegistryImpl的ref属性,是UnicastServerRef
2. 注册中心实现的RegistryImpl_Stub对象,反射生成,当时的cons参数中有clientRef
3. 注册中心实现的RegistryImpl_Skel对象,反射生成。无参cons
这时候的target参数:
impl:注册中心实现类,也就是RegistryImpl
disp: 当前的UnicastServerRef
stub:RegistryImpl_Stub
id: 0
提醒一下,我们现在在UnicastServerRef中,ref属性是LiveRef@962
我们会进入LiveRef的exportObject方法,所以继续进入下面的exportObject方法:
这里同样要开启1099端口,监听网络请求,为后续客户端来寻找远程对象做准备
一路走,来到这里:
熟悉的存储静态表:
往下走,直到这两步执行结束:
还记得创建远程对象的时候也注入到objTable了么, 按照源码逻辑,rmi需要用到的远程对象都会存放在objTable里面。
远程对象算一个,当前的RegistryImpl_Stub算一个。当然我说的是Target的核心部分,统一都被封装成了Target
看看objTable中的具体内容:
可以看到,第二个是远程对象的,第三个是注册中心的;
远程对象的target属性,动态代理,没有skel属性:
注册中心的target属性:
可以看到各自的liveref都是相同的,按照对象结构图来看,果然不出所料。
提醒,此时注册中心打开了1099端口,等待客户端(或服务端)发送lookup、bind等请求。
整体流程:
0x04 绑定
Registry+Name方式
Registry registry = LocateRegistry.createRegistry(1099);
registry.bind("Hello", remoteObject);
这里的registry就是我们上一部创建的RegistryImpl,进入它的bind方法:
bindings就是一个hashtable:
如果当前的keySet中找不到已经绑定的远程对象名,那么就put进去;
远程对象名,远程对象(动态代理)
示意图:
Naming+url方式
Naming.bind("rmi://localhost:1099/Hello", remoteObject);
绑定需要两个参数,前者是url,后者是远程对象。
进入bind方法:
首先解析url:
接下来获取注册中心,getRegistry跟进去:
进入getRegistry方法:
关键点:
进入熟悉的createProxy方法:
这里做的事情是给注册中心的实现创建一个代理,由于存在RegistryImpl_Stub方法,所以还是会进到createStub这里来:
和创建注册中心时候一样,使用反射创建代理,也就是RegistryImpl_Stub类,这里需要UnicastRef参与构造函数:
一路返回到最上层的bind方法,可以看到registry变量其实是RegistryImpl_Stub对象:
进入最后的bind方法,和上一种是一样的。
0x05 客户端获取注册中心
Registry registry = LocateRegistry.getRegistry("localhost", 1099);
其实这里说获取并不准确,应该是创建。下面细说:
进入getRegistry方法:
关键点:
进入熟悉的createProxy方法:
这里是给注册中心的实现创建一个代理,由于客户端classpath也存在RegistryImpl_Stub类,所以还是会进到createStub这里来:
和创建注册中心时候一样,使用反射创建代理,也就是RegistryImpl_Stub类,这里需要UnicastRef参与构造函数:
这里有一些流程和注册中心当时创建RegistryImpl_Stub是一样的。也就是说,注册中心创建的RegistryImpl_Stub其实并没有传递给客户端。
而是客户端记住注册中心的ip、port,自己在本地创建了一个RegistryImpl_Stub。
这里的RegistryImpl_Stub结构如下:
流程图:
0x06 客户端lookup获取远程对象
//$Proxy
RemoteInterface stub = (RemoteInterface) registry.lookup("Hello");
registry就是客户端自己做的RegistryImpl_Stub,跟进的lookup函数看看:
当前对象是RegistryImpl_Stub,他的ref属性就是UnicastRef。
所以进入UnicastRef的newCall方法:简单一句话,建立连接。
注意他的第一个方法,我认为是把客户端创建的Rigistry_Stub写到远程引用层(RemoteCall)里面。
返回到lookup方法,这里get到字节流,把远程对象的类名,序列化写进字节流:
当前类对象为RegistryImpl_Stub,ref属性为UnicastRef。
所以之后调用UnicastRef的invoke方法:
进入StreamRemoteCall#executeCall方法:
executeCall内部是处理客户端与注册中心交互的功能函数,他在字节流的层面负责传输:
1. 将客户端想寻找远程对象名字接收,传给注册中心;
2. 接收注册中心传递回来的对象的字节流;
所有客户端的请求,invoke->executeCall其实就是一条危险片段链;
因为不止lookup方法,还有bind、list方法也是会调用invoke方法的;
回到lookup方法,后续将进行反序列化:
远程对象会以动态代理的形式返回,里面包含了liveref,需要连接的ip:port等等信息。
可以发现,这个远程对象内部的liveRef的配置信息是当初服务端的ip和当时自动设置的随机端口。
0x07 客户端借助动态代理连接服务端
stub.sayHello()
这里的stub其实就是上一步获取的动态代理对象
动态代理大家都懂,核心就是handler的invoke方法,这里也不意外。
这里当调用远程对象的方法时,会走RemoteObjectInvocationHandler的invoke方法
这里会进入invokeRemoteMethod方法:
进入这里的invoke方法,看关键点:
首先将方法调用的参数进行了序列化。
之后又调用了executeCall()。
熟悉的攻击点1再次出现,executeCall是将参数传递给服务端,再将服务端的返回结果按照字节流返回。(如果返回值异常,触发readObject)
继续往下看,如果返回值不为null,调用unmarshalValue方法:
unmarshalValue和前面的给参数编码的marshalValue是对称的:
最终将返回值返回,客户端角度的一次RMI结束。
其实executeCall内部的报文处理,就是根据JRMP协议实现的解析逻辑;
0x08 注册中心响应客户端的lookup请求
注册中心创建时已经打开了1099端口,并开启了新线程监听网络请求。
处理客户端lookup方法的其实是RegistryImpl_Skeleton对象。
我们来到最开始,因为负责监1099端口请求的是sun.rmi.transport.tcp.TCPTransport,所以我们从listen方法开始静态跟:
listen->AcceptLoop.run()->executeAcceptLoop()->ConnectionHandler.run()->run0()->handleMessages()
断点下在TransportConstants.Call
这个opcode的分支上:
客户端lookup一下,服务端命中断点,停在ServiceCall中,进入serviceCall方法中:
还记得我们在客户端创建RegistryImp_Stub的时候,建立了一个ip为注册中心的ip,端口为1099,id为0的liveRef么?
嵌套结构如图:
target属性:
注册中心里面的objTable存放着两个Target,其中我们根据ObjectEndpoint在表中寻找具有和客户端相同RegistryImpl_Stub的Target对象,因为它的disp属性(UnicastServerRef)里面,我们当时存放了skel属性,现在需要用了。
ObjectEndpoint中的id应该是由ip和端口号生成的,注册中心创建和客户端的是相同的,所以可以准确找到包含skel属性的Target。
继续走,获取disp之后,触发dispatch方法:
调用oldDispatch方法:
感慨一句,当初在服务端创建的Registry_Skel,终于要派上用场了。
具体也是解析jrmp协议的opcode:
switch(opnum){
case 0:// bind(String, Remote)
case 1: // list()
case 2: // lookup(String)
case 3: // rebind(String, Remote)
case 4: // unbind(String)
}
我们选择lookup方法,所以会进case2:
这里的var10其实就是我们lookup寻找远程对象的方法名,并且不止lookup,只要有readObject都是可以攻击的。
0x09服务端响应客户端的方法调用
这里走的不再是1099端口
因为:
1. 远程对象创建时,将在随机端口上开放监听;
2. 客户端从注册中心获取的是远程对象的动态代理,底层的liveref记录了服务端的ip和那个随机端口;
所以我们还是需要从TCPTransport#listen进来:
listen()->AcceptLoop.run()->executeAcceptLoop()->ConnectionHandler.run()->run0()->handleMessages()
->serviceCall()->dispatch()
说两处:
1 sun.rmi.transport.Transport#serviceCall这里:
根据客户端的动态代理内部的liveRef属性的ip、port、id算出来一个oe,在objTable中找到了对应的target。
2 sun.rmi.server.UnicastServerRef#dispatch这里:
我们发现,由于目前对象是动态代理,skel属性为空,不会进入分支了:
继续往下走:
发现根据我们调用的方法名和实现存放好的哈希表进行匹配。
之后将客户端传过来的序列化参数反序列化出来:
最后反射调用方法,将结果序列化,写到字节流,返回给客户端。
这也就是为什么可以互相打,服务端打客户端多一种JRMP。
0x0a 服务端与注册中心
官方文档推荐服务端与注册中心写在一起,这样可以直接在服务端构建远程对象,紧接着就可以创建注册中心了。
这样一来,ObjectTable.objTable
中存放的就是全部交互对象(包括DGC、_Stub以及$Proxy)。
但如果分开的话,服务端是如何向注册中心注册远程对象的呢?
改写一下代码:
注册中心:只负责构建注册中心。
public class MyOwnRegistry {
public static void main(String args[]) {
try {
Registry registry = LocateRegistry.createRegistry(1099);
TimeUnit.DAYS.sleep(1); // 维持一天
} catch (Exception e) {
e.printStackTrace();
}
}
}
服务端:创建远程对象、获取数据中心、绑定远程对象
public class Server {
public static void main(String[] args) throws Exception{
// 创建远程对象
RemoteInterface remoteObject = new RemoteSeviceImpl();
//分离式
Registry registry = LocateRegistry.getRegistry("localhost", 1099);
registry.bind("rmi://localhost:1099/Hello", remoteObject);
System.out.println("Server Start");
}
}
客户端:维持原样,浑然不知
public class RMIClient {
public static void main(String[] args) throws RemoteException, NotBoundException {
Registry registry = LocateRegistry.getRegistry("localhost", 1099);
RemoteInterface stub = (RemoteInterface) registry.lookup("rmi://localhost:1099/Hello");
System.out.println(stub.sayHello());
}
}
经过前面的分析,我们知道是Registry_Skel类来响应各位的各种请求,此时我们想绑定远程方法,所以在该类的bind位置下断点。
debug MyOwnRegistry,run Server:
可以看到注册中心的ObjectTable.objTable
长度为2,分别是DGC、RegistryImpl_Stub, server变量的内容就是RegistryImpl,目前并没有绑定任何远程对象,bindings的大小为null。
复习一下RegistryImpl对象结构:
之前我们也分析过,bind方法就是向bindings数组中添加远程对象。
放开断点,让bind方法结束,再看相关属性的变化:
也就是说:
1. 注册中心创建之处,ObjectTable.objTable中存放着stub属性为DGCImpl_Stub和Registry_Impl的两个Target;
2. 远程对象绑定,其实就是到注册中心的RegistryImpl的bindings中put一个entry;
3. 如果服务端和注册中心放在一起,ObjectTable.objTable中会额外多一个stub属性为$Proxy的Target对象,但对象绑定的原理没有变;
另外需要注意的是,远程对象是如何传递给注册中心的?
我们把断点打在server端的bind方法处,发现直接进行了两次writeObject序列化操作,第一次是远程对象的url,第二次是远程对象的实现类,目前这里还不是动态代理类型。
此时进入了MarshalOutputStream#replaceObject
方法,在Object.implTable中拿到了对应的target对象,返回了它的动态代理对象。
0x0b 总结
整体交互流程:
最后补上两张流程图帮助理解:
1. 客户端与注册中心交互:
1. 客户端与服务端绑定
0x0c 参考
RMI应用概述:https://docs.oracle.com/javase/tutorial/rmi/overview.html
Java RMI 攻击由浅入深:https://su18.org/post/rmi-attack/#%E4%B8%89-%E6%80%BB%E7%BB%93
RMI 系列(02)源码分析:https://www.cnblogs.com/binarylei/p/12115986.html#1-%E6%9E%B6%E6%9E%84