JAVA RMI

前言:这篇RMI自己感觉还是很多需要补充的,慢慢来...

在学习RMI之前,我需要给大家说下RMI中代码对象的作用,因为我自己最大的感受就是对象很多,最大迷惑点就是很多对象虽然反复出现,但是不清楚其作用,这样就导致调试到后面就看不懂

UnicastRemoteObject:如果服务端要将对象发布,也就是导出到注册端上去给客户端使用的话,那么该导出对象就需要继承UnicastRemoteObject

RegistryImpl_Skel:这个对象是每次服务端发布完对应的远程导出到注册端上,该对象都会和RegistryImpl_Stub配对生成,会放到一个Target对象中,然后保存到注册端上,它的作用就是服务端处理客户端发送过来的请求

RegistryImpl_Stub:这个对象是每次服务端发布完对应的远程导出到注册端上,该对象都会和RegistryImpl_Stub配对生成,会放到一个Target对象中,然后保存到注册端上,它的作用就是处理客户端发起的请求,就比如你客户端要获取注册端上的服务,你就需要RegistryImpl_Stub发起请求

StreamRemoteCall:这个对象是用于网络通信的对象,在调试代码的时候可以看到RegistryImpl_Skel和RegistryImpl_Stub如果要发起请求,最终对于网络的收发请求都是通过该StreamRemoteCall对象进行处理的

UnicastRef:客户端对于网络请求的封装对象,实际上客户端发起的请求先是通过UnicastRef操作,而UnicastRef中操作是通过StreamRemoteCall来进行请求

UnicastServerRef:服务端对于网络请求的封装对象,实际上服务端发起的请求先是通过UnicastServerRef操作,而UnicastServerRef中操作是通过StreamRemoteCall来进行请求,这里的话其实UnicastServerRef就是继承了UnicastRef,但在角色上的表现个人感觉就是作为服务端的存在

关于RMI的架构

RMI(Remote Method Invocation)即Java远程方法调用,RMI用于构建分布式应用程序,RMI实现了Java程序之间跨JVM的远程通信。

一个RMI过程有以下三个参与者:

RMI客户端:客户端调用服务端的方法

RMI服务端:远程调用方法对象的提供者,也是代码真正执行的地方,执行结束会返回给客户端一个方法执行的结果。

Registry注册中心:其实本质就是一个map,相当于是字典一样,用于客户端查询要调用的方法的引用,如果没有这个的话,那么客户端无法找到对应的stub对象,这个注册中心只是一个媒介,方便了客户端进行寻找

Registry的知识点: 在低版本的JDK中,Server与Registry是可以不在一台服务器上的,而在高版本的JDK中,Server与Registry只能在一台服务器上,否则无法注册成功。

所以在上面可以看到,此时的Server与Registry都已经在一台服务器上面了

关于RMI底层通讯的流程

RMI底层通讯采用了Stub(运行在客户端)和Skeleton(运行在服务端)机制,RMI调用远程方法的大致如下:

  1. RMI客户端在调用远程方法时会先创建Stub(sun.rmi.registry.RegistryImpl_Stub)。

  2. RegistryImpl_Stub会将Remote对象传递给远程引用层(java.rmi.server.RemoteRef)并创建java.rmi.server.RemoteCall(远程调用)对象。

  3. RemoteCall序列化RMI服务名称、Remote对象。

  4. RMI客户端的远程引用层传输RemoteCall序列化后的请求信息通过Socket连接的方式(传输层)传输到RMI服务端的远程引用层。

  5. RMI服务端的远程引用层(sun.rmi.server.UnicastServerRef)收到请求会请求传递给Skeleton(sun.rmi.registry.RegistryImpl_Skel#dispatch)。

  6. Skeleton调用RemoteCall反序列化RMI客户端传过来的序列化数据,这里的作用就是要知道客户端请求的远程对象的名称,不解密的话就不知道远程对象的名称是什么了

注:反序列化就是从这里开始的,在RMI过程中,RMI服务端的远程引用层(sun.rmi.server.UnicastServerRef)收到请求会传递给Skeleton代理(sun.rmi.registry.RegistryImpl_Skel#dispatch),反序列化的操作实际是sun.rmi.registry.RegistryImpl_Skel#dispatch来进行处理

  1. Skeleton处理客户端请求:bind、list、lookup、rebind、unbind,如果是lookup则查找RMI服务名绑定的接口对象,序列化该对象并通过RemoteCall传输到客户端。

  2. RMI客户端反序列化服务端结果,获取远程对象的引用。

注:RMI客户端能够反序列化服务端的结果,那么也注定了RMI客户端上也能造成反序列化漏洞

  1. RMI客户端调用远程方法,RMI服务端反射调用RMI服务实现类的对应方法并序列化执行结果返回给客户端

注:这里其实就说明了真正执行代码的地方并不是在客户端而是在服务端,而客户端代码的方法调用只不过是取得了数据

  1. RMI客户端反序列化RMI远程方法调用结果。

什么是RMI

远程方法调用是分布式编程中的一个基本思想。实现远程方法调用的技术有很多,例如WebService,两者的区别就是:WebService是独立于编程语言的,它可以跨语言实现项目间的方法调用,而Java RMI是专用于Java环境的。

参考文章:https://www.jianshu.com/p/2c78554a3f36

这篇作为一个正常的RMI服务端和客户端的进行通信流程的学习笔记

RMI服务端的创建流程

前置知识点:关于UnicastRemoteObject,如果想要将对象能够通过RMI服务来进行远程调用的话,那么该对象就需要继承UnicastRemoteObject,或者是通过UnicastRemoteObject.exportObject来进行导出,返回的对象就是一个Remote对象

接着来看RMI服务端的创建,RMI服务端的代码如下所示

知识点:要导出的对象如果不是继承UnicastRemoteObject,需使用exportObject来处理,也就是UnicastRemoteObject.exportObject,或者也就是直接进行导出(那么直接在Naming.bind(RMI_NAME, new 导出的对象());即可

public class RMIServer {
    public static void main(String[] args) {
        try {
            HelloServiceImpl obj = new HelloServiceImpl();
            //HelloServiceImpl没有继承UnicastRemoteObject,需使用exportObject来处理
            IHelloService helloService = (IHelloService) UnicastRemoteObject.exportObject(obj, 0);
            //创建Registry,监听于9999端口
            Registry reg = LocateRegistry.createRegistry(9999);
            //将HelloServiceImpl绑定到Registry
            reg.bind("HelloService", helloService);
            System.out.println("HelloServiceImpl已绑定到Registry ......");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

这里通过UnicastRemoteObject.exportObject来进行导出远程对象,接着单步进入,可以看到如下

UnicastServerRef类的对象是会将当前要导出的对象的端口进行封装,啥意思?就是对象导出,它都会将其封装为一个对象,然后开启一个随机的端口进行监听socket通信

这里跟进UnicastServerRef,实例化new LiveRef(var1)

这里不细跟,总的来说就是返回一个UnicastServerRef对象,这个UnicastServerRef对象中包含了相关该远程导出对象的端口和相关的信息

接着继续回到如下,这里跟进exportObject方法

他会先判断传入的远程对象是否继承了UnicastRemoteObject,我们这边没有继承,所以这个判断不符合,那么直接执行return sref.exportObject(obj, null, false);

跟进到sref.exportObject(obj, null, false);,接着来到var5 = Util.createProxy(var4, this.getClientRef(), this.forceStubUse);

这里的话通过RemoteObjectInvocationHandler动态代理创建一个远程对象

最终生成的一个远程对象则是通过RemoteObjectInvocationHandler动态代理实现的一个Remote对象

接着通过 Target var6 = new Target(var1, this, var5, this.ref.getObjID(), var3);中将动态代理生成的Remote对象封装到Target对象中的stub字段中

接着继续来到this.ref.exportObject(var6);

一直跟进去会发现这个时候会开启一个随机的socket端口来进行监听操作,这个socket端口对应的就是当前导出的远程在服务端上监听的端口

到这里的话还有最后一步,sun.rmi.transport.Transport#exportObject还会将相关信息该Target对象和信息导入objTable和implTable对象中,这里需要知道每次导出的对象相关的Target对象的信息都会被放入到objTable和implTable对象,并且其中RMI服务默认还会有一个Target对象,其中是DGCImpl_Stub和DGCImpl_Skel

到了这里IHelloService helloService = (IHelloService) UnicastRemoteObject.exportObject(obj, 0);就执行完成了

结论:要导出的远程对象,都会对应生成一个随机的端口在服务端上进行监听,且该端口值是随机生成的

RMI注册端的创建流程

接着继续来看Registry reg = LocateRegistry.createRegistry(9999);,这里定义的注册端的端口是9999

这里跟进RegistryImpl构造函数,可以看到同样也有使用到UnicastServerRef对象

继续跟进到this.setup方法中,可以看到这里同样跟导出远程对象一样,也会通过UnicastServerRef对象来调用exportObject进行导出对象

var1.exportObject(this, (Object)null, true);,这里看到这里导出的对象是自身RegistryImpl对象,这里的RegistryImpl类同样实现了Remote的接口

接着继续跟进到UnicastServerRef.exportObject方法,到这里就开始不同了,这里的话它并不会创建一个RemoteObjectInvocationHandler动态代理的对象,而是调用createStub创建一个RegistryImpl_Stub

接着走到如下,当创建注册端的时候,这个条件会满足,因为动态代理创建的RegistryImpl_Stub instance of RemoteStub是满足的

这里的setSkeleton会将当前的RegistryImpl作为服务端的Skeleton,这里的Skel和Stub的是起到一个什么样的作用呢?个人理解因为是RMI远程调用的机制,所以这两个当导出对象之后都会被存储到一个map中去,而服务端接收到调度客户端的调度请求之后,就会通过RegistryImpl_skel专门来处理这种请求,那么RegistryImpl_Stub的作用则是每次客户端想要获取绑定在注册端上的对象的时候,都是通过RegistryImpl_Stub来进行操作

        if (var5 instanceof RemoteStub) {
            this.setSkeleton(var1);
        }

再接着同样的操作,通过Target封装,然后通过exportObject来进行导出

        Target var6 = new Target(var1, this, var5, this.ref.getObjID(), var3);
        this.ref.exportObject(var6);

到了这里可以发现,不仅仅是远程对象需要导出,同样在注册端也会被进行导出,只是注册端的监听端口是大家常听到的RMI的端口号1099

此时注册端就会返回一个对应的RegistryImpl对象,同时还可以看到该对象上有5个方法,分别是unbind,rebind,lookup,bind,list

{Long@964} 7305022919901907578 -> {Method@965} "public abstract void java.rmi.registry.Registry.unbind(java.lang.String) throws java.rmi.RemoteException,java.rmi.NotBoundException,java.rmi.AccessException"
{Long@966} -8381844669958460146 -> {Method@967} "public abstract void java.rmi.registry.Registry.rebind(java.lang.String,java.rmi.Remote) throws java.rmi.RemoteException,java.rmi.AccessException"
{Long@968} -7538657168040752697 -> {Method@969} "public abstract java.rmi.Remote java.rmi.registry.Registry.lookup(java.lang.String) throws java.rmi.RemoteException,java.rmi.NotBoundException,java.rmi.AccessException"
{Long@970} 7583982177005850366 -> {Method@971} "public abstract void java.rmi.registry.Registry.bind(java.lang.String,java.rmi.Remote) throws java.rmi.RemoteException,java.rmi.AlreadyBoundException,java.rmi.AccessException"
{Long@972} 2571371476350237748 -> {Method@973} "public abstract java.lang.String[] java.rmi.registry.Registry.list() throws java.rmi.RemoteException,java.rmi.AccessException"

该部分的过程总结如下图所示

注册端绑定导出对象的创建流程

接着就是执行reg.bind("HelloService", helloService);,此时要将HelloServiceImpl绑定到Registry

跟进去,可以看到当前的RegistryImpl对象进行调用bind方法,这里可以看到RegistryImpl会先检查自己的bingdings中是否有当前要绑定的远程对象的名称,如果有的话就抛出异常,没有的话则将当前远程对象绑定到RegistryImpl的bindings属性中去

这里跟进put方法中可以看到如下操作

到这里服务端的创建操作就已经完成了

总结下服务端的创建流程和知识点:

知识点1:Registry运行在server端,RMIRegistry 自身也是一个远程对象。

知识点2:所有的远程对象(继承了UnicastRemoteObject对象)都会在sever上任意的端口导出自己,因为RMIRegistry也是一个远程对象,所以它也在服务端上导出自己,只是这个端口是广为人知的1099

知识点3:服务端运行在server上,在UnicastRemoteObject构造函数里面,他把自己导出在server上一个任意端口上,这个端口client是不知道的

客户端获取RMIRegistry对象的流程

首先是执行Registry registry = LocateRegistry.getRegistry("127.0.0.1", 9999);

LocateRegistry对象中同样也是通过动态代理来生成一个

它会通过stubClassExists来判断RegistryImpl对应的存根Stub对象是否存在

如果存在的话,那么就让它执行createStub方法创建一个对应的存根对象

createStub方法通过反射构造实例化对象

到了这里客户端获得的并不是RegistryImpl对象,而是RegistryImpl对应的存根对象

接着跟进lookup方法中,它首先会进行newCall方法

newCall方法首先会建立一个连接到对应的RMI服务端

再接着创建一个StreamRemoteCall对象

StreamRemoteCall初始化会在自己的this.out属性中序列化一些属性进去

再接着就是序列化writeObject写入向remoteCall要lookup的对象名称

再接着就是执行super.ref.invoke(var2);

跟进去会继续调用前面生成的StreamRemoteCall的executeCall方法

跟进executeCall方法,可以看出当前客户端正在处理服务端返回回来的数据

接着读完第一个字节之后,又会继续读取一个字节

读取的第二个字节会用于下面的流程判断,如果是1的话那么直接return,而如果是2的话,那么会对返回回来的数据进行反序列化(这是一个攻击点,也就是如果服务端返回回来的序列化数据,那么在这里客户端是可以进行反序列化的)

而这里我看不太懂,就算上面结果不是2的话,那么此时返回回来的数据也会被反序列化,也就是invoke方法之后,然后通过done方法来进行释放内存空间

服务端接收客户端的流程

因为客户端发送了lookup的请求给服务端,那么服务端肯定是会先将客户端发来的序列化的数据进行反序列化,然后分析客户端要请求的远程对象,然后再将这个对象在注册端中拿到再返回给客户端

所以这边就直接在RegistryImpl类上的lookup进行打断点,然后调试服务端,接着运行客户端,断点会断到如下位置

这里在调用栈中进行观察,服务端会来到RegistryImpl_Skel对象中进行调用dispatch方法来处理对应的客户端请求

先检验一段4905912898345647071L,检测是否是RMI请求

接着再进行根据传来的数值来去对应的分支,这里传入的数值是2

可以看到服务端会先客户端传来的数据进行反序列化操作(攻击点),从而拿到对应的请求的远程对象的名称

将对应请求的远程对象的名称传入到lookup方法中

最后在lookup方法中将该对象进行取出,然后给客户端

这里拿su18师傅的博客中的图来展示整体的总结

关于DGC自动垃圾回收机制

当RMI服务端的第一个UnicastRemoteObject.exportObject绑定到注册端的时候,其中就会自动生成一个DCG相关的Stub和Skel对象生成,调用堆栈图如下所示

putTarget:174, ObjectTable (sun.rmi.transport)
exportObject:106, Transport (sun.rmi.transport)
exportObject:265, TCPTransport (sun.rmi.transport.tcp)
exportObject:411, TCPEndpoint (sun.rmi.transport.tcp)
exportObject:147, LiveRef (sun.rmi.transport)
exportObject:236, UnicastServerRef (sun.rmi.server)
exportObject:383, UnicastRemoteObject (java.rmi.server)
exportObject:320, UnicastRemoteObject (java.rmi.server)
main:12, RMIServer (com.zpchcbd.rmi.debug)

关于DCG相关的Stub和Skel对象会在如下生成

        if (DGCImpl.dgcLog.isLoggable(Log.VERBOSE)) {
            DGCImpl.dgcLog.log(Log.VERBOSE, "add object " + var1);
        }

跟进去这段代码可以发现会触发sun.rmi.transport.DGCImpl的静态代码块,然后最终的结果就是将可以看到其中创建对应的Skel

                        DGCImpl.dgc = new DGCImpl();
                        final ObjID var2 = new ObjID(2);
                        LiveRef var3 = new LiveRef(var2, 0);
                        final UnicastServerRef var4 = new UnicastServerRef(var3, (var0) -> {
                            return DGCImpl.checkInput(var0);
                        });
                        final Remote var5 = Util.createProxy(DGCImpl.class, new UnicastRef(var3), true);
                        var4.setSkeleton(DGCImpl.dgc);

堆栈调用过程如下所示

run:339, DGCImpl$2 (sun.rmi.transport)
run:325, DGCImpl$2 (sun.rmi.transport)
doPrivileged:-1, AccessController (java.security)
<clinit>:325, DGCImpl (sun.rmi.transport)
putTarget:174, ObjectTable (sun.rmi.transport)
exportObject:106, Transport (sun.rmi.transport)
exportObject:265, TCPTransport (sun.rmi.transport.tcp)
exportObject:411, TCPEndpoint (sun.rmi.transport.tcp)
exportObject:147, LiveRef (sun.rmi.transport)
exportObject:236, UnicastServerRef (sun.rmi.server)
exportObject:383, UnicastRemoteObject (java.rmi.server)
exportObject:320, UnicastRemoteObject (java.rmi.server)
main:12, RMIServer (com.zpchcbd.rmi.debug)

编写RMI服务

通过上文RMI介绍中的描述,自己来写个例子来实现RMI调用远程方法的代码

Remote对象的接口编写,需要继承Remote类

public interface RMITest extends Remote {
    String test() throws RemoteException;
}

这个接口类需要:

1、使用public声明,否则客户端在尝试加载实现远程接口的远程对象时会出错。(如果客户端、服务端放一起没关系)

2、同时需要继承Remote类(作为远程对象)

3、接口的方法需要声明java.rmi.RemoteException报错

Remote对象的实现类如下:

public class RMITestImpl extends UnicastRemoteObject implements RMITest{
    protected RMITestImpl() throws RemoteException {
        super();
    }

    public String test() throws RemoteException {
            return "Hello RMI~";
    }
}

这个实现类需要:

1、实现远程接口

2、继承UnicastRemoteObject类,原因是继承了这个类之后,RMI服务端一直运行在服务器上

3、构造函数需要抛出一个RemoteException错误

4、实现类中使用的对象必须都可序列化,即都继承java.io.Serializable,但是如果实现类继承了UnicastRemoteObject,UnicastRemoteObject该类中已经继承了Serializable,所以就不用再写一次了!

这里的UnicastRemoteObject类的作用?参考:https://xz.aliyun.com/t/9261

但远程接口实现类必须继承UnicastRemoteObject类,用于生成 Stub(存根)和 Skeleton(骨架)。

Stub可以看作远程对象在本地的一个代理,囊括了远程对象的具体信息,客户端可以通过这个代理和服务端进行交互。

Skeleton可以看作为在服务端的一个代理,用来处理Stub发送过来的请求,然后去调用客户端需要的请求方法,最终将方法执行结果返回给Stub。

写好了远程对象的类之后之后,继续来写RMI的服务端,步骤如下:

1、将RMI服务名称创建到注册表Registry中

2、将远程对象注册到注册表Registry中去,也就是绑定远程对象与对应的RMI服务名称

实现代码如下:

public class RMIServer {
    // RMI服务器IP地址
    public static final String RMI_HOST = "192.168.1.5";
    // RMI服务端口
    public static final int RMI_PORT = 9527;
    // RMI服务名称
    public static final String RMI_NAME = "rmi://" + RMI_HOST + ":" + RMI_PORT + "/AAAAAAA";

    public static void main(String[] args) throws RemoteException, AlreadyBoundException, MalformedURLException {
        // 注册RMI服务端口
        LocateRegistry.createRegistry(RMI_PORT);
        // 绑定对应的Remote对象(这里就是你的RMITestImpl对象)
        Naming.bind(RMI_NAME, new RMITestImpl());
        System.out.println("RMI服务启动成功,服务地址:" + RMI_NAME);
    }
}

到目前RMI服务端就已经写好了,继续来写客户端,客户端需要找到对应的RMI服务端中的指定服务名称,并请求远程对象

public class RMIClient {

    public static void main(String[] args) throws RemoteException, NotBoundException, MalformedURLException, ClassNotFoundException, NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {

        // 查找指定RMI_NAME的远程RMI服务
        RMITest rt = (RMITest) Naming.lookup(RMI_NAME);

        // 调用远程接口RMITestInterface类的test方法
        String result = rt.test();

        // 输出结果
        System.out.println(result);
    }
}

先开启RMI服务端

然后运行RMI客户端,一次完整的RMI远程方法调用就这样实现了!

RMI动态加载

RMI的流程中,客户端和服务端之间传递的是一些序列化后的对象。如果某一端反序列化时发现一个对象,那么就会去自己的CLASSPATH下寻找想对应的类。

如果当前JVM中没有某个类的定义(即CLASSPATH下没有),它可以根据codebase去下载这个类的class,然后动态加载这个对象class文件。

codebase是一个地址,告诉Java虚拟机我们应该从哪个地方去搜索类;CLASSPATH是本地路径,而codebase通常是远程URL,比如http、ftp等。所以动态加载的class文件可以保存在web服务器、ftp中。

如果我们指定 codebase=http://example.com/ ,动态加载 org.vulhub.example.Example 类,
则Java虚拟机会下载这个文件http://example.com/org/vulhub/example/Example.class,并作为 Example类的字节码。

那么只要控制了codebase,就可以加载执行恶意类。同时也存在一定的限制条件:

1、安装并配置了SecurityManager

2、Java版本低于7u21、6u45,或者设置了 java.rmi.server.useCodebaseOnly=false

java.rmi.server.useCodebaseOnly 配置为 true 的情况下,Java虚拟机将只信任预先配置好的 codebase ,不再支持从RMI请求中获取。

具体细节在java安全漫谈-05 RMI篇(2)一文中有描述。

漏洞的主要原理是RMI远程对象加载,即RMI Class Loading机制,会导致RMI客户端命令执行的。

posted @ 2021-05-03 20:13  zpchcbd  阅读(469)  评论(0编辑  收藏  举报