Java反序列化-RMI流程分析
概述
官方文档:https://docs.oracle.com/javase/tutorial/rmi/overview.html
RMI应用程序通常由两个独立的程序组成,一个服务器和一个客户端。服务端通过绑定这个远程对象类,它可以封装网络操作。客户端层面上只需要传递一个名字,还有地址。RMI提供了服务器和客户端通信和来回传递信息的机制。这种应用程序有时称为分布式对象应用程序。
RMI服务端:负责“暴露远程对象+处理调用请求”
服务端的核心目标是将可被远程调用的对象注册到注册表中,并监听客户端的调用请求,最终执行方法并返回结果。
RMI客户端:负责“查找远程对象+发起远程调用”
核心目的是找到服务端注册表中的远程对象,获取其本地代理(stub),并通过代理调用远程方法。
代码演示
需要两个主程序,分别是:客户端和服务端
服务端需要实现类和接口
客户端只需要接口就好了
公共接口类
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.RemoteException;
import java.rmi.server.UnicastRemoteObject;
//继承远程对象 UnicastRemoteObject
public class RemoteObjlmpl extends UnicastRemoteObject implements IRemoteObj {
protected RemoteObjlmpl() throws RemoteException {
}
@Override//转大写的功能
public String sayHello(String keywords) throws RemoteException {
String upperCase = keywords.toUpperCase();
System.out.println(upperCase);
return upperCase;
}
}
RMIServer服务端主程序类
package org.example;
import java.rmi.AlreadyBoundException;
import java.rmi.NotBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.UnicastRemoteObject;
public class RMIServer {
public static void main(String[] args) throws RemoteException, AlreadyBoundException{
RemoteObjlmpl remoteObjlmpl = new RemoteObjlmpl(); //new一个实现类
Registry registry = LocateRegistry.createRegistry(1099);//创建一个注册中心,它的默认端口为1099
registry.bind("remoteObj", remoteObjlmpl);//绑定这个实现类的名字为remoteObj
System.out.println("Server ready");
}
}
客户端
客户端主程序类
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");//去查找注册中心的这个名字
remoteObj.sayHello("hello"); //查到了之后,这个接口类型直接调用接口实现类的方法
}
}
运行
首先在RMIServer主程序运行,可以看到程序开始监听等待连接
然后这个时候运行RMIClient主程序
这个时候看见服务端,成功调用了实现类的方法
RMI流程总览
服务端
注册中心
客户端
然后漏洞是发生在两两通信之间的。
RMI流程
从wireshark抓包分析RMI通信原理
下载Npcap:https://npcap.com/#download,在安装时选择Support loopback traffic
再打开wireshark时就会看到一个新接口Npcap Loopback Adapter
选择该接口开始抓包就可以抓取127.0.0.1的流量了。
在客户端远程调Java程序的过程中其实建立了两次TCP连接,第一次连接是连接1099端口;第二次连接是由服务端发送给客户端的。
第一次连接:客户端来连接注册中心(Registry)在其中寻找调用函数的名称,这个对应数据流中的Call消息,然后注册中心返回一个序列化的数据,这个就是找到的Name=调用函数的名称的对象,这个对应数据流中的ReturnData消息
call消息
ReturnData消息
AC ED 00 05
是常见的Java反序列化16进制特征
第二次连接:服务端发送给客户端的Call消息客户端反序列化对象,发现该对象是一个远程对象,地址在ip:port,于是再与这个地址建立TCP连接;在这个新的连接中,才执行真正的远程调用,也就是
sayHello()
总的来说,RMI Registry就像一个网关,他自己是不会执行远程方法的,但RMI Server可以在上面注册一个Name到对象的绑定关系;RMI Client通过Name向RMI Registry查询,得到这个绑定关系,然后再连接RMI Server。最后,远程方法实际上在RMI Server上调用。
断点调试理解
创建远程服务
在这里进行断点调试
发布远程对象
这一行代码的主要功能是在本地实例化远程对象并把它发布到网络上
RemoteObjlmpl remoteObjlmpl = new RemoteObjlmpl(); //new一个实现类
一步步调试查看它的内在逻辑
来到这里,远程对象实现类的构造方法
按F7,来到其父类的构造函数
这里的父类构造函数的port传入了一个0,它代表一个随机端口。
这里的 exportObject(Remote obj, int port)
是一个静态函数,它主要是负责将远程服务发布到网络上,它第一个参数this代表我们刚刚创建的RemoteObjlmpl
实例,第二个参数是new UnicastServerRef(port)
。
UnicastServerRef
是RMI服务端的核心引用实现。它负责管理网络端点、监听端口、以及处理远程方法调用。可以把它看作是服务对象和网络之间的“桥梁”或“适配器”。
继续跟进此函数,去到UnicastServerRef的构造函数。
发现这里new 了一个 LiveRef(port),它是一个网络引用的类
LiveRef
:直译就是“活动引用”,它是一个非常底层的类,直接代表了一个远程对象在网络上的具体位置。它内部封装了两个关键信息
对象标识符:一个唯一的long类型数字,用来在一台主机上区分不同的远程对象
端点:代表网络地址和传输协议
查看跳进this后的构造函数,第一个参数为ID,第三个参数为true,所以我们重点关注第二个函数,也就是TCPEndpoint.getLocalEndpoint(var2)
TCPEndpoint
:它是RMI中TCP/IP传输协议的具体实现。它封装了IP地址和端口号。当传入的port为0时,TCPEndpoint会记录下这个“动态端口”的意图。它内部会处理获取主机 的IP地址(getLocalHost
)
TCPEndpoint是一个网络请求的类,看一下它的构造函数,传参进去一个ip,一个端口,可以进行网络请求
继续跟进this
这里看到所有信息都存到了LiveRef
里面。
再回到super(new LiveRef(port));
的地方
进入super看一下它的父类方法
查看它的父类构造方法,这里只是进行了一个赋值,而不是建立了一个新的。
一路f7到这里,这一部分代码是 UnicastServerRef 类的核心代码吗,完成了存根(stub)的创建,目标对象(Target)的封装以及最终的网络绑定。
public Remote exportObject(Remote impl, Object data, boolean permanent) throws RemoteException {
// 1. 获取实现类的 Class 对象
Class<?> implClass = impl.getClass();
Remote stub;
// 2. 创建存根(Stub)
try {
stub = Util.createProxy(implClass, getClientRef(), forceStubUse);
} catch (IllegalArgumentException e) {
throw new ExportException(
"remote object implements illegal remote interface", e);
}
// 3. 如果是旧的 Stub 机制,则设置骨架(Skeleton)
if (stub instanceof RemoteStub) {
setSkeleton(impl);
}
// 4. 创建 Target 对象,封装所有必要信息
Target target = new Target(impl, this, stub, ref.getObjID(), permanent);
// 5. 真正将服务绑定到网络上
ref.exportObject(target);
// 6. 缓存方法哈希映射,用于快速查找
hashToMethod_Map = hashToMethod_Maps.get(implClass);
// 7. 返回创建的存根
return stub;
}
发现在这一步创建了stub
RMI先在服务端创建一个Stub,再把Stub传到注册中心,最后让客户端获取Stub
进入createProxy方法
这里的stubClassExits
判断,如果有_Stub结尾的话,结果会返回为真。
f8来到这里,这是一个创建动态代理的过程。
可以看到这里有一个创建动态代理的过程,第一个参数是AppClassLoader,第二个参数是一个远程接口,第三个参数是调用处理器。
我们看一下调用处理器的创建过程
进入super方法之后看到这个
继续f8
如此如此,动态代理类就创建好了
我们再看Target类,这里的Target类相当于一个总的封装,将所有有用的东西都封装给了Target类
注意到这里的var2和var3也就是disp和stub,一个服务端,一个客户端,他们ref的id都是一样的,都是824。
一路f8,回到之前的Target,
这一语句的作用就是把target这个封装好的对象发送出去,真正把服务绑定到网络上。
看一下它的发送逻辑,一路f7到这里
从这里开始,第一句listen,真正处理网络请求了,跟进去
先获取TCPEndpoint,然后我们继续f8往后看,直到 this.server = var1.newServerSocket();
,这是listen()方法的核心操作。
var1.newServerSocket()
的内部:这个方法会调用new ServerSocket(port, backlog, bindAddr)
,在指定的 IP 地址和端口上创建一个套接字,使其进入 “监听” 状态。如果端口被占用,会抛出BindException
。
它创建了一个新的进程,然后等待客户端连接。
继续按F8出去代码逻辑来到这里,可以看见一开始 liveRef的默认端口是0,实际上这里已经随机分配了一个端口了
这是因为在 this.server = var1.newServerSocket();语句的时候,就调用了下图的代码,可以看见如果端口为0,它会随机给它一个值,然后返回服务端。
服务端记录发布
f8来到这里,然后按f7,进入expoortObject方法
这里调用了putTarget方法
f7进入此方法,可以看见这两个方法
这两个方法会把信息保存到这两个table
最后
// 6. 缓存方法哈希映射,用于快速查找
hashToMethod_Map = hashToMethod_Maps.get(implClass);
小总结
总结:执行 RemoteObjlmpl remoteObjlmpl = new RemoteObjlmpl();
时,触发了一个复杂的、自动化的网络服务启动流程。这个流程可以概括为:
触发导出:
UnicastRemoteObject
的构造函数调用exportObject()
。封装网络地址:
UnicastServerRef
创建一个LiveRef
。LiveRef
创建一个TCPEndpoint
,用它来记录服务器的 IP 地址和要监听的端口(此时为动态端口)。
启动网络服务:
TCPTransport 根据 TCPEndpoint 打开一个 ServerSocket,并绑定到一个由操作系统分配的具体端口上。
一个后台 Acceptor 线程被启动,开始监听这个 ServerSocket 上的客户端连接。
建立映射关系:
RMI 内部维护一个表格,将接收到的网络请求(包含对象 ID)与我们的 remoteObjlmpl 实例关联起来。
最关键的还是UnicastServerRef
类的此核心代码
public Remote exportObject(Remote impl, Object data, boolean permanent) throws RemoteException {
// 1. 获取实现类的 Class 对象
Class<?> implClass = impl.getClass();
Remote stub;
// 2. 创建存根(Stub)
try {
stub = Util.createProxy(implClass, getClientRef(), forceStubUse);
} catch (IllegalArgumentException e) {
throw new ExportException(
"remote object implements illegal remote interface", e);
}
// 3. 如果是旧的 Stub 机制,则设置骨架(Skeleton)
if (stub instanceof RemoteStub) {
setSkeleton(impl);
}
// 4. 创建 Target 对象,封装所有必要信息
Target target = new Target(impl, this, stub, ref.getObjID(), permanent);
// 5. 真正将服务绑定到网络上
ref.exportObject(target);
// 6. 缓存方法哈希映射,用于快速查找
hashToMethod_Map = hashToMethod_Maps.get(implClass);
// 7. 返回创建的存根
return stub;
}
服务端创建注册中心
创建RegistryImpl
对象,可以看到创建注册中心的默认端口为1099
来到注册中心的实现类
f7进去setup方法
在 exportObject的方法可以看见参数 permanent的意思为永久,意思是我们创建注册中心这个对象为永久对象
exportObject又进入了熟悉的这个UnicastServerRef
方法
进入createProxy方法
进入createStub方法
可以看见,类名的名字改变了,return 返回了加载的初始化 ref
返回UnicastServerRef,进入setSkeleton
跟踪方法
来到这里,可以发现static中的数据的 objTarget的第二个Target对象的Value的值有一个 DGCImpl_Stub。它是分布式垃圾回收的一个对象,并不是我们创建的,而且这里有三个Target后面会说到。
小总结
这部分代码创建了一个注册中心,它在1099端口监听,等待其他RMI服务(如我们的 RemoteObjlmpl
)来注册地址,也等待客户端来查询这些地址。
服务端远程对象绑定创建的注册中心
来到这里
public void bind(String var1, Remote var2) throws RemoteException, AlreadyBoundException, AccessException {
// 1. 安全访问检查
checkAccess("Registry.bind");
// 2. 同步代码块,保证线程安全
synchronized(this.bindings) {
// 3. 检查名称是否已被绑定
Remote var4 = (Remote)this.bindings.get(var1);
if (var4 != null) {
// 4. 如果已绑定,则抛出异常
throw new AlreadyBoundException(var1);
} else {
// 5. 如果未绑定,则将名称和对象存入映射表
this.bindings.put(var1, var2);
}
}
}
实际上这个bindings就是Hastable表
注册中心接受并处理服务端的绑定请求
在服务端主程序中进行打断点debug调试。
注册中心通过TCPTransport#handleMessages处理相关的网络请求
是注册中心的代理,所以走到这个方法里
至此,注册中心接受并处理服务端的绑定请求。
客户端获取注册中心代理对象
在客户端打断点调试
来到
进入createProxy,这里返回创建好的Stub对象。
至此客户端获取注册中心代理对象就到这里了。
客户端通过注册中心查找远程对象
来到executeCall方法
此方法主要是处理网络请求,这个方法中也使用了反序列化方法,也就是说调用invoke,都有可能执行反序列化。
注册中心收到查询请求并返回远程对象代理
这里需要服务端与客户端之间的交互,在服务端主程序进行DEBUG操作,然后断点如图。
调试来到这里Transport#disp.disppath
这里的skel只有注册中心才有,当判断是注册中心就会调用oldDispath方法,显然这里满足条件
进行追踪调试,调用 skel.dispatch 方法
总的来说是 RegistryImpl_Skel类调用了dispath方法,然后lookup方法中有一个反序列化的点,这里是存在漏洞的
最后服务端本地调用 RegiistryImpl.lookup(name),获取返回的远程对象,最后远程对象序列化,然后还给客户端,让它进行反序列化读取
至此,注册中心收到查询请求并返回远程对象代理,就到这了。
客户端调用远程对象的方法并返回结果
因为客户端获取的是远程对象代理stub,也就是说它调用任意方法都会走到invoke里
进入重载的invoke方法
这里有一个marshalValue方法
这个方法里进行了反序列化
实际上call.exeuteCall()方法我们知道执行这个方法是存在漏洞的,客户端如果遇到了恶意的注册中心
跟进 unmarshalValue 方法,可以看见最后进行了反序列化的操作
可以看到之后返回了一个HELLO的值,成功反序列化的值
到这一行,返回调用方法执行的结果,至此客户端调用对象方法结束
服务端接受调用函数请求并返回执行结果
服务端在这里打断点,然后开始debug调试
这个时候在客户端运行主程序
然后f8来到这里
按f9直到skel为null的时候f7调试
继续往下走
主要有以下三个关键点
第一个关键点
先看第一个 unmarshalValue 方法,最后反序列化客户端序列化的内容
因为要反序列化数据的类型是String,所以它绕过了前面的判断
可以看到反序列化参数成功了
第二个关键点
再看第二个关键点,当服务端进行反射调用后,可以看到方法执行成功并且返回了值
第三个关键点
跟进到marshalValue方法,可以看见它是进行反序列化返回值的操作
至此可以看见客户端进程直接运行完毕了,因为它收到了来自服务端发送的返回值
服务端完成接受客户端的调用、执行本地函数、返回执行结果的过程就是这样
客户端请求服务端-dgc
DGC代理的产生
在这里下断点调试
这里发现stub是dgc代理
来到DGClmpl的实现类进行断点调试
至此DGC_Stub的创建就完成了,DGC是一个自动创建的过程,用于清理内存。
DGC是实现类Stub
DGClmpl_Stub的类下有两个方法,一个是clean(强清除),一个是dirty(弱清除)
在clean方法中存在反序列化的漏洞点
DGC实现类Skel
在DGClmpl_Skel类的dispash方法,存在反序列化漏洞的入口:
总结
漏洞点在客户端与服务端都存在,因为Skel代理是服务端,Stub代理是客户端。所以这就是JRMP所谓的绕过。
参考链接
https://jaspersec.top/2023/12/24/0x0A%20RMI%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90/#%E5%AE%A2%E6%88%B7%E7%AB%AF%E6%94%BB%E5%87%BB%E6%9C%8D%E5%8A%A1%E7%AB%AF
https://www.bilibili.com/video/BV1L3411a7ax/?p=8&spm_id_from=pageDriver&vd_source=9f847c5239350d8425b1d2242ef00bbf
https://drun1baby.github.io/2022/07/19/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E4%B9%8BRMI%E4%B8%93%E9%A2%9801-RMI%E5%9F%BA%E7%A1%80/
https://blog.csdn.net/weixin_53912233/article/details/139422625?fromshare=blogdetail&sharetype=blogdetail&sharerId=139422625&sharerefer=PC&sharesource=2301_80951345&sharefrom=from_link
这部分感觉好乱好杂,有点给我学死了。