RMI反序列化
一、什么是 RMI
RMI是远程方法调用,RMI技术可以使一个java虚拟机中的对象去调用另一个java虚拟中的对象方法并获取调用结果。也就是说RMI实现了客户端调用服务端的对象方法像调用本地的对象方法。
二、RMI原理分析
既然是解决远程调用的问题,那么肯定要有client(客户端)和服务端(server),也就是方法的调用者和被调用者,从客户端-服务器模型来看,客户端程序之间调用服务端,两者之间是通过JRMP协议实现的。
这里简单了解一下JRMP协议,类似于HTTP协议,规定了客户端和服务端要满足的规范:
1 | JRMP(Java远程方法协议)可以定义为特定于Java的,基于流的协议,该协议查找并引用远程对象。它要求客户端和服务器都使用Java对象。它是线级协议,在RMI下和TCP / IP上运行。 |
下面通过流程图去进行RMI原理的分析:
RMI 客户端在调用远程方法时会先创建一个stub(sun.rmi.registry.RegistryImpl_Stub)也称为存根,Stub是RMI client的代理对象,Stub的主要功能是请求远程方法时构造一个信息块,然后通过RMI机制发送给客户端。
stub构造的信息块由几个部分组成:
1 | 1.远程对象标识符 |
Stub会Remote对象传递给客户端的远程引用层(java.rmi.server.RemoteRef)并创建远程调用对象(java.rmi.server.RemoteCall)
Remotecall会对RMI的服务名称和Remote进行序列化,然后通过Socket连接的方式传输到服务端的远程应用层
在上面我们看到client有一个stub构造信息块发送到服务端,那么在Skeleton就是在服务端接收这个信息的对象。
Skeleton在接收到client传递来的信息块后调用Remotecall反序列化RMI客户端传过来的序列化
然后Skeleton会处理客户端请求,调用相应服务端的对象进行调用,并将方法的返回值打包成响应消息并发送回客户端
1 | Skeleton 接收到客户端请求后,会调用远程对象方法并返回方法的执行结果。客户端不会直接访问远程对象,而是通过 Skeleton 间接访问远程对象。Skeleton 的作用是隐藏远程对象的实现细节,使客户端可以像调用本地对象一样调用远程对象。 |
三、RMI代码实现
1、RMI服务端注册服务代码
1 | package com.anbai.sec.rmi; |
前几行代码定义了RMI服务的ip,端口以及名字
1 | LocateRegistry.createRegistry(RMI_PORT); |
LocateRegistry.createRegistry(RMI_PORT)
是在 Java RMI 中创建 RMI 注册表的方法。它将在指定的 RMI 端口上启动 RMI 注册表,并返回一个对该注册表的远程引用。
在JAVA RMI中RMI注册表是一种服务,在 RMI 中,客户端必须知道远程对象的位置(主机名和端口号),才能与之通信。RMI 注册表提供了一种机制,使客户端可以通过名称查找远程对象,而不必知道其位置。
当你在 RMI 中启动一个远程对象时,你需要将其注册到 RMI 注册表中,以便客户端可以查找和访问它。这个注册代表着将远程对象绑定到一个名称上,这个名称可以被客户端用来查找远程对象。在 Java RMI 中,这个名称通常是一个字符串,被称为绑定名称 (binding name)。
当客户端需要访问远程对象时,它可以使用 RMI 注册表来查找该对象。客户端使用绑定名称向 RMI 注册表发出请求,RMI 注册表会返回绑定名称所对应的远程对象的引用。然后客户端可以使用该引用来调用远程对象的方法。
如果 RMI 注册表已经在指定的端口上运行,那么 createRegistry()
方法将不会创建新的注册表,而是返回对现有注册表的引用。如果你希望在另一个虚拟机上创建 RMI 注册表,可以使用 LocateRegistry.getRegistry(host, port)
方法来获取对远程 RMI 注册表的引用。
1 | Naming.bind(RMI_NAME, new RMITestImpl()); |
Naming.bind()
是 Java RMI 中用于将远程对象绑定到指定名称的方法。具体来说,它会将指定的远程对象绑定到一个指定的名称上,并将这个名称注册到 RMI 注册表中。这个名称可以用来在客户端中查找远程对象。
使用 Naming.bind()
方法绑定远程对象时,需要指定一个 URL,该 URL 包含了 RMI 注册表的主机名、端口号和绑定名称。
代码运行:
2、RMITestImpl()类的实现
在javaRMI中如果想将一个对象作为远程对象暴露给客户端使用,这个对象必须要满足以下要求:
1 | 1、实现一个远程接口(即扩展java.rmi,Remote接口) |
UnicastRemoteObject
是一个抽象类,它实现了 Remote
接口,并提供了一些默认的远程方法实现。当一个类继承了 UnicastRemoteObject
类后,它就可以直接暴露为远程对象,客户端可以通过 RMI 协议访问这个对象。
RMITestImpl()类代码:
1 | package com.anbai.sec.rmi; |
远程接口RMITestInterface代码:
1 | package com.anbai.sec.rmi; |
在RMITestImpl 这段代码中,定义了一个RMITestImpl类,并实现了一个RMITestInterface接口,这个类的作用是将test()方法暴露为远程方法,以便客户端可以通过RMI协议调用它。在这个类中我们重写了RMITestInterface
接口中的 test()
方法,该方法返回了一个字符串hello Rmi 。由于这个类继承了UnicastRemoteObject 因此它可以直接暴露为远程对象,客户端可以通过 RMI 协议访问它。
那么为什么要继承UnicastRemoteObject
类呢?
1 | 1、这是因为 RMI 通过序列化和反序列化对象来进行远程通信。当客户端调用远程对象的方法时,它实际上是在向远程对象发送序列化后的方法调用请求。而远程对象接收到请求后,需要将序列化后的数据反序列化成方法调用,并执行这个方法。如果远程对象没有实现 UnicastRemoteObject 类,那么 RMI 将无法序列化和传输这个对象,也就无法将它暴露为远程对象。 |
由上面我们可以知道
在 Java RMI 中,如果要将一个对象暴露为远程对象,这个对象必须实现一个远程接口。这个远程接口必须继承 Remote
接口,并且其中的所有方法都必须声明抛出 RemoteException
异常。这个远程接口定义了客户端可以通过 RMI 协议调用的方法。
在这个示例代码中,RMITestImpl
类实现了一个名为 RMITestInterface
的远程接口。这个接口中只有一个方法 test()
,它声明了抛出 RemoteException
异常。由于 RMITestImpl
类实现了 RMITestInterface
接口,因此它必须实现 test()
方法,并且在方法声明中也必须声明抛出 RemoteException
异常。
3、客户端代码实现
1 | package com.anbai.sec.rmi; |
- 在
RMIClientTest
类中定义了一个main
方法,用于启动客户端程序。 - 在
main
方法中,通过调用Naming.lookup
方法查找指定名称的远程对象,该名称在常量RMI_NAME
中定义。 - 通过将
Naming.lookup
方法的返回结果转换为RMITestInterface
类型,获取了远程接口对象的引用rt
。 - 通过调用
rt
对象的test
方法,执行了远程接口的方法调用。 - 将远程方法调用的返回值打印到控制台。
- 在代码中使用了
try-catch
语句来捕获可能发生的异常,比如在远程调用时可能发生的RemoteException
异常等。
代码运行:
四、源码分析
在前面我们对RMI的调用流程进行了大概分析,我们这里看一下RMI服务的具体实现
1、远程对象创建
我们这里看一下我们服务端的代码:
1 | public class Server { |
首先通过 rmi hello = new RemoteClass();
创建一个远程对象
这里远程对象就已经创建了,后面就是要分析如何将这个远程对象给发布出去
这个远程对象类是继承了UnicastRemoteObject
类。
UnicastRemoteObject
提供了导出远程对象的方法,它可以在 RMI 注册表中注册远程对象,并监听客户端的请求。当客户端请求调用远程对象的方法时,RMI 框架将通过网络将请求传递给远程对象。
这里再创建远程对象处打一个断点,跟进分析一下远程对象的创建过程:
远程对象类继承了UnicastRemoteobject类,首先会到父类的构造函数
UnicastRemoteobject类的构造方法接受一个port参数,也就是端口,我们这里没有传入port参数的值的话,这里port参数的值就默认为0,这里就是将远程对象发布到一个端口上面,这里没有定义端口,那么就会发布到一个随机的端口。这里发布时远程调用对象的端口,所以不是我们在服务端定义的注册中心的端口1099。
然后就会调用exportobject方法,也就是导出对象,这个方法就是我们发布远程对象的核心方法。
在这个方法里面调用exportObject方法,接受两个参数一个obj,也就是我们的远程对象,然后还接受一个new UnicastServerRef(port)
obj是我们远程调用的具体实现,那么这个new UnicastServerRef(port)
就是用来进行网络通信,处理网络请求的。
我们这里跟进这个处理网络请求类,分析一下处理的逻辑:
这里就是处理网络请求的核心,这里又实例化了一个LiveRef类,接受一个port参数
继续跟进这个实例化类:
调用了这个类的构造函数,传递了两个参数,一个id,一个端口,跟进看一下LiveRef类的构造函数分析处理逻辑。
到这里还是进行了一次封装,我们继续跟进构造函数
到这里才是这个类的构造函数,前面进行了两次封装。
这个类的构造函数接受三个参数,一个就是我们前面见到的id
第二参数是一个endpoint参数
我们可以回到上次封装,看一下这里传入的是什么参数
endpoint参数传入的值是一个TCPEndpoint.getLocalEndpoint(port)
,也就是调用了TCPEndpoint
的getLocalEndpoint
方法,跟进到这个类:
首先我们这里先看一下这个类的构造方法:
这个类构造方法接受主机地址,端口号这些。
我们大概可以了解到这是一个处理网络请求的类,也就是说,这个类才是网络请求的处理类,Live是通过调用这个方法来进行网络处理的。
csf
:RMIClientSocketFactory
实例,用于创建客户端套接字。ssf
:RMIServerSocketFactory
实例,用于创建服务器套接字。
这里就是基于给定的端口号和可能的套接字工厂实例创建一个本地 TCPEndpoint
对象。这样的本地端点通常在 RMI 服务端用于监听指定端口,以便远程客户端可以连接到该端口。
我们这里回到LiveRef的构造方法
可以看到这个ep传递的就是TCPEndpoint
TCPEndpoint.getLocalEndpoint(port)
获取到的值就是这个endpoint
根据变量调试信息我们可以看到这里存储了ip地址,端口号
分析到这里我们可以知道,网络请求的核心类是TCPEndpoint这个类,然后封装在LiveRef中进行网络请求的处理。
然后步出,回到这个export0bject类
上面我们分析了new UnicastServerRef(port)的处理逻辑。
我们这里跟进到export0bject方法中继续分析如何将远程对象发布出去。
这个sref就是我们前面传入的new UnicastServerRef(port)。
根据这个变量信息,可以知道其实还是封装的LiveRef。
继续跟进
1 | Class<?> implClass = impl.getClass(); |
这里会先获取到要导出的远程对象的类
1 | stub = Util.createProxy(implClass, getClientRef(), forceStubUse); |
然后通过Util.createProxy
方法创建一个stub对象,这个stub就是我们在前面分析RMI的远程对象在客户端的代理,用于处理客户端对远程对象的方法调用。
然后这里可以会有个疑问,stub不是客户端调用的吗,为什么是在服务端生成的。
根据这个图我们可以很清楚的理解,stub是由服务端生成,然后放到注册中心的,然后客户端根据要调用的远程对象,获取到这个stub,然后进行远程方法调用的处理。
跟进到createProxy方法分析stub对象具体时怎么创建的
首先还是获取到远程对象类
1 | if (forceStubUse || |
然后检查是否强制使用 Stub,如果强制使用 Stub,或者没有设置忽略 Stub 类,或者 Stub 类存在,就创建 Stub 对象。
继续向下分析
1 | final ClassLoader loader = implClass.getClassLoader(); |
如果不使用 Stub,就准备创建代理对象。获取类加载器、远程接口数组以及远程对象调用处理器。 RemoteObjectInvocationHandler 来为我们测试写的 RemoteObject 实现的 RemoteInterface 接口创建动态代理。
然后这里创建动态代理对象传入了加载器,远程接口,以及handler
根据变量调试信息可以看到这个handler封装的还是LiveRef类用来处理网络请求的。
前面创建了stub后,这里创建了一个Target对象,传入的参数都是我们前面创建的。
我们可以跟进去看一下
这里Target可以理解为一个总的封装,就是将前面创建的stub代理,远程方法对象之类的给封装起来。
然后这里有一个点,可以了解一下:
可以看到服务端引用对象和客户端代理对象的LIveRef和id都是一样的,这是因为客户端和服务端通信,都是用的这个LIveRef来进行处理的。
然后这里会调用export0bject将封装好的target对象给发布出去
跟进到这个exportObject方法:
调用listen()监听端口
跟进这个方法
首先会获取到远程对象的ip和端口,然后通过newServerSocket()创建一个套接字
然后后面就是创建了一个线程,当有连接执行这个线程。
然后就是在创建这个套接字对象的时候,就会给我们的远程对象分配一个随机的端口。
然后到这里就是已经给这个远程调用对象给发布到一个随机的端口上面了。
但是发布出去之后,客户端时不知道的,所以我们这里还有记录一下这个地址。
跟进
第一个方法就是简单的赋值,我们直接跟进到第二个方法
前面都是一些处理
继续往下面跟进
objTable和implTable都是定义的静态的Map表,用来存储我们发布出去的远程对象。
那么到这里远程对象的创建和发布就分析完了。
2、注册中心创建与绑定
前面我们分析了远程对象的创建与发布,这里我们分析一些,注册中心的创建,以及远程对象的绑定。
跟进这个创建注册中心的代码逻辑:
我们是调用createRegistry方法来创建这个注册中心
然后这里实例化了 RegistryImpl类,并传入了端口,这里的端口参数的值是我们设置的,默认就是1099
继续跟进:
首先就是进行一个安全检查
最终就是到达else里面
1 | LiveRef lref = new LiveRef(id, port); |
先是创建了一个LiveRef对象,传入了参数id和端口号。
这里和我们在分析远程对象创建的过程是一样的。
只不过这里端口不再是0,而是我们设置的1099
然后后面创建了UnicastServerRef对象,传入了我们创建的LiveRef对象
跟进后是调用了UnicastServerRef的exportObject方法
到这里我们可以看到这里的流程和我们前面分析远程对象服务的过程是一样的
我们这里回顾一下远程服务对象的调用
同样的还是调用UnicastServerRef的exportObject方法
但是这里有一个区别,由上图我们可以看到在远程对象创建的时候传入了三个参数,obj也就是我们的远程对象,null和false
然后我们看一下注册中心创建时传入的三个参数是:
这里可以看到传入的第三个参数是true
我们这里跟进去看一下这个参数是干什么的
传入的参数是parmanent
boolean permanent
参数表示导出的远程对象是否是永久性的。具体来说,当 permanent
参数设置为 true
时,表示导出的远程对象是永久性的,它不会因为长时间没有被使用而被自动取消导出。相反,如果设置为 false
,则表示导出的远程对象是暂时性的,它有可能因为一段时间没有被使用而被自动取消导出,以释放资源。
这就代表我们这里在注册中心创建的是一个永久对象,而在远程对象创建的是一个临时的。
然后后面还是一样的。创建stub
跟进
这个地方是和前面创建远程对象不一样的一个地方
我们这里跟进到stubClassExists这个方法
这里会检查这个stub类是否存在
然后再创建注册中心的时候对应的stub类是存在的
这个类是jdk自带的类,
这个方法主要是检查创建的stub是否以及存在了,存在了就返回true,否则就返回false
然后就会调用这个creatstub去生成这个stub
这里就是动态加载 stub 类,并使用反射机制实例化它
在 exportObject 方法中,重要的一步就是使用 Util.createProxy()
来创建动态代理,之前提到对远程对象使用 RemoteObjectInvocationHandler 来创建,但是之前有一个 stubClassExists 的判断。
这里就是和创建远程对象不同的地方,由于创建的stub类是存在的导致进入if里面调用createstub创建stub,但是再创建远程对象的时候是不存在的,所以再创建远程的对象的时候使用的是RemoteObjectInvocationHandler 来创建的。
上面创建完代理类后,会调用UnicastServerRef 类的setSkeleton 方法创建Skeleton,这个Skeleton就是服务端的代理对象。
这里也就是调用classforname创建了Skeleton,然后进行了实例化。
这里可以看到里面多了一个skel,那么这里其实就是impl里面放入了一个服务端的代理对象skel(Skeleton)
后面和在创建远程对象的时候都是一样的,将创建的这些远程服务都放到创建的target里面。后面就是将这个封装好的target给发布出去。具体和创建远程对象的时候是一样的,这里就不再详细分析了。
最终写入到objTable和implTable这两个表里面。
我们可以看到在这个表里面是存在我们创建的对象。
那么根据我们上面的分析我们大概可以知道创建远程对象和创建注册中心不一样的地方:
注册中心与远程服务对象注册的大部分流程相同,差异在:
远程服务对象使用动态代理,invoke 方法最终调用 UnicastRef 的 invoke 方法,注册中心使用 RegistryImpl_Stub,同时还创建了 RegistryImpl_Skel
远程对象默认随机端口,注册中心默认是 1099(当然也可以指定)
上面我们分析了创建注册中心的流程,然后我们这里开始分析一下服务绑定的流程:
这里调用注册中心的rebind方法进行远程服务的绑定,我们这里跟进看一下流程:
这里的bindings其实就是一个hash表
就是将 Remote 对象和名称 String 放在成员变量 bindings 中。
其实还有一个绑定方法是bind和rebind其实是一样的
只不过对于同一名称的绑定,bind
方法会抛出 AlreadyBoundException
异常,而 rebind
方法会覆盖已有的绑定而不抛出异常。
可以看到在bind中首先就是通过调用bindings的get方法在或者hash表对象中去查找是否已经存在该名称,存在的话就抛出异常
而rebind是直接覆盖,不抛出异常。
一般来说注册中心和服务端是要在一台机器上面的,但是在一些低版本jdk是允许将服务端和注册中心不放在一起唉,如果远程对象和注册中心不在一起这个时候就要先获取到Registry 对象。
无论是使用 Naming 或者 LocateRegistry 都是调用 LocateRegistry.getRegistry()
方法来创建 Registry,这部分的创建过程与注册中心注册时的过程是一致的
3、客户端请求注册中心-客户端
1 | Registry registry = LocateRegistry.getRegistry("192.168.137.1", 1099); |
这个就是客户端去进行远程方法调用的代码实现,首先就是获取到注册中心RMI注册表的引用,然后调用lookup方法从RMI注册表中查找名为 “hello” 的远程对象。lookup
方法返回对远程对象的引用,需要将其转换为适当的接口或类类型。获取到远程对象就可以调用相应的远程方法。
我们这里打断点具体分析一下调用流程:
跟进
我们可以看到这里获取远程对象并不是通过序列化反序列化的方式实现的,而是在本地新建了一个liveRef对象,然后作为参数传进了新建的RemoteRef对象。
最终是调用这个createProxy方法,这个方法我们在服务端创建stub时也用过,我们这里这个创建代理的方法:
分析到这里我们可以看到,虽然是客户端从注册中心获取到stub,但是注册中心并不是通过序列化/反序列化的方式直接将整个stub对象传给了客户端,而是客户端本地新建了一个包含了具体通信地址、端口的 RegistryImpl_Stub 对象。
这里和创建注册中心是一样的,调用createStub方法在本地实例化了一个RegistryImpl_Stub 对象。
然后这里就获取到了注册中心的远程对象,然后我们可以看到我们获取到这个远程对象里面其实也还是一个Liveref,包含了远程注册中心的地址和端口。我们这里其实也就是获取到了注册中心的一个stub对象,那么后面就是通过这个stub对象获取我们想要调用的远程方法的stub对象,我们在前面创建远程服务对象的时候,其实也就是创建了一个stub对象绑定到注册中心上面,而不是真正的远程对象实体。
首先就是通过StreamRemoteCall call = (StreamRemoteCall)ref.newCall(this, operations, 2, interfaceHash);
创建一个连接,也就是使用给定的远程引用 (ref
) 创建一个 StreamRemoteCall
对象,该对象表示一次远程调用。
然后就是将获取到一个输出流,这里也就是将我们传入的要调用的远程方法名进行序列化,那么注册中心相应会存在一个反序列化。这里先不分析注册中心的反序列化,我们继续向下分析:
对查找的方法进行序列化后,然后通过我们创建的连接调用invoke方法其实这里invoke是进行网络通信的
我们这里可以跟进去看看:
继续跟进:
到这里真正实现网络通信的就是这个executeCall方法。
上面我们将序列化的方法名传递给注册中心,然后会对响应结果进行反序列化。那么这里的这个result就是注册中心返回的动态代理对象。也就是我们要调用的远程方法的stub对象。
4、客户端请求服务端-客户端
我们这里获取到远程对象的stub代理对象,那么后面就是通过这个代理对象与服务端通信调用远程的对象方法了。
跟进去看一下调用流程:
这里的hello是我们获取的一个动态代理,动态代理无论你调用什么方法都会调用到invoke方法
这个方法首先就是通过if判断去检查代理的是否是一个可用的代理对象。
然后检查调用的方法是否是一个object类的方法,是的话则调用invokeObjectMethod(proxy, method, args);
进行处理,如果不是检查被调用的方法是否是 finalize
方法,并且不允许调用 finalize
。如果是,返回 null
表示忽略此调用。不是则调用invokeRemoteMethod(proxy, method, args);
进行处理
我们这里是调用了invokeRemoteMethod(proxy, method, args);
方法
跟进到这个方法我们可以看到是调用了ref的invoke方法。
继续跟进
这里通过ref.getChannel().newConnection();
新建了一个连接对象
这里使用循环遍历方法的参数类型数组,对每个参数进行序列化并写入输出流中。
marshalValue
是一个用于序列化给定类型的值到输出流的自定义方法。这是为了将方法参数转换为字节流,以便在远程调用中传递。我们跟进到这个方法里面可以看到,这个方法是根据类型的不同采用不同的序列化策略。而这个值就是我们调用方法时传入的参数的值。
对传入的参数进行序列化后,可与看到这里又调用了executeCall()
方法,分析到这里我们可以发现只要是客户端的请求都会调用这个方法进行网络通信。
这里从 StreamRemoteCall
对象 call
中获取输入流,用于读取从远程方法调用返回的数据。反序列化的实现是在这个unmarshalValue
这个自定义方法中实现的。
同样是根据值的类型实行不同的反序列化策略
根据调试信息是可以看到这里是通过反序列化读取到了返回信息。
5、客户端请求注册中心-注册中心
我们上面分析了客户端请求注册中心的时候在客户端部分的调用流程,我们这里分析当注册中心收到客户端的请求后,注册中心的调用流程:
我们前面在分析创建注册中心的流程时,创建注册中心的时候在服务端开启了一个监听。
跟进到这个listen方法·
在listen方法里面我们可以看到是开启了一个新线程,用于接受远程调用。然后对这个接受的远程调用就在这个新线程里面进行了处理,跟进到这个处理线程里面。
在这个线程的run方法里面写了一个处理的executeAcceptLoop()
方法
1 | private void executeAcceptLoop() { |
这个方法首先就是接受了客户端的请求,然后获取了客户端的ip地址
但是这里可以看到,接受请求后,又创建了一个线程new ConnectionHandler(socket, clientHost)
这里也就是接受到客户端请求后,将这个请求交给这个线程处理
跟进这个线程,继续分析这个线程的run方法,在这个方法里面首先就是设置了线程的名称,以及执行run0方法然后再run0方法被执行后还原这个线程的名称。继续跟进到这个run0方法。
1 | byte protocol = in.readByte(); |
run0前半部分是基于 Java RMI 的服务端处理逻辑,用于接受来自客户端的请求,并在接收到请求后进行一系列的处理,包括检测是否有 HTTP 封装、验证协议头等。我们主要看这后半部分,后半部分通过一个switch用于根据读取到的协议类型(protocol
)来执行不同的处理逻辑,主要涉及到 SingleOpProtocol、StreamProtocol 和 MultiplexProtocol 这三种协议。
根据前面客户端请求注册中心的分析,可以知道,客户端是通过序列化进行传输的,那么这里就会触发这个StreamProtocol 逻辑调用handleMessages()
进行处理。
再handleMessages()
会从输入流中读出操作码,然后根据操作码执行不同的逻辑。
这段代码主要写了三种类型的处理逻辑:RMI调用,ping请求和DGC 确认。我们这里是RMI调用这里就会调用TransportConstants.Call
:处理逻辑进行处理。
RMI的调用处理逻辑里面还是调用这个serviCall方法进行处理
在这个serviceCall方法里面,从 ObjectTable 中获取封装的 Target 对象
这个Target和OBjectable我们应该很熟悉了,我们在服务端创建远程服务或者注册中心的时候最终都会将封装的Target放入到这个objectTable里面。
这里从 ObjectTable 中获取封装的 Target 对象,并获取其中的封装的 UnicastServerRef 以及 RegistryImpl 对象。然后调用 UnicastServerRef 的 dispatch
方法。
然后继续跟进
然后会判断skel师是否为空我们这里根据变量调试信息可以看到这里的skel并不为空,用来区分注册中心和服务端
然后我们这里是不为空的就会调用这个oldDispatch方法
在oldDispatch方法中首先是通过 skel
对象的 getOperations
方法获取与该骨架对象关联的操作数组。然后可以看的调用unmarshalCustomCallData
方法对从输入流获取的数据进行反序列化,然后调用skel的dispatch方法,那么这里也就是RegistryImpl_Skel
的dispatch方法
RegistryImpl_Skel 的 dispatch
方法根据流中写入的不同的操作类型分发给不同的方法处理,例如 0 代表着 bind 方法,则从流中读取对应的内容,反序列化,然后调用 RegistryImpl 的 bind 方法进行绑定。
客户端请求注册中心调用的是lookup这里也就是:
1 | case 2: // lookup(String) |
$param_String_1 =SharedSecrets.getJavaObjectInputStreamReadString().readString(in);
这里就是对获取到的方法名序列化后的值进行反序列化,然后再调用lookup方法进行查询,然后将查询的结果进行序列化。这也就是说为什么客户端要对响应结果进行反序列化。
6、客户端请求服务端-服务端
客户端请求服务端服务端的工作流程的和注册中心的前半部分是一样的。
不同的是在调用unicastServerRef的dispatch方法处,再dispatch方法中会进行一个判断判断skel是否为空,也就是判断当前是注册端还是服务端调用,上面我们再分析注册端地调用流程的时候,这里的skel是存在的所有调用了olddispatch方法,但是当服务端调用的时候这里的skel是为空的,所以这里并不会调用oldDispatch方法,而是继续向下调用,这就是在调用注册端和服务端流程不同的地方:
这里首先就是获取到我们传入的参数也就是我们要调用的方法名
我们在客户端传入的数据是进行了序列化的,所以在服务端在处理的时候要进行反序列化
然后根据传入的类型调用不同的反序列逻辑
然后在这里调用invoke方法进行远程调用
根据参数调试信息我们可以看到调用结果就是hellorun…
然后后面就是对调用结果进行序列化
同样是根据类型调用不同的序列化策略,服务将远程调用结果通过序列化传给客户端,客户端收到后在进行反序列化就可以获取到远程调用的值,然后在进行后续处理。
五、RMI攻击
通过前面分析RMI注册端,客户端和服务端的调用流程,可以发现客户端在进行调用注册中心或者服务端的时候是通过序列化和反序列化进行数据传输的,那么我们这里可以对序列化或者反序列化的数据进行控制或者修改就能对相应的服务进行攻击。
1、攻击server端
(1)恶意服务参数
我们前面分析到当客户端请求注册中心获取到stub后,会调用这个stub与服务端进行请求,stub会将客户端传递的参数进行序列化然后传给服务端,服务端会对收到的客户端参数进行反序列化,然后进行调用,如果这个参数是 Object 类型的情况下,Client 端可以传给 Server 端任意的类,这个时候就会造成一个反序列化漏洞,攻击者可以构造恶意的攻击类传递过去,对服务端进行攻击和利用。
客户端对传递的参数进行序列化:
服务端对获取到的参数进行反序列化:
新建一个传递object类型的方法
然后修改客户端代码,调用saygoodbye,传递构造的恶意代码:
1 | package RMIfenxi; |
这里构造的恶意代码为CC6的利用代码
然后正常启动服务端
服务端启动后,启动客户端进行远程方法的调用
可以看到,当客户端进行远程调用后就会执行我们的命令,调用计算器程序,当服务端收到我们传递的恶意代码参数,就会对其进行反序列化,然后就会触发CC6利用链,然后执行弹出计算器的命令。
但是有一点要注意的是传入的参数不能是基本类型,
我们可以看到在其反序列化的逻辑中如果是string,int等基础类型就会进入其相应的处理逻辑中,那么我们就不能够反序列化利用成功了,当传入参数的类型不是基础类型的时候才会进入else子句中调用readObject中进行反序列化处理。
那这里如果我们传入的不是基础类型外的非Object类型,是否能够反序列化成功。
一般情况下通常客户端和服务端的调用的服务接口是一样的,那么如果我们修改服务端的接口类型为为其他类型,但是客户端仍然定义接受Object类型,是否还能触发反序列化漏洞呢?
答案是不能的,在调用的过程中会抛出异常
这是因为在服务端没有找到相应的调用方法,是在 UnicastServerRef 的 dispatch
方法中在 this.hashToMethod_Map
中通过 Method 的 hash 来查找的。这个 hash 实际上是一个基于方法签名的 SHA1 hash 值。这里找到的是服务端我们设置的类型的hash值,但是我们传递的是object类的数据,所以导致抛出异常,这里有几种解决方法,也就是绕过手法:
- 通过网络代理,在流量层修改数据
- 自定义 “java.rmi” 包的代码,自行实现
- 字节码修改
- 使用 debugger
详情可以参考下面几篇文章:
https://su18.org/post/rmi-attack/#1-%E6%81%B6%E6%84%8F%E6%9C%8D%E5%8A%A1%E5%8F%82%E6%95%B0
(2)远程加载对象
利用条件毕竟苛刻
可以参考下面文章:
https://paper.seebug.org/1091/#serverrmi-server
https://su18.org/post/rmi-attack/#2-%E5%8A%A8%E6%80%81%E7%B1%BB%E5%8A%A0%E8%BD%BD
2. 攻击 Registry 端
客户端请求注册中心客户端序列化:
客户端请求注册中心注册中心反序列化:
客户端向注册中心发起请求的时候是通过序列化进行数据传输的,我们可以看到客户端将传入的参数进行序列化,然后调用invoke方法传递给注册中心,所以后面的代码对攻击没有什么影响,但是,这里这个功能只接受一个字符串作为参数,那么skel在进行反序列化的时候也只会对字符串进行反序列化,这样的话我们的恶意类是不是就无法利用的。但是这里实际上客户端已经获取到了RegistryImpl_Stub了,也就是获取到了里面的ref,我们这里可以自己定义通信的对象,我们可以直接在本地写一个lookeup方法然后把恶意对象发给注册中心就能够实现反序列化利用了。
同样的这里服务端和客户端也是一样的道理,只不过服务端使用的是bind方法,那我们直接重写一个bind的方法,然后将恶意对象发给注册中心就能够实现利用:
1 | public class RegistryExploit { |
3、注册中心攻击客户端
通过前面的分析我们可以知道,注册中心也可以攻击客户端,客户端向注册发起请求后,注册中心同样是进行序列化然后传输给客户端,客户端会对相应的结果进行反序列化,也就是反序列化查询到的Stub对象,那么在注册中心绑定恶意对象。客户端在调用lookup方法进行反序列化的时候就会被攻击:
攻击代码:
1 | public class EvilRegistry { |
4、服务端攻击客户端
其实根据前面的分析我们就可以知道这个攻击都是相互的,客户端可以攻击服务端,服务端那么也可以反过来攻击客户端,因为客户端和服务端的传输是通过序列化和反序列化来实现,,当服务端处理客户端发过来的请求后,会通过序列化将处理的结果返回给客户端,客户端收到响应后会对其进行反序列化,那么这里服务端伪造一个恶意对象给客户端,客户端对其进行反序列化的时候就会受到攻击。
但是这里和客户端一样,这里如果返回值时Object就可以直接打,但是如果是其他类型就需要重新实现一个服务端。
这里不在详细分析了。
5、攻击DGC
6、JEP290
7、JEP290的绕过
- 标题: RMI反序列化
- 作者: GTL-JU
- 创建于: 2024-01-23 20:03:55
- 更新于: 2024-01-23 20:04:36
- 链接: https://gtl-ju.github.io/2024/01/23/RMI反序列化/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。