RMI

RMI概述

RMI(Remote Method Invocation),远程方法调用。说直白点就是可以利用机器A调用远程机器B上面的方法。但是这是依赖JVM实现的,所以也只能从一个JVM到另一个JVM去调用。
话不多说来个简单的代码理解:
我们首先敲服务端的代码:

package org.example;

import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIServer {
    public static void main(String[] args) throws RemoteException, AlreadyBoundException {
        IRemoteObj remoteObj = new RemoteObjImpl();
        Registry registry= LocateRegistry.createRegistry(1099);
        registry.bind("remoteObj", remoteObj);
    }
}

然后完善RemoteObjImpl类:

package org.example;

import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;

public class RemoteObjImpl extends UnicastRemoteObject implements IRemoteObj {

    public RemoteObjImpl() throws RemoteException {
    }

    @Override
    public String sayHello(String keywords) {
        String upKeywords = keywords.toUpperCase();
        System.out.println(upKeywords);
        return "copy that";
    }
}

这个就是被调用的远程方法的具体实现,要执行的代码逻辑全写在这里了,代码比较简单就不赘述了。
最后再添加一个服务端的IRemoteObj:

package org.example;

import java.rmi.Remote;
import java.rmi.RemoteException;

public interface IRemoteObj extends Remote {
    public String sayHello(String keywords) throws RemoteException;
}

接着来编写客户端:

package org.example;

import java.rmi.NotBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIClient {
    public static void main(String[] args) throws RemoteException, NotBoundException {
        Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
        IRemoteObj remoteObj = (IRemoteObj) registry.lookup("remoteObj");
        String hello = remoteObj.sayHello("hello");
        System.out.println(hello);
    }
}

客户端还需编写一个接口IRemoteObj,用于说明客户端需要调用何种方法:

package org.example;

import java.rmi.Remote;
import java.rmi.RemoteException;

public interface IRemoteObj extends Remote {
    public String sayHello(String keywords) throws RemoteException;
}

实验结果:
服务端:

客户端:

完成了这个简单小实验后对RMI的理解应该深了一点,我们现在来更完整地理解RMI的流程
首先有服务端和客户端,客户端如何调用服务端呢?服务端通过绑定远程对象,这个对象可以封装网络操作,网络之间的通信就是端口之间的通信,客户端只要传递需要调用的方法的名字即可。可是客户端和服务端之间的沟通端口怎么确定呢,Java为了解决这个问题,弄了一个叫做注册中心的东西,并且固定端口为1099。因此只要任何想要和服务端通信的机子只要来1099端口询问要调用的服务开在哪个端口即可,同理在服务端开设服务也要到注册中心注册并且会使用动态分配端口的方法来开设服务。
需要注意的是端口之间通信的话接口也要相同(java.rmi.Remote),同时接口也要抛出异常,这样才能通信。

最后说几个坑点:

1.最好把这个RMIClient和RMIServer分开,不然很容易写岔。
2.分开写之后两边的包名都要相同,否则反序列化,然后报错
3.服务端new一个RemoteObjImpl的时候是用RemoteObj这个接口去声明的

RMI流程分析

RMI创建远程服务

https://xz.aliyun.com/t/9261这个链接偷了一张图

服务端有注册中心,是一个hash表,用来存储名字和远程对象。
客户端是连接注册中心,获取名字来调用远程对象。
客户端和服务端并不是直接进行交互的,而是利用了代理。服务端的代理叫做Skeleton,客户端的代理叫做Stub
用代理的目的是为了把不属于业务的东西提取出来。
产生漏洞的地方肯定是在交互过程中发生的,但是出问题的是在哪部分呢?从图中可以看到有六部分,为了寻找问题到底是出在哪部分,我们从服务端的创建开始逐个分析。
我们从下面这段代码开始调试

package org.example;

import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIServer {
    public static void main(String[] args) throws RemoteException, AlreadyBoundException {
        IRemoteObj remoteObj = new RemoteObjImpl();
    }
}

开始分析创建远程对象的这个流程,因为这个流程是把服务发布到网上,我们一步一步来看它是如何发布的。
在图示地方下断点:

下一步走到构造函数:

如何再下一步走到UnicastRemoteObject的构造函数:

同时注意到此时的port是0,这里的0就是代表随机值。因为这里是把服务发布到网络上(如果对端口有疑惑为什么不是1099的要注意区分注册中心和服务端口的区别),所以不可能每种服务固定一个端口,这样子一旦服务过多端口会不够用的。
下一步我们跟到调用exportObject这个地方:

根据英文意思这里就是发布对象的感觉,这是一个静态函数,而且也是关键语句。因此我们在RemoteObjImpl这个类中也可以不继承UnicastRemoteObject这个类,直接在构造函数中调用这个静态方法也可以。
这个obj是我们要实现的真正逻辑,后面的new UnicastServerRef是用于处理网络请求的,可以注意到这里只传了port进去,因此ip是他可以自动获取到的。
下一步:

可以看见新建了一个类LiveRef,我们跟进

传进去的是一个ID和一个port,ID就是理解成给个编号吧,port就是之前的默认0端口
然后我们ID就不看了,直接跟进他的构造函数:

然后可以看到
第一个参数是ID
第二个参数是TCPEndpointD的一个静态函数
第三个参数true

我们这里只看第二个参数

可以看到他的里面是返回类型为TCPEndpoint的一个东西,再看一下TCPEndpoint的构造函数:

发现这里他要接受两个参数,host和port。可以感受到这个东西就是一个处理网络请求的东西

我们再看一下LiveRef的构造函数:

接收三个参数,ID,Endpoint,isLocal
其他都好理解,主要就是这个Endpoint是什么,我们看一下它里面有什么东西:

发现这里host已经被获取了
但是port还是0,port如何获取我们后面在分析

LiveRef的创建到这里就完成了,我们需要记住LiveRef的ID,并且我们从头到尾只创建了这一个LiveRef
再往下走,这里也只进行了赋值:

继续往下走:

这里的UnicastServRef就是刚才赋值的的那个东西,只不过包装了而已,而且这也进行了赋值
然后继续往下走到sref.exportObject,继续对sref“exportObject”

但是我们发现这里创建了代理stub

stub明明是客户端的代理,为什么要在服务端创建

因为需要现在服务端创建完这个代理放在注册中心,客户端再到注册中心去使用这个stub进行操作

我们往下看一下这个stub是怎么创建的
第一步是创建一个远程对象类:

Class<?> remoteClass;

        try {
            remoteClass = getRemoteClass(implClass);
        } catch (ClassNotFoundException ex ) {
            throw new StubNotFoundException(
                "object does not implement a remote interface: " +
                implClass.getName());
        }

第二步是判断:

forceStubUse ||!(ignoreStubClasses || !stubClassExists(remoteClass))

forceStubUse 表示当不存在时是否抛出异常
是否存在以 _Stub 结尾的类。remoteClass + "_Stub" 

stubClassExists的具体逻辑是这样的:

private static boolean stubClassExists(Class<?> remoteClass) {
        if (!withoutStubs.containsKey(remoteClass)) {
            try {
                Class.forName(remoteClass.getName() + "_Stub",
                              false,
                              remoteClass.getClassLoader());
                return true;

            } catch (ClassNotFoundException cnfe) {
                withoutStubs.put(remoteClass, null);
            }
        }
        return false;
    }

第三步就是创建动态代理了:

try {
            return AccessController.doPrivileged(new PrivilegedAction<Remote>() {
                public Remote run() {
                    return (Remote) Proxy.newProxyInstance(loader,
                                                           interfaces,
                                                           handler);
                }});
        } catch (IllegalArgumentException e) {
            throw new StubNotFoundException("unable to create proxy", e);
        }

创建完stub,就是收尾工作,这里创建了一个Target

把我们之前创建的所有东西全都放在这里
创建完Target就进行ref.exportObject(target)
就是对这个target进行发布

我们可以看见这个ep里面有TCPTransport,所以当我们执行transport.exportObject(target)的时候会对TCPTransport进行exportObject:

我们跟进到listen里面:

可以发现这里创建了一个Socket等待别人连接,并且使用了t.start()创建一个新的线程。
此时已经成功把服务发布到网络上面了,但是客户端并不知道,注册中心也不知道,所以他自己需要先记录一下这个发布的服务
在ObjectTable中执行了这么两行代码:

objTable.put(oe, target);
implTable.put(weakImpl, target);

发现这里是用Map来记录的,并且把刚才创建的target当作值。同时这里还是一个静态表

上面的流程我们分析了服务端创建远程服务,接下来我们来看如何创建注册中心、创建的服务如何和注册中心绑定

小结

注册中心的创建和远程服务的发布其实是没有关系的,他们之间并不在乎谁先谁后。因为发布远程服务和注册中心的创建他们本质上都是一样的,都是把某个服务发布到某个端口上,只不过注册中心通常是固定在1099端口,而服务则是随机发布到某一个端口上。
注册中心的创建和发布远程对象本质是一样的我们在下面的代码分析中也会提到。
所以这段代码:

//方式一
public class RMIServer {
    public static void main(String[] args) throws RemoteException, AlreadyBoundException {
        Registry registry= LocateRegistry.createRegistry(1099);
        IRemoteObj remoteObj = new RemoteObjImpl();
        registry.bind("remoteObj", remoteObj);
    }
}

//方式二
public class RMIServer {
    public static void main(String[] args) throws RemoteException, AlreadyBoundException {
        IRemoteObj remoteObj = new RemoteObjImpl();
        Registry registry= LocateRegistry.createRegistry(1099);
        registry.bind("remoteObj", remoteObj);
    }
}

这两种代码怎么写都不影响结果,但是我们为了逻辑更合理通常都是利用方式二来写。

创建注册中心:

Registry registry= LocateRegistry.createRegistry(1099); 处下断点,我们开始调试代码。

首先是进入了静态方法createRegisty,并且传入了port1099.
然后这里new了一个RegistryImpl,我们就顺势走到RegistryImpl的构造方法:

重点看下面的new一个LiveRef,然后又new了一个UnicastServRef,并且把LiveRef放在里面,之后调用了setup。
看到这里可能有点懵逼,我们上面不是提到创建注册中心和发布远程服务本质上是相同的嘛,我们可以回顾一下发布远程服务的流程:

那我们在来看看创建注册中心的流程,前三步是不是都和发布远程对象一样的步骤,就是第四步执行了exportObject目前还没有体现,我们跟进到setup函数里面:

其实也是调用了UnicastServerRef.exportObject了。这样看来,其实发布远程对象和创建注册中心本质上就是一样的了,他们都执行了一样的步骤。
唯一的区别就是调用时第三个参数permanent不一样,其实就是代表一个是永久,而另一个是非永久罢了。

接下来我们继续跟进到exportObject函数里面:

到目前为止和我们之前调试发布远程对象都一样,但是我们跟进到createProxy里面就开始有区别了

因为这里会执行一个stubClassExists,这个函数的代码逻辑如下:

private static boolean stubClassExists(Class<?> remoteClass) {
        if (!withoutStubs.containsKey(remoteClass)) {
            try {
                Class.forName(remoteClass.getName() + "_Stub",
                              false,
                              remoteClass.getClassLoader());
                return true;

            } catch (ClassNotFoundException cnfe) {
                withoutStubs.put(remoteClass, null);
            }
        }
        return false;
    }
//功能就是判断JDK中是否有以xxx_Stub的类,有的话就加载


于是就会进入这个类中把他加载出来,具体的加载逻辑是这样的:

try {
    Class<?> stubcl = Class.forName(stubname, false, remoteClass.getClassLoader());
    Constructor<?> cons = stubcl.getConstructor(stubConsParamTypes);
    return (RemoteStub) cons.newInstance(new Object[] { ref });
} 

这里利用反射forName获取类名,然后利用构造器进行实例化加载这个类

这里和服务端的区别就是:服务端是利用动态代理创建出来的,而注册中心是利用JDK自由的类反射创建出来的

接着往下走

if (stub instanceof RemoteStub) //这一步就是判断stub是否是服务端定义好的
{
     setSkeleton(impl);
}

因为这里的stub确实是服务端已经定义好的,于是我们跟进到setSkeleton里面:

再跟到createSkeleton

发现这里和上面创建stub一样也是直接利用反射获取JDK的类名来实例化这个类
出来之后就是创建target,然后发布到网络上,和发布远程对象一样的。
就是这里tartget里面有三个值得注意的东西,就是objTable里面
第一个:RegistryImpl

第二个:DGC(分布式垃圾回收)

第三个:远程服务
可以注意到远程服务的stub类型是动态代理创建的类型为**$Proxy0**

注册绑定

我们直接下bind的断点跟进:

这个checkAccess就是判断是否本地绑定

然后上面那个判断就是判断这个name是否绑定过,没绑定过就put呗
这个bingdings本质上就是一个HashTable,然后把远程对象绑定进去,就是这么简单

服务端的分析到这里告一段落,接下来我们分析客户端

客户端请求注册中心

package org.example;

import java.rmi.NotBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIClient {
    public static void main(String[] args) throws RemoteException, NotBoundException {
        Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
        IRemoteObj remoteObj = (IRemoteObj) registry.lookup("remoteObj");
        String hello = remoteObj.sayHello("hello");
        System.out.println(hello);
    }
}

我们在第十行下断点,我们可以发现他的流程和服务端的一样:

都是重新createProxy,然后利用forName来加载类
执行完后我们可以看到:

这里就是获取注册中心的stub对象,下一步就是通过名字来获取远程对象
我们往下看lookup

下面那个newCall就是创建一个连接
然后有一个writeObject(var1),这个var1就是我们传进来的字符串。我们发现了他被序列化了,到时候注册中心就会反序列化读取他

再往下就是重点invoke方法

invoke方法会调用executeCall()方法
executeCall()方法中的捕获异常中有一个readObject:

在这里如果服务端是一个恶意的类被服务端加载的话,就可以达到攻击客户端的目的

执行完invoke下面还有一个攻击客户端的利用点:

因为这里客户端获取服务端的远程对象的过程是通过反序列化读取他的,那么如果服务端是恶意的反序列反参数就可以攻击客户端

但是这两个反序列的攻击点还是invoke进去的executeCall()这里更加隐蔽,更加常用到。因为很多函数都会调用invoke方法。如bind(),list()

客户端请求服务端

我们从remoteObj.sayHello开始调试
发现我们调试第一步就直接进入了invoke

因为这里remoteObj是一个动态代理,所以调用方法的时候就会直接进入invoke。
我们从invokeRemoteMethod进入
然后跟进invoke:

之后的走到marshaValue函数,这个函数就是判断是否是基本类型,不是的话就序列化

再往下,发现执行了call.executeCall()
其实不管是用户自定义的stub还是系统定义的stub都会调用这个方法,executeCall()是处理网络请求的东西东西,这里也有可能被攻击。因为executeCall()处理走的是JRMP协议,所以通过JRMP进行攻击就是通过RMI自定义的客户端协议进行攻击,攻击的是stub。可以是客户端攻击服务端,也可以是服务端攻击客户端,不过还没有研究。

再往下走,如果调用的远程函数有返回值的话会执行unmarshalValue,并且获取远程返回值是利用反序列化读出来的

DGC的分析

DGC会在创建远程服务的时候就自动创建DGC服务,我们来关注DGC服务是在何时、何处产生的。
我们定位到:putTarget(),这个函数就是在众多七七八八的都创建完之后执行的,把一些东西放在静态表里面,我们可以注意到在putTarget()中,有一个DGCImpl.dgcLog.isLoggable

DGC服务就是在这里创建的,这里是调用了DGCImpl类的静态函数,在类的动态加载中我们提到只要调用了类的静态函数就对这个类进行了初始化,因此会执行类的static静态代码块

在DGCImpl的静态代码块里面的try里执行了new DGCImpl(),再往下看一下stub是怎么创建的,其实原理和我们之前分析服务端的skel和客户端的stub一样,看一下JDK是否有DGCImpl_Stub这个类,有则反射创建。

DGCImpl_Stub类中有两个方法,cleandirty。这两个函数都有我们之前说过比较危险的地方:readObject和invoke


因此存在被攻击的风险。
DGCImpl_Skel也是同理,也存在危险的地方


因此服务端和客户端都存在被攻击的风险。