JAVA反序列化-RMI服务调用流程篇
前言
之前就学完RMI了,本来没打算写的,但是过几个月来看又有新的收获。重新记录一下吧。
- 本环境是8u65,因为在 8u121 之后,bind rebind unbind 这三个方法只能对 localhost 进行攻击。
RMI基础
RMI 全称 Remote Method Invocation(远程方法调用),一种用于实现远程过程调用的应用程序编程接口。它使客户机上运行的程序可以调用远程服务器上的对象。远程方法调用特性使Java编程人员能够在网络环境中分布操作。RMI全部的宗旨就是尽可能简化远程接口对象的使用。
JRMP(Java Remote Message Protocol,Java 远程消息交换协议),RMI 依赖的通信协议,该协议为 Java 定制,要求服务端与客户端都为 Java 编写。通俗点解释,它就是一个协议,一个在TCP/IP之上的线路层协议,一个RMI的过程,是用到JRMP这个协议去组织数据格式然后通过TCP进行传输,从而达到RMI,也就是远程方法调用。
RMI包括三个部分:
- Server:提供远程服务的程序,包含了实际的远程对象实现,服务器程序在启动时需要创建远程对象实例并使用Naming.rebind()方法将其与指定的名称绑定到RMI Registry,当接受到来自客户端的远程调用请求时,服务器会执行相应的操作并返回结果。
- Client:发起远程方法调用的程序,客户端通过调用Naming.lookup()方法使用字符串形式的对象名从RMI Registry获取远程对象的Stub,获得Stub后客户端就可以像调用本地对象一样调用远程对象的方法
- **Registry(注册中心)**:运行在服务器上的一个简单的名称服务,用于管理远程对象的注册和查找,RMI Registry通常在独立的进程中运行(默认端口为1099),服务器在启动时会注册其提供的远程对象使得客户端能够通过名称访问这些对象
其实很好理解,当客户端调用远程的方法的时候它并不知道服务端对象在哪个端口,所以引入了Registry,它有默认的端口(1099),
Server 端向 Registry 注册服务,Client 端从 Registry 获取远程对象的一些信息,如地址、端口等,然后进行远程调用。
备注:RMI采用代理来负责客户与远程对象之间通过Socket进行通信的细节,RMI框架为远程对象分别生成了客户端代理和服务器端代理,位于客户端的代理必被称为存根(Stub),位于服务器端的代理类被称为骨架(Skeleton)。
RMI简单实现
服务端
- 先定义一个远程接口,需要继承Remote(固定写法),同时有个sayHello()接口方法。此远程接口要求作用域为 public。
1 |
|
- 定义IRemoteObj的实现类。
1 |
|
现在可以被远程调用的对象被创建好了,接下来改如何调用呢?Java RMI 设计了一个 Registry 的思想(注意:通常情况下,Registry 和Server会部署在同一台机器上,但 RMI 也支持将它们分开部署。所以也提供了两种调用方法,将会在下面的代码体现),很好理解,我们可以使用注册表来查找一个远端对象的引用,更通俗的来讲,这个就是一个 RMI 电话本,我们想在某个人那里获取信息时(Remote Method Invocation),我们在电话本上(Registry)通过这个人的名称 (Name)来找到这个人的电话号码(Reference),并通过这个号码找到这个人(Remote Object)。
这种电话本的思想,由 java.rmi.registry.Registry
和 java.rmi.Naming
来实现。这里分别来说说这两个东西。
先来说说 java.rmi.Naming
,这是一个 final 类,提供了在远程对象注册表(Registry)中存储和获取远程对象引用的方法,这个类提供的每个方法都有一个 URL 格式的参数,格式如下: //host:port/name
:
- host 表示注册表所在的主机
- port 表示注册表接受调用的端口号,默认为 1099
- name 表示一个注册 Remote Object 的引用的名称,不能是注册表中的一些关键字
Naming 提供了查询(lookup)、绑定(bind)、重新绑定(rebind)、接触绑定(unbind)、list(列表)用来对注册表进行操作。也就是说,Naming 是一个用来对注册表进行操作的类。而这些方法的具体实现,其实是调用 LocateRegistry.getRegistry
方法获取了 Registry 接口的实现类,并调用其相关方法进行实现的。
那就说到了 java.rmi.registry.Registry
接口,这个接口在 RMI 下有两个实现类,分别是 RegistryImpl 以及 RegistryImpl_Stub。
- 我们通常使用
LocateRegistry.createRegistry()
方法来创建注册中心:
- 注册远程对象,启动了一个1099端口的Registry注册服务,并把IRemoteObj接口的实现RemoteObjimpl暴露和注册到Registry注册服务。
1 |
|
客户端
- 具有同样的接口(但不需要实现)。
1 |
|
- 连接到注册中心,获取远程对象,调用远程方法。
1 |
|
启动服务端再启动客户端,我们发现服务端和客户端都将hello大写。
这其中的过程是:
当服务端启动时,启动 RMI 注册中心,将远程对象RemoteObjImpl
注册到该注册中心,并将其 stub 暴露。stub 包含了远程对象的 IP 和端口信息。客户端通过连接 RMI 注册中心,查询并获得该远程对象的 stub。客户端通过这个 stub 发起远程方法调用。
客户端通过 lookup
方法获取到 stub 后,实际上获得了指向远程对象的代理。该代理包含了远程对象的 IP 地址和端口信息,所以客户端可以通过 stub 发起远程方法调用。调用过程中会先经过Skeleton,随后方法参数通过 序列化 转换为字节流并通过 JRMP 协议 发送到服务端。服务端接收到字符流后反序列化,执行方法并将结果HELLO的序列化字节流通过 JRMP 协议 返回,客户端再通过 反序列化 获得返回的数据并输出结果。
(这里借用su18师傅的图来说明整个过程)
服务注册
创建远程对象
我们通过RemoteObjimpl remoteObj = new RemoteObjimpl();
创建了一个远程对象,这个对象继承了 UnicastRemoteObject,这个类用于使用 JRMP 协议 export 远程对象,并获取与远程对象进行通信的 Stub。我们可以看一下具体的流程。
在初始化时,调用其 exportObject
方法来 发布RemoteObjimpl这个远程对象。我们来看这个静态函数。第一个参数是 obj 对象,也就是我们实例化的对象,第二个参数是 new UnicastServerRef(port)
,它主要封装了服务端与客户端通信的细节。我们跟进 exportObject
方法。
继续跟进我们发现服务端创建远程服务这一步居然出现了 Stub 的创建,其实原理是这个样子的,RMI 先在服务端创建一个 Stub,再把 Stub 传到 RMI Registry 中,最后让 RMI Client 去获取 Stub。可以看一下Stub是怎么产生的。
这其中使用 sun.rmi.server.Util#createProxy()
一看就知道是创建代理,这个方法使用 RemoteObjectInvocationHandler 来为 RemoteObjectlmpl 实现的 IRemoteObj接口创建动态代理。
然后创建 sun.rmi.transport.Target
对象,可以看到Target 对象封装了我们远程执行方法和生成的动态代理类(Stub)。
并调用 LiveRef#exportObject
接着调用 sun.rmi.transport.tcp.TCPEndpoint#exportObject
监听本地端口。
然后调用 sun.rmi.transport.tcp.TCPTransport#exportObject
方法将 Target 实例注册到 ObjectTable 中。ObjectTable 用来管理所有发布的服务实例 Target,ObjectTable 提供了根据 ObjectEndpoint 和 Remote 实例两种方式查找 Target 的方法(不同参数的 getTarget 方法)。
创建注册中心
LocateRegistry.createRegistry(1099)
,创建了注册中心,跟进。
可以看到 createRegistry
方法实际 new 了一个 RegistryImpl 对象。
RegistryImpl 的构造方法中创建 LiveRef 对象,然后创建 UnicastServerRef 对象,最后调用 setup
进行配置。
在 setup
方法中,使用 UnicastServerRef 的 exportObject
方法发布对象,和创建远程对象的时候是一样的,但是这次发布的是 RegistryImpl 这个对象。
在 exportObject
方法中,重要的一步就是使用 Util.createProxy()
来创建动态代理,之前提到对远程对象使用 RemoteObjectInvocationHandler 来创建,但是之前有一个 stubClassExists 的判断,创建远程对象的时候直接跳过去了,这里是可以进入判断。
大概意思就是找有没有_Stub
结尾的类
1 |
|
如果需要创建代理的类在本地有 _Stub
的类,则直接使用 createStub
方法反射调用 stub 类的构造方法创建类实例。
为什么会是 RegistryImpl_Stub 这个文件呢?因为由于是 RegistryImpl 这个类,所以系统会找到 RegistryImpl_Stub 这个类并进行实例化,RegistryImpl_Stub 继承了 RemoteStub ,实现了 Registry。这个类实现了 bind/list/lookup/rebind/unbind
等 Registry 定义的方法,全部是通过序列化和反序列化来实现的。
创建完代理类之后,调用 setSkeleton 方法调用 Util.createSkeleton()
方法创建 skeleton。
其实就是反射实例化 RegistryImpl_Skel 这个类并引用在 UnicastServerRef 的 private transient Skeleton skel;
中。
然后又调用 sun.rmi.transport.tcp.TCPTransport#exportObject
方法将 Target 实例注册到 ObjectTable 中。
最后发布的时候我们有三个对象,我们自己建的RemoteObjlmpl(是个代理)、RegistryImpl_Stub、DGClmpl_Stub(DGC—— 分布式垃圾回收,我们现在只用知道它是自动创建的就行)
随后将整个target发布出去
总结一下就是开了几个远程服务,远程服务对象使用动态代理,注册中心使用 RegistryImpl_Stub,同时还创建了 RegistryImpl_Skel。注册中心端口是固定了,另外两个端口是不固定的,随机产生的。
服务注册
其实就是 bind 的过程,直接调用sun.rmi.registry.RegistryImpl#bind
进行绑定,就是将 Remote 对象和名称 String 放在成员变量 bindings 中,bindings是一个Hashtable 对象,接着bindings.put
。
服务调用
使用远程方法调用时会涉及参数的传递和执行结果的返回,参数或者返回值可以是基本数据类型也可以是对象的引用,所以这些需要被传输的对象必须可以被序列化,这就要求相应的类必须实现java.io.Serializable接口并且客户端的serialVersionUID字段要与服务器端保持一致。
JVM之间通信时RMI,它并没有直接把远程对象复制一份传递给客户端,而是传递了一个远程对象的Stub,Stub基本上相当于是远程对象的引用或者代理,Stub对开发者是透明的,客户端可以像调用本地方法一样直接通过它来调用远程方法,Stub中包含了远程对象的定位信息,例如:Socket端口、服务端主机地址等,同时也实现了远程调用过程中具体的底层网络通信细节,所以RMI远程调用逻辑是这样的。
客户端请求注册中心-客户端
这里其实是通过调用本地创建的 RegistryImpl_Stub 对象,跟创建注册中心的流程一样的,随后调用lookup
方法。
在调用其lookup
方法时,会向注册中心传递序列化的 name (所以注册中心也一定有一个反序列化的点),然后将 Registry 端回传的结果反序列化。
另外还需要注意的一个点是invoke
方法
它实际调用 的是sun.rmi.server.UnicastRef#invoke(java.rmi.server.RemoteCall)
,这个方法中又调用了sun.rmi.transport.StreamRemoteCall#executeCall。如果你是2号异常,他会通过反序列化来获取流里的对象(也就是说如果注册中心返回一个恶意的流,那客户端也会被攻击)这个点更隐蔽,结论就是,RegistryImpl_Stub的方法中调用了invoke
方法,都有可能会被攻击
客户端请求注册中心-注册中心
注册中心端会走到RegistryImpl_Skel 的 dispatch
方法,lookup
方法对应的值是 2 ,调用 RegistryImpl 的 lookup
方法,然后将查询到的结果 writeObject 到流中
至于为什么找到dispatch
方法过程比较繁琐,推荐大家看这一期视频Java反序列化RMI专题-没有人比我更懂RMI
case2也就是我们客户端调用的lookup方法,这里对应的也就是我们传入的name字段,这里是通过反序列化读出来的
这里面只要有readObject方法都能被攻击 bind、rebind、unbind
所以客户端攻击注册中心的方法还是很多的。
客户端请求服务端-客户端
直接跟进sayHello方法
因为我们现在的对象是一个动态代理,所以会走到调用处理器的invoke方法 ,然后会调用java.rmi.server.RemoteObjectInvocationHandler#invokeRemoteMethod
方法
invokeRemoteMethod
我们发现调用了重载的invoke方法也就是到了sun.rmi.server.UnicastRef#invoke(java.rmi.Remote, java.lang.reflect.Method, java.lang.Object[], long)。
最终逻辑会走到marchalValue,反正它最终也就是序列化一个值,也就是我们传入的hello,最后又调用了 java.rmi.server.RemoteCall#executeCall
,这里面也有反序列化的点,客户端请求注册中心-客户端那边已经提到了。
1 |
|
接着会调用sun.rmi.server.UnicastRef#unmarshalValue
,也就是说结果是反序列化出来的
这里啰嗦一下。
为什么客户端能直接和服务端通信呢?着重看一下RemoteObjectInvocationHandler 这个动态代理,继承 RemoteObject 实现 InvocationHandler,因此这是一个可序列化的、可使用 RMI 远程传输的动态代理类。既然是动态代理类,自然重点关注 invoke
方法,可以看到如果是 Object 的方法会调用 invokeObjectMethod
方法,其他的则调用 invokeRemoteMethod
方法。
而在 invokeRemoteMethod
中实际是委托 RemoteRef 的子类 UnicastRef 的 invoke
方法执行调用。
UnicastRef 的 invoke
方法是一个建立连接,执行调用,并读取结果并反序列化的过程。这里,UnicastRef 包含属性 LiveRef
,LiveRef 类中的 Endpoint、Channel 封装了与网络通信相关的方法。
反序列化方法在 unmarshalValue
中。
也就是说,客户端拿到服务端返回的动态代理对象并且反序列化后,对其进行调用,这看起来是本地进行调用,但实际上是动态代理的 RemoteObjectInvocationHandler 委托 RemoteRef 的 invoke 方法进行远程通信,由于这个动态代理类中保存了真正 Server 端对此项服务监听的端口,因此客户端端直接与服务端端进行通信。
客户端请求服务端-服务端
服务端由 UnicastServerRef 的 dispatch
方法来处理客户端的请求,会在 hashToMethod_Map.get(op)
中寻找 Client 端对应执行 Method 的 hash 值,如果找到了,则会反序列化 Client 端传来的参数,并且通过反射调用。
会把运行的结果,序列化之后传给客户端
DGC
在创建注册中心那一部分我们看到了一个对象DGClmpl_Stub,它是通过objTable.put(oe,target)
put进去的,那肯定是在put之前就创建好了这个对象,接下来我们来看看它的创建流程。
我们找到了这里sun.rmi.transport.ObjectTable#putTarget
调用了 sun.rmi.transport.DGCImpl#dgcLog
,dgcLog是一个静态变量
在调用一个类的静态变量的时候,实际上是会完成类的初始化的,初始化的时候首先会走到静态代码块,也就是走到java.security.PrivilegedAction#run
方法里面。
调用了sun.rmi.server.Util#createStub
,它和创建注册中心创建代理的逻辑是一摸一样的,大概意思就是找有没有_Stub
结尾的类
1 |
|
所以最后会实例化一个DGClmpl_Stub,它的和注册中心一样,有一个ip和端口,端口不固定,作用是远程回收服务,注册中心是用来注册服务。这就创建完成了。
对于客户端的话有两个方法ditry()
和dispatch()
,我们可以看到DGC客户端也是有反序列化的点。
对于服务端最后的调用流程和客户端请求注册中心-注册中心一样的,走到dispatch
方法,一样有反序列化的点。
总结
把以上的流程,引用su18师傅的图片来表述一下(su18师傅的图画的太好了)