深入学习rmi工作原理
2020-12-15 11:42:35 Author: xz.aliyun.com(查看原文) 阅读量:195 收藏

1. 前言

随着对rmi反序列化的深入学习,发现它的攻击面并没有一开始理解的浅显。主要还是在看这篇针对RMI服务的九重攻击时,发现攻击中涉及的类和通信协议足以让我单独花上时间研究一阵子了。因此这篇算是我单独研究rmi调用流程和源码的总结。

2. 回顾

先回顾一下rmi工作的流程:

  1. 注册端开启注册服务
  2. 服务端在注册端通过String - Object的映射关系绑定对象
  3. 客户端通过一个字符串向注册端查询获取到操纵远程对象的stub(?)
  4. 客户端通过stub来执行远程方法

在整个流程中,涉及了两组关系:

  • 客户端——注册端
  • 客户端——服务端

这里的两个客户端都是相对而言的,并不是指代同一个对象。比如,注册端开放在192.168.242.1:1099端口,下述代码的行为就是我说的第一个客户端:

Registry registry = LocateRegistry.getRegistry("192.168.242.1", 1099);
String[] lists = registry.list();
registry.lookup("Hello");
registry.bind("las", new myRemoteObj());  // only work for localhost

再比如,当通过lookup方法获取到一个对象后,对该对象的操作就是指代第二种实体关系:

...
myInterface obj = registry.lookup("Hello");
obj.myMethod("param");

上面两种实体之间的通讯细节和协议,就是我接下来要尝试解释的东西。

由于分析rmi源码也是为了能够更深入的学习rmi安全问题,所以我列出了分析中的一些关注点以助于理解:

  • 当服务端要抛出一个错误时,它的调用栈是怎样的。这个错误是怎样被发送给客户端的。
  • 客户端的DGCClient对象发起dirty call的流程。这里实际上就是ysoserial中JRMPClient载荷利用的地方。
  • UnicastServerRef#dispatch方法的两个分支。

3. 客户端—注册端

注册端

简单来说,注册端监听在1099端口,解析客户端发起的所有连接,判断它们是listbindrebindlookupunbind这五种行为的哪一种,并调用相应的方法来处理。不过在此之前,它们还会传递一些数据包用于认证和信息交换。比如,下面是客户端在执行lookup方法时所产生的流量:

不过注册端不只会处理这些api调用,后面会看到,注册端还会处理dgc和一些心跳包的发送。

分析清楚了注册端的行为,就能搞清楚客户端所做的事情,两者是相对的。


我们首先分析注册端。

一般来说,注册端的关键代码如下:

IRemoteHelloWorld remoteHelloWorld = new RemoteHelloWorldImpl();
Registry registry = LocateRegistry.createRegistry(1099);
registry.bind("Hello", remoteHelloWorld);

处理网络数据前的调用链

进入createRegistry方法:

这里获取到的是一个RegistryImpl对象。需要说明的是,客户端一般调用的是LocateRegistry.getRegsitry(ip,port),它获取到的是一个RegistryImpl_Stub对象。故可知,stub和skel的概念是相对而言的,并不只存在于服务端和客户端之间。

进入RegistryImpl的构造方法:

可以看到,无论是if还是else语句块中,核心代码都是:

LiveRef var2 = new LiveRef(id, var1);
this.setup(new UnicastServerRef(var2));

这里之所以有个if,是为了保证当程序设置了SecurityManager后,只有当rmi注册服务开放在1099端口时才能执行核心代码。在设置SecurityManager策略后,程序本身可能会没有特权去执行核心代码,因此需要通过AccessController.doPrivileged的形式去赋予特权。关于AccessController可参见连接

总的来说,这里的if相当于提供了一个安全策略:程序员可以通过设置securityManager来保证rmi服务只能开放在1099端口。

接下来进入this.setup()方法:

这里执行了UnicastServerRef上的exportObject方法,这也是第一次看到exportObject方法出现。执行了它之后,客户端就可以调用注册端上的api了,就好像注册端(object)被暴露(export)在了1099端口一样。

在openjdk代码的注释中解释的则更detail:

/**
     * Export this object, create the skeleton and stubs for this
     * dispatcher.  Create a stub based on the type of the impl,
     * initialize it with the appropriate remote reference. Create the
     * target defined by the impl, dispatcher (this) and stub.
     * Export that target via the Ref.
     */

说明在这个方法里,会创建注册端相应的stub对象和skeleton对象。

继续跟进:

这里先跟进Util.createProxy()方法:

发现最后执行了createStub方法,这个方法通过反射实例化了sun.rmi.registryImpl_Stub对象并将其作为返回值。

这里就知道了,var5就是一个registryImpl_Stub对象。

同时这里也调用了this.setSkeleton来设置一个registryImpl_Skel

接下来回到刚刚的exportObject方法中,发现它创建了一个Target对象var6,然后调用了this.ref.exportObject(var6),这里的ref,就是我们前面创建UnicastServerRef时传入的liveRef对象:

于是跟进liveRef#exportObject()方法:

跟进:

继续跟进:

这里的this.listen()方法是重点,执行它之后注册端就开始监听1099端口了。于是我们跟进看它的内部逻辑:

这里会进入到第一个if代码块内。

可以看到这里又出现了AccessController.doPrivileged()方法。由于它最终会调用到TCPTransport.AcceptLoop#run方法,我们直接在这个方法下断点并跟进:

跟进:

下面的代码都是catch块就不贴出来了。

这里的this.serverSocket就是在TCPTransport#listen方法中创建的,它监听的端口就是我们最开始传入的port。

接下来进入try中的代码块,它其实又创建了新线程。跟进ConnectionHandler#run方法:

再跟进this.run0()方法。

处理网络数据

需要说明的是,从这个方法开始,就能看到注册端开始读取并解析客户端传递的TCP数据,根据字段的类型来执行相应的bindlist等操作,并将结果返回。

由于该方法比较长,所以我逐段进行分析。

前面的一些代码主要是设置TCP的一些参数,不管,看下面的部分:

这里是第一次从输入流读取数据,接下来会根据var6的值判断进入哪个if块:

这里的1347375956转为十六进制再转为字符串就是:POST。说明这里的逻辑是判断它是否为http流量,一般不会进入这个分支。

我们进入第二个if。1246907721转为十六进制其实是0x4a524d49,这其实就是rmi协议的魔术头,同时第二个short字段表示版本:

在这个if分支里又读取了一个字节存到var15,然后进入switch。这里一般来说读取到的是0x4b,即75:

进入case后,由于已经解析完了第一个接受包,注册端开始构造第一个回复包:

直到var10.flush();,注册端缓冲区的数据被发送出去。

接下来的代码:

String var16 = var5.readUTF();
int var17 = var5.readInt();

用于解析客户端发送的第二个数据包,不过这个数据包似乎没起到什么作用。第二个数据包内容如下:

接下来进入TCPTransport.this.handleMessages(var14, true)。注意第二个参数为true,它让接下来的代码中的while语句不断循环。见红框圈处:

这里int var5 = var4.read()其实已经开始解析客户端发送的第三个数据包了(说明注册端并没有回复第二个数据包,从流量图也能看出),第三个数据包的内容将在之后贴出。初看这个var5是int仿佛是读取4个字节,但是跟进var4.read()能看到其实还是读取的一个字节:

var5的内容其实是表示该数据包是哪种类型。一般来说有下面三种:

  • 80,即0x50,表示执行业务方法。这里是调用注册端的某个方法(如listbind),后面会看到,客户端在执行远程方法时,服务端也会从这里进去。

    例如下述数据包:

  • 82,即0x52,心跳包(大概),这里可以看到注册端回复了一个字节0x53。例如下面两个数据包:

  • 84,即0x54DgcAck,如下:

这里继续跟进80,也就是调用注册端api方法的case。进入StreamRemoteCall#serviceCall

我们首先贴出第三个数据包的内容:

需要注意的是,我们上面已经读取了一个字节了,接下来的aced之后的内容,按理来说都是序列化的内容了。

但是注意这里的var39 = ObjID.read(var1.getInputStream());,它是从什么地方读取的内容呢?

其实内容是在序列化数据的BlockData块。

读取了ObjID后,又开启了新线程。进入var6.dispatch(),也就是UnicastServerRef#dispatch

这里其实是进入this.oldDispatch。继续跟进:

后面在分析客户端—服务端时可知,如果不进入这个if语句,之后所作的事情其实就是服务端处理客户端调用远程对象方法的部分。这里留个印象就好。

这里的this.skel就是在之前调用UnicastServerRef#exportObject方法时设置的registryImpl_Skel。跟进registryImpl_Skel#dispatch

终于到这里了!这里就是注册端最后调用各个api(listbind等)的地方。

看一眼堆栈:

不过到这里,我们只跟进了LocateRegistry.createRegistry的内容。注册端的代码还有一条:

registry.bind("Hello", remoteHelloWorld);

万幸是registryImpl#bind的逻辑很简单:

这里只是将String——obj的映射关系放到了registryImplbindings中。

后续可以看到,客户端执行lookup方法时,注册端就会从registryImplbindings中查询对象:

注册端分析完毕。接下来分析客户端。

图示

注册端调用LocateRegistry#createRegistry的流程比较复杂,所以截了下图便于更直观的看到调用关系。

每一个线程单独为一张图。

主线程:

线程一:

线程二:

线程三,从这里开始处理网络数据:

线程四,又回到了UnicastServerRef方法,最终调用了RegistryImpl_Skel#dispatch

客户端

客户端代码如下:

Registry registry = LocateRegistry.getRegistry("192.168.242.1", 1099);
 registry.lookup("Hello");

开启注册端后开始debug。

首先跟进LocateRegistry#getRegistry方法:

继续跟进:

最后同样调用Util#createProxy创建了一个registryImpl_Stub对象(这个方法在注册端的UnicastServerRef#exportObject中曾被调用)。传入的UnicastRef中包含了ObjIDhostport等信息,用于连接注册端:

到这里可知,客户端调用LocateRegistry#getRegistry方法获取到的对象是RegistryImpl_Stub


接下来跟进客户端的第二行代码,即RegistryImpl_Stub#lookup

super.ref.newCall( this, operations, 2, ...);处会发起前期的握手包(不是tcp的),

然后在super.ref.invoke(var2);处发送lookup的数据。

在接下来的var23=(Remote)var6.readObject()获取到服务端发送的代理对象。

这里的super.ref就是一个unicastRef对象。

super.ref.newCall()

这里先跟进super.ref.newCall

再跟进this.ref.getChannel().newConnection(),AKATCPChannel#newConnection

跟进this.createConnection(),这部分代码有些长,分两部分列出:

这里的var3.writeByte(75)就是去构造了客户端发送的第一个握手包,在注册端的分析中我们知道它其实表示StreamProtocol

至于if块中的var3.writeByte(76),应该是在socket不是Reusable时,采用将握手包和实际数据合在一起的方式(可以看到它没有var3.flush())。

接着var3.writeByte(75)看它之后的代码:

这里是在读取注册端回复的第一个数据包。长这样,发现它把我们的host和port又发给了我们:

var3.writeUTF开始,客户端发送第二个数据包:

到这里为止,客户端发送了两次数据包,处理了注册端的第一个回复。

回到super.ref.newCall

接下来进入var7 = new StreamRemoteCall(var6, this.ref.getObjID(), var3, var4);

这里其实是在构造实际的lookup包了。但是还没有写入对象,只是将一些信息写到了BlockData中。

到这里为止,super.ref.newCall()的功能基本分析完毕。该回到RegistryImpl_Stub#lookup分析下个方法super.ref.invoke。在此之前,需要看到,在lookup中,它先调用了var3.writeObject(var1)将查询的字符串写入了缓冲区:

super.ref.invoke()

跟进UnicastRef#invoke

跟进StreamRemoteCall#executeCall()

首先在this.releaseOutputStream()中将刚才的写的数据(主要是lookup的字符串)发送了出去:

到这里,客户端发送了第三个数据包。

客户端发送这个数据包之后,是期望得到注册端的回复的,因为我们希望通过lookup获取到一个对象,然后基于这个对象来操纵远程对象。

我们看一下回复数据包长啥样:

不太清晰,用SerializationDumper.jar打开看下:

可以看到,返回的对象是一个继承了java.rmi.Remoteorg.las.remote.IRemoteHelloWorld(自定义)接口的代理对象(TC_PROXYCLASSDESC),同时也需要注意,在TC_BLOCKDATA中也有东西。后面读的一些内容就是从这里拿的。

接着releaseOutputStream的代码就开始对注册端回复的这个数据包进行处理。

首先验证了第一个字节是否为0x51,接着是读取存储在BlockData中的一个字节和UID。

这里我们回过头看一下注册端最后RegistryImpl_skel#dispatch的部分,因为我们其实并没有分析它在调用了api函数后是怎样发送数据包的。我们就以lookup为例:

首先关注这个var2.getResultStream(true),在这个函数里会写入两个字节和UID。

接下来将lookup查询到的var8写入流中。

然后回到调用RegistryImpl_skel#dispatch的上级方法UnicastServerRef#oldDispatch

这里调用releaseInputStream发送了缓冲的数据。

回过头来,刚才分析到StreamRemoteCall#executeCall读取了BLOCK_DATA中的数据,但还没有读取关键的代理对象,继续看该方法的代码:

这里之前读到的var1其实是1,所以我们return到上一级。不过也能从这里看到,当读到的var2是2时,是会在这里抛出异常的。

var6.readObject()

重新回到RegistryImpl_Stub#lookup,这里通过了var23 = (Remote)var6.readObject()来获取到lookup查询到的代理对象。

最后把这个var23返回,这就是lookup的全过程了:

至于finally中的super.ref.done大致就是将刚才的流释放的操作。

此时的堆栈很简洁:

总结

  1. 以客户端定位到注册端并调用lookup为例,客户端与注册端的通信流程如下:
    • 客户端发送第一个握手包,注册端回复;
    • 客户端发送第二个包含ip和端口的包,注册端并不回复;
    • 客户端发送lookup数据包,其内容是字符串的序列化。注册端返回一个序列化的代理对象。
  2. 注册端底层使用UnicastServerRef对象发送请求。与之相对的,客户端使用UnicastRef
  3. 注册端上层获取的对象是RegistryImpl,而客户端上层获取的是RegistryImpl_Stub

4. 客户端—服务端

编写服务端前首先得提供一个接口和一个远程对象。

我自己编写了一个远程调用接口:

package org.las.remote;

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

public interface IRemoteHelloWorld extends Remote {
    public Object helloWorld(Object word) throws RemoteException;
}

一个远程对象:

package org.las.remote;

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

public class RemoteHelloWorldImpl extends UnicastRemoteObject implements IRemoteHelloWorld {

    public RemoteHelloWorldImpl() throws RemoteException{

    }

    public Object helloWorld(Object word) throws RemoteException {
        System.out.println("Hello world..");
        return "las";
    }
}

我的客户端代码:

import org.las.remote.IRemoteHelloWorld;

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

public class RMIServer {
    public static void main(String[] args) throws Exception{
        Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
        IRemoteHelloWorld helloWorld = (IRemoteHelloWorld) registry.lookup("Hello");
        helloWorld.helloWorld("las");
    }
}

在分析客户端和服务端之前,我想先贴出两者之间产生的流量。

红框框住的地方,是客户端在调用远程方法间隙,执行的有关DGC的操作。同时也可以看到,客户端发送的数据包有些太多了。除去握手包,注意第24和26两个包,根本不是我自己发的(远程调用的数据包是32、34号)而且数据量比较大。通过将24、26的数据反序列化后会发现,它们也是和DGC有关的。

关于DGC,简单提一下,全称是distribute garbage collection。其实它的存在也很合理,当客户端获取远程对象时,服务端需要创建一个对象,以供客户端来调用其上的方法;但是这些对象也并不是永久存在的,它们也需要垃圾收集,这就引出了DGC的概念。但是具体是如何实现的我不太清楚,这也是我分析RMI源码的一个原因,希望接下来的分析能让我进一步理解它是如何实现的。

这一次我们先从客户端开始分析。

在此之前,又先抛出另外一个问题。

奇怪的UnicastRemoteObject

我们知道,一个远程对象,要么得继承UnicastRemoteObject类,要么得通过UnicastRemoteObject#exportObject进行转换:

public class RMIServer {
    public static void main(String[] args) throws Exception{
        System.setSecurityManager(null);
        IRemoteHelloWorld remoteHelloWorld = new RemoteHelloWorldImpl();
        IRemoteHelloWorld remoteHelloWorld2 = new RemoteHelloWorldImpl2();
        Object obj2 = UnicastRemoteObject.exportObject(remoteHelloWorld2,0);
        System.out.println(remoteHelloWorld);
        System.out.println(obj2);
        Registry registry = LocateRegistry.createRegistry(1099);
        registry.bind("Hello", remoteHelloWorld);
        registry.bind("Hello2", (Remote) obj2);
        System.out.println("[*] RMI Server started...");
    }
}

这里我们将两个对象都打印出来看看:

后者是一个代理对象,前者还是正常的RemoteHelloWorldImpl

但是在客户端看看呢:

public class RMIClient {
    public static void main(String[] args) throws Exception{
        Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
        IRemoteHelloWorld helloWorld = (IRemoteHelloWorld) registry.lookup("Hello");
        IRemoteHelloWorld helloWorld2 = (IRemoteHelloWorld) registry.lookup("Hello2");
        System.out.println(helloWorld);
        System.out.println(helloWorld2);
    }
}

打印结果:

都是代理对象!

这就奇怪了,在分析客户端—注册端时,我们讨论了在调用registry.bind时只是单纯将对象放到registryImpl的成员变量bindings中:

在客户端lookup时,注册端也只是从这个bindings里将对象取出,并调用writeObject

所以问题只可能出在writeObject方法中。

我们跟进var9.writeObject方法,在下面这个地方发现了敏感的函数replaceObject,在进入前我们是RemoteHelloWorldImpl,出来时就是代理对象了:

此时的调用堆栈:

进入MarshalOutputStream#replaceObject瞧瞧:

这里的ObjectTable看着怪熟悉的。它在哪个地方似乎出现过。

其实是在TCPTransport#exportObject中:

我们之前只跟进了this.listen(),但是在它下面的super.exportObject(var1)里:

这里!它将Target放到了ObjectTable中。

但是到目前为止,我还有两个疑问:

  • 服务端在什么时候进入了TCPTransport#exportObject,然后将这个Target放到了objectTable里呢?
  • replaceObject中,调用Target上的getStub方法获取到的对象和我们在服务端自定义的RemoteHelloWorldImpl有什么关系呢?

由于我们的对象RemoteHelloWorldImpl继承了UnicastRemoteObject类,第一时间想到的就是它的构造函数。我猜测也许是在新建RemoteHelloWorldImpl时,调用的父类无参构造函数做了些事情:

跟进:

发现它竟然调用了exportObject这个静态方法。它是我们创建一个远程对象的第二种方式。继续跟进:

跟进ㄟ( ▔, ▔ )ㄏ:

终于到头了。

这里得到了两个信息:

  • 首先,两种导出远程对象的形式最终都会去执行UnicastServerRef#exportObject
  • 其次,采用UnicastRemoteObject.export(obj, port)获取的远程对象,其实就是UnicastServerRef#exportObject的返回值。

而这个方法,我在客户端—注册端其实已经分析了。它的返回值就是一个stub,并且在它冗长的调用链中肯定会执行TCPTransport#exportObject方法。这里又再贴一下UnicastServerRef#exportObject方法:

至此,之前的第一个问题解决了。第二个问题:调用Target上的getStub方法获取到的proxy和我们自己的对象RemoteHelloWorldImpl有什么关系呢?

进入Target的构造函数可以看到:

var3就是我们刚刚创建的Stub对象。

回到最最最开始,我们奇怪两种创建远程对象的方式最终在客户端获取到的都是同一种类型的问题也解决了:

  • 使用extends UnicaseRemoteObject的方式,在writeObject的时候,会去获取到相应的Target上的stub对象。
  • 使用UnicastRemoteObject.exportObject(obj,0);的形式,由于它本身返回值就是这个stub,所以会直接被写入。

此时我仍然疑惑,这个stub对象究竟是哪一个类?

跟进到UnicastServerRef#exportObject中的Util.createProxy()方法:

之前获取的stub对象是在上面if代码块中的createStub()

而这里则是创建了一个RemoteObjectInvocationHandler的代理对象。

客户端

远程方法调用

经过了上面的分析,我们知道了客户端的调试需要从RemoteObjectInvocationHandler#invoke方法开始:

前面几个if都直接跳过,直接进入到invokeRemoteMethod

进入到UnicastRef#invoke方法,这里只贴出较关键的部分:

new StreamRemoteCall()之前已经跟过一次,就是写入一个版本字节,同时在BLOCK_DATA处写入数据:

在for循环下的marshalValue()部分,是将客户端执行方法传入的参数一个个写入到流中:

接下来跟进var7.executeCall(),即StreamRemoteCall#executeCall方法:

this.releaseOutputStream();为止,客户端的调用请求被发送出去。接下来的地方又是老样子,读取首字节,然后获取BLOCK_DATA信息,如果没有抛异常则正常返回(见之前的分析)。

紧接着在unmarshalValue()方法中,将服务端执行命令后的返回对象进行了反序列化读取到var50中,并作为了UnicastRef#invoke的返回值。

dgc分析

等等,是不是什么东西漏了?明明还有DGC这些东西啊。它们的流量似乎在RemoteObjectInvocationHandler#invoke之前就发出了。

调试后发现,有关DGC的那段流量,在客户端执行registry.lookup方法时就发出了:

仔细调试后,定位到执行RegistryImpl_Stub#lookupfinally块中的代码会发出这些流量:

不断跟进,可以来到StreamRemoteCall#releaseInputStream处:

registerRefs意味注册引用。看名字有点像是在告诉远端的JVM对对象增加引用的意思。

跟进:

这里获取到的var5类型是List<LiveRef>

跟进DGCClient#registerRefs

这里也许有一些难懂,但是参考jdk源码的注释则一目了然:

/**
     * Register the LiveRef instances in the supplied list to participate
     * in distributed garbage collection.
     *
     * All of the LiveRefs in the list must be for remote objects at the
     * given endpoint.
     */
    static void registerRefs(Endpoint ep, List<LiveRef> refs) {
        /*
         * Look up the given endpoint and register the refs with it.
         * The retrieved entry may get removed from the global endpoint
         * table before EndpointEntry.registerRefs() is able to acquire
         * its lock; in this event, it returns false, and we loop and
         * try again.
         */
        EndpointEntry epEntry;
        do {
            epEntry = EndpointEntry.lookup(ep);
        } while (!epEntry.registerRefs(refs));
    }

也就是说,每一个Endpoint,它可能有不只一个LiveRef,我们要在这个循环中向Endpoint注册所有这些LiveRef。这里,Endpoint就理解为某个主机上监听在某个端口的进程就行了。

接下来跟进epEntry.registerRefs,即DGCClient#registerRefs(List)

跟进DGCClient#makeDirtyCall

跟进DGCImpl_Stub#dirty

可以看到,在这里发送了dgc请求,并且收到服务端的回复是一个java.rmi.dgc.Lease对象。

并且我们在这里再次看到了“_stub“字眼,再次说明skel和stub的概念不只用于客户端和服务端之间。

这个Lease对象的作用可见官方注释:

/**
 * A lease contains a unique VM identifier and a lease duration. A
 * Lease object is used to request and grant leases to remote object
 * references.
 */

发现它确实有一个成员变量来表示对象的存活时间:

回过头来,再看一下DGCClient这个对象上官方的注释:

/**
 * DGCClient implements the client-side of the RMI distributed garbage
 * collection system.
 *
 * The external interface to DGCClient is the "registerRefs" method.
 * When a LiveRef to a remote object enters the VM, it needs to be
 * registered with the DGCClient to participate in distributed garbage
 * collection.
 *
 * When the first LiveRef to a particular remote object is registered,
 * a "dirty" call is made to the server-side distributed garbage
 * collector for the remote object, which returns a lease guaranteeing
 * that the server-side DGC will not collect the remote object for a
 * certain period of time.  While LiveRef instances to remote objects
 * on a particular server exist, the DGCClient periodically sends more
 * "dirty" calls to renew its lease.
 *
 * The DGCClient tracks the local reachability of registered LiveRef
 * instances (using phantom references).  When the LiveRef instance
 * for a particular remote object becomes garbage collected locally,
 * a "clean" call is made to the server-side distributed garbage
 * collector, indicating that the server no longer needs to keep the
 * remote object alive for this client.
 *
 * @see java.rmi.dgc.DGC, sun.rmi.transport.DGCImpl
 *
 * @author  Ann Wollrath
 * @author  Peter Jones
 */

这里看到,自调用StreamRemoteCall#releaseInputStream中的this.in.RegisterRefs以来,客户端所作的事情,确实是向服务端注册每一个LiveRef,并且获取到服务端返回的Lease,来告诉服务端短时间内不要回收这些对象。至于像注释说的,租约lease到期后需要再次发送dirty call的操作,就不再跟进了。

服务端

我们在客户端—注册端一节,调试LocateRegistry#createRegistry时曾跟进了UnicastServerRef#exportObject方法。它的调用栈会在某个地方执行this.listen()方法监听在某个端口接受客户端的请求;

回到客户端—服务端,由于前面分析过,创建一个远程对象无论怎样都会调用UnicastRemoteObject#exportObject方法:

在这里也调用了UnicastServerRef#exportObject方法。

所以服务端和注册端一样都通过了UnicastServerRef对象来开放服务。这里就不进一步跟进了。

需要强调的是UnicastServerRef#dispatch方法,我前面也提到过:

在远程方法调用时,它是不会进入这个if分支的,而是会继续向下执行。

它首先会从返回数据中读取一个数据类型为Long的变量:

这个long变量其实是客户端想要调用方法的哈希,当在this.hashToMethod_Map获取不到时会报错:

在学习使用反序列化攻击rmi服务端时,如果在客户端自己魔改了远程对象的接口方法(比如参数类型原本在服务端是java.lang.String,但在客户端修改为java.lang.Object),此时需要修改这个哈希值才能正常攻击。

总结

  • dgc流程:
    1. 客户端通过lookup获取到一个对象后,会向服务端发起一次dirty call,以通知服务端短时间内不要回收该对象;
    2. 服务端返回给客户端一个lease,该对象告诉了客户端接下来多久的时间内该对象是有效的。如果客户端在时间到期后还需要使用该对象,则需要继续调用dirty call
    3. DGCClient会跟踪每一个liveRef,当他们在客户端已经不再有效后,就会发起clear call告诉服务端可以回收有关对象了。
  • 无论是rmi注册端还是服务端,它们都通过UnicastServerRef#exportObject开启指定端口上的服务,最终都会进入TCPTransport#handleMessages中的循环来监听输入流,并且最后又都会使用UnicastServerRef#dispatch来调用注册端或者服务端的功能。

参考链接

  1. 针对RMI服务的九重攻击
  2. Java安全-RMI-学习总结
  3. DGCClient源码

文章来源: http://xz.aliyun.com/t/8644
如有侵权请联系:admin#unsafe.sh