源码层面梳理Java RMI交互流程

admin 2022年4月15日10:28:19评论22 views字数 10766阅读35分53秒阅读模式

0x00 RMI简述

RMI(Remote Method Invocation)是Java语言执行远程方法调用的Java API,相当于面向对象的远程过程调用(RPC),支持直接传输序列化的Java类和分布式垃圾回收。

整体时序图如下:
源码层面梳理Java RMI交互流程<sub>~</sub>~
由于其内部直接以来序列化机制,近些年来部分低版本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方法处:
源码层面梳理Java RMI交互流程
源码层面梳理Java RMI交互流程
源码层面梳理Java RMI交互流程
暴露远程对象,obj是我们的服务实现类,new UnicastServerRef(port)就是处理网络请求的逻辑实现,port初始默认值是0,最后是随机端口。

注意,这里的端口是指远程对象的端口,而不是注册中心的1099。

跟进UnicastServerRef方法:
源码层面梳理Java RMI交互流程
跟进LiveRef方法:
源码层面梳理Java RMI交互流程
源码层面梳理Java RMI交互流程
跟进TCPEndpoint.getLocalEndpoint(port)方法:
源码层面梳理Java RMI交互流程
TCPEndpoint处理网络请求,没必要继续往深跟了。
回到上层:
源码层面梳理Java RMI交互流程
构造函数执行完毕,当前liveRef的属性,之后会经常使用到。
源码层面梳理Java RMI交互流程
然后一路返回,回到这里:
sun.rmi.server.UnicastServerRef#UnicastServerRef(int)

这里其实可以理解为:UnicastRef对应客户端,UnicastServerRef对应服务端,二者是神奇的继承的关系。

一路返回,终于进到exportObject这里:

这里的sref其实就是包含了刚创建的liveref的UnicastServerRef,意思就是借助sref把远程对象暴露出去;

往下走,由于我们的远程对象实现了UnicastRemoteObject接口,所以进入分支;

向上转型,将当前的UnicastServerRef赋值给了ref属性:

image-20220331211609936

接下来进到sref的exportObject方法处:

image-20220331211956686

看到了熟悉的stub变量,原来在这里:

image-20220331212511248

那stub是怎么创建的呢?

进入Util.createProxy方法:

伏笔1: 先记住这个判断,创建注册中心时会细说:

image-20220331212907844

创建动态代理:

image-20220331213402326

创建好动态代理,返回这个动态代理,来到上层。

image-20220331213649747

可以看到,stub就是个动态代理

继续走,这里如果是系统内置的Stub(RemoteStub及其子类),就创建skeleton,目前不是,所以不进if。

image-20220331213818276

继续走,来到下面这里:

image-20220331213857739

看参数,顾名思义,可以理解为一个总封装,把目前我们创建的零件封装到一起。

看一下target的属性:

image-20220331214446229

仔细观察这里的参数:

disp:UnicastServerRef 服务端引用

stub:代理(动态代理)

impl:远程服务实现类(后面的注册中心实现类也是这个参数)

id:内部liveRef的编号

关键是二者都封装了同一个liveRef,并且该liveRef的id就是整体target的id,可见其重要性。(不要小看这个id,后面会发挥大作用)

一路返回,回到target建立之后这里:

image-20220331214951267

这里的ref其实还是那个liveRef,调用LiveRef的exportObject方法,把target暴露出去:

一路走:

image-20220331215233652

一直来到这里:

sun.rmi.transport.tcp.TCPTransport#exportObject

image-20220331215530181

直接监听网络请求,跟进去:

image-20220331215757708

开启了一个新的ServerSocket,开启了一个新的线程,监听客户端请求。

newServerSocket中将创建随机端口。

进入Accept这个线程代码中:

image-20220331215924817

executeAcceptLoop()都是网络流操作,也就是说,服务端将会开启一个新的线程,等待客户端连接。

网络请求的线程和代码逻辑已经分开,我们代码可以继续走,处理网络请求的线程将持续等待。

这时,远程对象已经在服务端上的随机端口发布出去了。

回到上层,需要记录一下:

image-20220331220800153

来到Transport#exportObject中,进入putTarget:

image-20220331220849526

发现内部有这么两行:

image-20220331221448889

就是把一些琐碎对象存储到ObjectTable类的两个静态Map中:

image-20220331221528107

相当于在服务端自身做了一下备份,保留案底。

到这里,整个远程对象就发布了,socket监听。

提示:这里的ObjectEndpoint和liveRef一样具有id,后面还会看到。

image-20220402103232036

整体流程:

image-20220402203123812.png

0x03 创建注册中心

Registry registry = LocateRegistry.createRegistry(1099);

正常跟:

进到这里:

image-20220331223735263

可以看到不管是否进行安全检查,核心代码都是一样的。

熟悉的感觉,创建一个新的LiveRef(这次端口强制是1099),作为注册中心,id默认为0,套在UnicastServerRef里面。

image-20220401092909905

注意,这里创建liveRef的id为默认值,也就是0,后续会用到。

这里可以看到,熟悉的UnicastServerRef套LiveRef,和上一步创建远程对象时的方式是一样的。

也就说这里又创建一个服务端引用,作为参数交给了setup方法,进入setup方法:

属性赋值之后,一样调用了UnicastServerRef的exportObject方法。

闪回一下,看一下当时创建远程对象的时候:

image-20220401094601187

可以看到,两次调用的是同一个方法。

不同点:

第一个参数代表远程对象,创建远程对象就是自己实现的Impl,创建注册中心就是RegistryImpl(目前还没完善,但已经被传进来了);

第三个参数代表时效选项,上次是false,这次变成了true;

也就是说:

  1. 远程对象的创建依赖于我们自定义的实现类,是一个临时的;
  2. 注册中心是JDK原生支持的,所以是永久的;

我们进入uref.exportObject方法:

发现一样还是熟悉的感觉,创建代理,进入createProxy方法中看:

image-20220401095615816

伏笔1补全:还记得上一步的伏笔1么(cmd+f “伏笔1”回溯一下),这里判断结果出现了变化;

首先,这里的remoteClass已经不是上一步的服务实现类了,因为我们现在要创建注册中心,所以变量remoteClass是注册中心的实现类,也就是RegistryImpl类对象,进入最后一个分支判断函数:

image-20220401095958824

可以看到,这里出现了反射类加载,也就是说,如果当前classpath中存在remoteClass_Stub类,那么就返回true;

对比一下,我们创建远程对象的时候,我们的remoteClass是RemoteSeviceImpl,classpath路径下根本不存在RemoteSeviceImpl_Stub

类,所以当时返回的是false;

上次不进分支,这次进分支:

image-20220401100521728

进入了createStub方法,参数是RegistryImpl,和UnicastRef类型的客户端引用:

image-20220401101302565

只不过这里的cons使用的ref是UnicastRef,就是客户端引用:

之后将RegistryImpl_Stub对象返回。

image-20220401101653663

回想一下:

  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的结构图:

到这步梳理一下:

image-20220401111755737

看一下各个属性值,当前上下文是在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方法:

image-20220401111755737

这里同样要开启1099端口,监听网络请求,为后续客户端来寻找远程对象做准备

一路走,来到这里:

熟悉的存储静态表:

往下走,直到这两步执行结束:

还记得创建远程对象的时候也注入到objTable了么, 按照源码逻辑,rmi需要用到的远程对象都会存放在objTable里面。

远程对象算一个,当前的RegistryImpl_Stub算一个。当然我说的是Target的核心部分,统一都被封装成了Target

看看objTable中的具体内容:

image-20220401115041356

可以看到,第二个是远程对象的,第三个是注册中心的;

远程对象的target属性,动态代理,没有skel属性:

注册中心的target属性:

可以看到各自的liveref都是相同的,按照对象结构图来看,果然不出所料。

提醒,此时注册中心打开了1099端口,等待客户端(或服务端)发送lookup、bind等请求。

整体流程:

image-20220402203545072

0x04 绑定

Registry+Name方式

Registry registry = LocateRegistry.createRegistry(1099);
registry.bind("Hello", remoteObject);

这里的registry就是我们上一部创建的RegistryImpl,进入它的bind方法:

image-20220401154653621

bindings就是一个hashtable:

image-20220401154725549

如果当前的keySet中找不到已经绑定的远程对象名,那么就put进去;

远程对象名,远程对象(动态代理)

示意图:

Naming+url方式

Naming.bind("rmi://localhost:1099/Hello", remoteObject);

绑定需要两个参数,前者是url,后者是远程对象。

进入bind方法:

image-20220401152656410

首先解析url:

image-20220401152848411

接下来获取注册中心,getRegistry跟进去:

image-20220401152940639

image-20220401152955326

进入getRegistry方法:

关键点:

image-20220401153412706

进入熟悉的createProxy方法:

这里做的事情是给注册中心的实现创建一个代理,由于存在RegistryImpl_Stub方法,所以还是会进到createStub这里来:

image-20220401153619812

和创建注册中心时候一样,使用反射创建代理,也就是RegistryImpl_Stub类,这里需要UnicastRef参与构造函数:

image-20220401153748678

一路返回到最上层的bind方法,可以看到registry变量其实是RegistryImpl_Stub对象:

image-20220401153936495

进入最后的bind方法,和上一种是一样的。

0x05 客户端获取注册中心

Registry registry = LocateRegistry.getRegistry("localhost", 1099);

其实这里说获取并不准确,应该是创建。下面细说:

image-20220401152955326

进入getRegistry方法:

关键点:

image-20220402124317673

进入熟悉的createProxy方法:

这里是给注册中心的实现创建一个代理,由于客户端classpath也存在RegistryImpl_Stub类,所以还是会进到createStub这里来:

image-20220401153619812

和创建注册中心时候一样,使用反射创建代理,也就是RegistryImpl_Stub类,这里需要UnicastRef参与构造函数:

image-20220401153748678

这里有一些流程和注册中心当时创建RegistryImpl_Stub是一样的。也就是说,注册中心创建的RegistryImpl_Stub其实并没有传递给客户端。

而是客户端记住注册中心的ip、port,自己在本地创建了一个RegistryImpl_Stub。

这里的RegistryImpl_Stub结构如下:

流程图:

image-20220402204857034

0x06 客户端lookup获取远程对象

//$Proxy
RemoteInterface stub = (RemoteInterface) registry.lookup("Hello");

registry就是客户端自己做的RegistryImpl_Stub,跟进的lookup函数看看:

image-20220401161110003

当前对象是RegistryImpl_Stub,他的ref属性就是UnicastRef。

所以进入UnicastRef的newCall方法:简单一句话,建立连接。

image-20220401161322307

注意他的第一个方法,我认为是把客户端创建的Rigistry_Stub写到远程引用层(RemoteCall)里面。

返回到lookup方法,这里get到字节流,把远程对象的类名,序列化写进字节流:

image-20220401161442869

当前类对象为RegistryImpl_Stub,ref属性为UnicastRef。

所以之后调用UnicastRef的invoke方法:

image-20220401161615347

进入StreamRemoteCall#executeCall方法:

executeCall内部是处理客户端与注册中心交互的功能函数,他在字节流的层面负责传输:

  1. 将客户端想寻找远程对象名字接收,传给注册中心;
  2. 接收注册中心传递回来的对象的字节流;

所有客户端的请求,invoke->executeCall其实就是一条危险片段链;

因为不止lookup方法,还有bind、list方法也是会调用invoke方法的;

回到lookup方法,后续将进行反序列化:

image-20220401163740831

远程对象会以动态代理的形式返回,里面包含了liveref,需要连接的ip:port等等信息。

可以发现,这个远程对象内部的liveRef的配置信息是当初服务端的ip和当时自动设置的随机端口。

0x07 客户端借助动态代理连接服务端

stub.sayHello()

这里的stub其实就是上一步获取的动态代理对象

动态代理大家都懂,核心就是handler的invoke方法,这里也不意外。

这里当调用远程对象的方法时,会走RemoteObjectInvocationHandler的invoke方法

这里会进入invokeRemoteMethod方法:

image-20220401165730891

进入这里的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方法:

image-20220401203648415

调用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属性为空,不会进入分支了:

继续往下走:

image-20220401212423376

发现根据我们调用的方法名和实现存放好的哈希表进行匹配。

之后将客户端传过来的序列化参数反序列化出来:

最后反射调用方法,将结果序列化,写到字节流,返回给客户端。

这也就是为什么可以互相打,服务端打客户端多一种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方法结束,再看相关属性的变化:

image-20220402164406690

也就是说:

  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 总结

整体交互流程:

image-20220402195257584

最后补上两张流程图帮助理解:

  1. 客户端与注册中心交互:

image-20220402211232444

  1. 客户端与服务端绑定

image-20220402212639237

0x0c 参考

RMI应用概述

Java RMI 攻击由浅入深

RMI 系列(02)源码分析

FROM:tttang . com

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2022年4月15日10:28:19
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   源码层面梳理Java RMI交互流程http://cn-sec.com/archives/912844.html

发表评论

匿名网友 填写信息