1. 项目概述:从一次“诡异”的远程调用说起
几年前,我在排查一个线上服务异常时,遇到了一个非常典型的问题。一个基于Java RMI(Remote Method Invocation)的分布式服务,在某个客户端节点上间歇性地抛出
ClassNotFoundException
,但服务端和其他客户端都运行正常。日志里没有堆栈溢出,也没有明显的OOM,就是找不到类。当时的第一反应是检查类路径,确认jar包版本,一通操作下来,问题依旧。后来,在深入追踪网络包和线程栈后,才把目光锁定在了RMI的通信机制,特别是其数据传输的核心——序列化与反序列化过程上。这次经历让我深刻意识到,不理解RMI底层的序列化流程,就像医生看病不看病理报告,只能凭经验瞎猜,遇到复杂问题根本无从下手。
Java反序列化-RMI流程分析 ,这个标题听起来很技术,甚至有些枯燥,但它直指分布式Java应用中的一个核心且脆弱的安全环节。简单来说,RMI是Java原生支持的、用于实现远程方法调用的框架,它让一个JVM中的对象可以调用另一个JVM中对象的方法,感觉就像调用本地方法一样。而这个“感觉像本地”的魔法,主要就靠序列化(把对象变成字节流)和反序列化(把字节流变回对象)来实现。对于开发者,尤其是涉及分布式系统、中间件开发或安全研究的同学,理清这个流程至关重要。它不仅能帮你高效地调试“类找不到”、“数据不一致”等疑难杂症,更是理解近年来层出不穷的Java反序列化漏洞(如Shiro、FastJson、WebLogic等漏洞)的基石。很多高危漏洞的利用链,起点往往就是一段不受控的反序列化数据流经了RMI这类协议。
本文将从一个实践者的角度,带你深入RMI通信的腹地,拆解其从方法调用到网络传输,再到对象重建的完整流程。我们会避开教科书式的概念罗列,聚焦于**“数据到底是怎么流动的”**,并重点分析反序列化这一关键环节的潜在风险点和调试技巧。无论你是想夯实Java基础、应对深度面试,还是从事安全研究,相信这篇结合了实战踩坑经验的流程分析,都能给你带来直接的帮助。
2. RMI通信核心流程与序列化角色定位
要分析反序列化,必须先理解它在整个RMI调用中扮演的角色。我们可以把一次完整的RMI调用想象成一次跨国快递。
2.1 RMI调用的宏观三阶段
一次标准的RMI调用,主要涉及三个角色:客户端(Client)、存根(Stub)、骨干(Skeleton)和服务端(Server)。这里特别说明一下,在JDK 1.2之后,传统的静态Skeleton已被动态代理机制取代,但核心流程和概念不变。整个过程可以分为三个阶段:
- 查找与绑定阶段 :客户端通过RMI注册表(Registry)查找远程对象的引用。这个引用(Stub)是一个代理对象,它包含了远程对象的位置信息(主机、端口、对象标识)。这就像你在电商平台(Registry)下单,获得了商家的仓库地址和物流单号(Stub)。
- 请求编组与传输阶段 :客户端调用Stub的方法时,Stub会将调用的详细信息(方法名、参数等) 序列化 (Marshalling)成一个字节流,然后通过网络发送给服务端。这个过程就是“打包”,把复杂的Java对象变成扁平的、可传输的字节包裹。
- 请求解组与执行阶段 :服务端(的Skeleton或动态代理)接收到字节流后,将其 反序列化 (Unmarshalling)回Java对象,识别出要调用的方法和参数,然后在真正的服务对象上执行该方法。执行完毕后,再将返回值或异常序列化,传回客户端。客户端再反序列化得到结果。
在整个流程中, 序列化(Serialization) 是“打包”或“编码”的过程,而 反序列化(Deserialization) 是“拆包”或“解码”的过程。RMI协议底层默认使用Java原生的序列化机制,这也是所有故事的起点。
2.2 为什么序列化是核心也是软肋?
Java原生序列化机制(
java.io.Serializable
)设计初衷是为了方便对象的持久化和网络传输。它通过递归地遍历对象图,将对象状态(字段值)以及必要的类描述信息(类名、serialVersionUID等)写入字节流。反序列化时,则根据流中的类描述信息,利用类的无参构造函数(或特定机制)重建对象,并恢复其状态。
这里的
关键风险点
在于:反序列化过程本质上是在
根据外部输入的数据流,在运行时动态构造对象
。如果攻击者能够控制输入流,并精心构造数据,就有可能诱使反序列化过程去实例化一个危险类(例如
Runtime.exec()
),并执行其中的恶意代码。RMI作为广泛使用的通信协议,其传输的序列化数据流自然成为了一个潜在的攻击面。许多框架(如Shiro、FastJson)在身份认证、数据解析时,如果直接反序列化用户可控的数据,就会引入漏洞。
注意 :并非所有对象都能序列化。一个类必须实现
java.io.Serializable接口(或其子接口如Externalizable)才能被序列化。transient关键字修饰的字段不会被序列化。理解这些基本规则,是分析流程的前提。
3. 反序列化流程的深度拆解与关键环节
现在,让我们聚焦到反序列化本身,把它从黑盒中打开。当服务端的RMI框架接收到一个网络包,准备将其还原为方法调用请求时,会发生以下一系列精密的操作。
3.1 字节流解析与对象图重建
反序列化器(
ObjectInputStream
)会按照严格的格式读取字节流。这个过程不是简单的“照单全收”,而是一个复杂的状态机:
- 读取魔数与版本 :首先验证流头部,确认这是合法的Java序列化流。
-
解析类描述符
:读取类名、serialVersionUID、字段描述等信息。这里会尝试使用当前线程的上下文类加载器(或系统类加载器)来加载这个类。
这是第一个关键点
:如果类路径中找不到这个类,就会抛出
ClassNotFoundException,这正是我开篇遇到的问题之一。在复杂的类加载器环境(如OSGi、Web容器)下,需要格外注意类加载器的一致性。 -
递归创建对象实例
:根据类信息,通过反射调用类的无参构造函数(或
readObject/readExternal方法)来创建对象实例。如果类没有可访问的无参构造,反序列化会失败。 - 填充对象字段 :按照流中字段的顺序和值,通过反射设置新创建对象的字段值。如果字段是另一个对象的引用,则递归进行上述过程,从而重建整个对象图。
3.2
readObject
方法:自定义反序列化的钩子
这是反序列化流程中
最需要警惕的环节
。如果一个类定义了私密的
private void readObject(ObjectInputStream in)
方法,那么在反序列化该类的对象时,默认的字段填充流程将被跳过,转而执行这个自定义的
readObject
方法。这个方法内部通常会调用
defaultReadObject()
来执行默认的反序列化,然后再执行一些自定义的逻辑。
// 一个示例性的readObject方法
private void readObject(java.io.ObjectInputStream in)
throws IOException, ClassNotFoundException {
// 先执行默认反序列化
in.defaultReadObject();
// 然后执行自定义逻辑,这里可能是危险的!
// 例如,根据某个字段值,去执行命令或进行网络连接。
performSomeOperation(this.someField);
}
许多反序列化漏洞利用链(Gadget Chain)的起点,就是找到一个在
readObject
方法中执行了危险操作(如调用
Runtime.exec()
)的类。攻击者通过精心构造序列化流,让反序列化过程像多米诺骨牌一样,从一个“无害”的类开始,通过一系列的字段引用和方法调用,最终触发恶意代码。
3.3 实战中的流程追踪技巧
当遇到反序列化相关问题时,如何定位?以下是我常用的几种方法:
-
开启序列化调试
:通过设置JVM参数
-Dsun.rmi.server.logCalls=true或-Djava.rmi.server.logLevel=VERBOSE,可以在服务端日志中看到详细的RMI调用和序列化信息。 -
使用Agent进行字节码插桩
:对于更底层的分析,可以使用Java Agent技术,在
ObjectInputStream.resolveClass方法或特定类的readObject方法处插入日志,打印出正在反序列化的类名。这对于分析未知的利用链极其有效。 -
网络抓包与流分析
:使用Wireshark等工具捕获RMI通信流量。RMI默认使用JRMP(Java Remote Method Protocol)协议,其载荷就是Java序列化数据。虽然直接阅读二进制流困难,但可以将其导出,使用专门的工具(如
SerializationDumper)进行解析,直观地看到流中包含的类名和数据结构。 -
安全防护的介入点
:理解流程后,就知道在哪设防。常见的防护措施包括:
- 输入验证与白名单 :在反序列化前,对字节流进行校验,或使用白名单机制,只允许反序列化已知的安全类。这是最根本的解决方案。
-
替换
ObjectInputStream:自定义ObjectInputStream,重写resolveClass方法,在其中进行严格的类名检查。 - 使用安全替代方案 :考虑使用更安全的序列化协议,如JSON(Jackson, Gson)、Protocol Buffers、Kryo(需正确配置)等,它们不直接支持任意代码执行。
4. 从原理到实战:构造一个简单的RMI交互实验
光说不练假把式。我们通过一个极简的RMI示例,来亲眼见证序列化数据的流动。这个实验能帮你建立最直观的感受。
4.1 定义远程接口与实现
首先,定义一个远程接口,它必须继承
java.rmi.Remote
,其方法需要抛出
RemoteException
。
// IHelloService.java
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface IHelloService extends Remote {
String sayHello(String name) throws RemoteException;
// 注意:参数和返回值必须是可序列化的类型
}
接着实现这个接口。实现类需要继承
UnicastRemoteObject
并实现接口。
// HelloServiceImpl.java
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
public class HelloServiceImpl extends UnicastRemoteObject implements IHelloService {
protected HelloServiceImpl() throws RemoteException {
super(); // 默认导出到随机端口
// super(1099); // 可以指定端口
}
@Override
public String sayHello(String name) throws RemoteException {
System.out.println("[Server] Received call for name: " + name);
// 模拟一个复杂对象返回
return "Hello, " + name + "! Current time is " + System.currentTimeMillis();
}
}
4.2 启动RMI注册表与服务端
服务端需要创建远程对象实例,并将其绑定到RMI注册表。
// RMIServer.java
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class RMIServer {
public static void main(String[] args) {
try {
// 创建本地RMI注册表,监听1099端口
Registry registry = LocateRegistry.createRegistry(1099);
System.out.println("RMI Registry started on port 1099.");
// 创建远程对象实例
IHelloService helloService = new HelloServiceImpl();
// 将对象绑定到注册表,客户端通过“HelloService”这个名字查找
registry.rebind("HelloService", helloService);
System.out.println("HelloService bound in registry.");
System.out.println("Server ready. Waiting for calls...");
// 保持主线程运行,防止服务退出
Thread.sleep(Long.MAX_VALUE);
} catch (Exception e) {
e.printStackTrace();
}
}
}
4.3 客户端查找与调用
客户端从注册表获取远程对象的存根(Stub),然后像调用本地方法一样使用它。
// RMIClient.java
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class RMIClient {
public static void main(String[] args) {
String host = (args.length < 1) ? "localhost" : args[0];
try {
// 获取指定主机上的RMI注册表
Registry registry = LocateRegistry.getRegistry(host, 1099);
// 查找名为“HelloService”的远程对象存根
IHelloService stub = (IHelloService) registry.lookup("HelloService");
System.out.println("[Client] Found remote service.");
// 发起远程调用!参数“World”会被序列化传输
String response = stub.sayHello("World");
System.out.println("[Client] Received response: " + response);
} catch (Exception e) {
e.printStackTrace();
}
}
}
4.4 实验观察与流程印证
-
分别编译并先运行
RMIServer,再运行RMIClient。 -
在服务端控制台,你会看到
[Server] Received call for name: World的输出。这说明调用成功抵达服务端并执行。 -
关键推演
:当客户端调用
stub.sayHello("World")时:-
客户端Stub将方法标识
sayHello和参数String("World")序列化为字节流。 - 字节流通过网络发送到服务端的1099端口。
- 服务端RMI框架接收字节流,反序列化出方法名和参数。
-
框架调用真正的
HelloServiceImpl.sayHello("World")方法。 -
方法返回的
String对象被序列化。 - 字节流传回客户端。
-
客户端Stub反序列化得到
String响应,返回给调用者。
-
客户端Stub将方法标识
在这个过程中,字符串
"World"
和返回的
String
响应都经历了序列化与反序列化的洗礼。你可以尝试修改
sayHello
方法,让其接收或返回一个自定义的、包含复杂引用的
Serializable
对象,然后通过调试或日志来更细致地观察整个过程。
5. 反序列化漏洞在RMI中的典型利用场景与排查
理解了基础流程,我们再来看看攻击者是如何利用这个机制的。RMI本身有几个特定的攻击面。
5.1 RMI Registry 与 DGC 的攻击面
除了我们自定义的远程服务,RMI基础设施本身也提供了一些远程端点:
-
RMI Registry
:运行在1099端口的注册服务。它本身也暴露了
bind、lookup、list等远程方法。攻击者可以向Registry发送恶意的序列化数据,尝试利用其反序列化过程。 - DGC(Distributed Garbage Collection) :分布式垃圾收集器,用于管理远程对象的引用。它同样处理序列化数据。在某些JDK版本中,DGC端点可能无需认证即可访问,成为攻击入口。
针对这些端点的攻击,通常需要利用JDK或常用库中存在的“可利用类”(Gadget Class)链。例如,历史上著名的
UnicastRef
、
RemoteObjectInvocationHandler
等类,可以被组合用于在目标服务器上构造恶意RMI请求,进而触发二次反序列化或直接代码执行。
5.2 利用“反序列化链”进行攻击
攻击者并非直接发送一个包含
Runtime.exec()
的序列化流(因为流里不包含代码,只包含数据和类名)。他们是发送一个由一系列“无害”对象组成的链。这个链的每个环节都是一个已知的、存在于目标类路径中的类。当这些对象被反序列化时,它们
readObject
方法中的代码会被依次执行,通过巧妙的组合(如调用
getter
方法、修改字段值、触发静态代码块等),最终达到执行任意命令的目的。
例如,一个经典的简化链可能如下:
HashMap.readObject()
-> 调用
key.hashCode()
-> 如果key是
TemplatesImpl
代理 -> 触发
getTransletInstance()
-> 加载并实例化恶意字节码。
5.3 安全加固与排查清单
如果你的系统使用了RMI,以下是一些必须的检查和加固措施:
| 检查项 | 操作与目的 | 风险说明 |
|---|---|---|
| JDK版本 | 升级至最新长期支持版(如JDK 11/17/21)。 | 旧版本JDK(如6,7,8早期)包含大量已知的RMI/反序列化漏洞利用类。 |
| JEP 290过滤 | 确认已启用并正确配置JEP 290过滤器。 | JDK 9+引入了JEP 290,提供了内置的反序列化过滤器机制,可全局或局部限制可反序列化的类。 |
| RMI服务暴露 | 将RMI服务部署在内网,通过防火墙严格限制访问源。禁止将RMI Registry等直接暴露在公网。 | 减少外部攻击面。 |
| 代码审计 |
检查所有实现
Serializable
的类,特别是自定义了
readObject
、
readResolve
、
writeReplace
方法的类。
| 确保没有危险的反射、类加载、命令执行或文件操作逻辑。 |
| 依赖库安全 | 使用工具(如OWASP Dependency-Check)扫描项目依赖,更新存在反序列化漏洞的库版本(如Commons-Collections, Fastjson等)。 | 第三方库是Gadget Chain的主要来源。 |
| 使用替代通信 | 评估是否可以用REST over HTTP/HTTPS、gRPC等更现代、更安全的协议替代RMI。 | 从根本上移除Java原生序列化的风险。 |
5.4 应急排查与诊断工具
当怀疑存在反序列化攻击时:
-
分析日志
:重点搜索
ClassNotFoundException、InvalidClassException、StackOverflowError(可能由循环引用导致)等异常,但攻击成功的日志可能很隐蔽。 -
检查网络连接
:使用
netstat或ss命令查看服务器是否在监听RMI端口(默认1099),以及是否有异常的外部IP连接。 -
使用诊断工具
:
-
jstack:抓取线程栈,查看是否有线程卡在ObjectInputStream.readObject()或相关方法上。 -
jmap/jhat/ VisualVM :分析堆内存,查找异常的或大量的来自反序列化的对象实例。 -
安全扫描工具
:使用如
ysoserial(仅用于安全测试!)、marshalsec等工具,在授权环境下测试自己的服务是否存在已知的Gadget Chain。
-
实操心得 :在真实生产环境,最有效的防御往往是 纵深防御 。单一措施可能被绕过。组合使用升级JDK、启用JEP 290、网络隔离、代码白名单(如使用
SerialKiller这样的库)等多种手段,才能将风险降到最低。同时,监控系统的异常行为(如短时间内大量反序列化错误、出现未知类名的加载尝试)比单纯依赖漏洞修补更为主动。
6. 高级话题:动态代理与自定义传输协议
现代RMI在底层做了一些优化,理解它们有助于应对更复杂的情况。
6.1 动态代理与动态骨架
如前所述,JDK 1.2后用动态代理机制替代了静态Skeleton。这意味着,服务端在导出远程对象时,会动态生成一个实现了远程接口的代理类,用于处理网络通信。对于客户端来说,从Registry获取到的Stub也是一个动态代理。这带来的好处是无需预编译骨架类,更灵活。但在调试时,你可能会在栈跟踪中看到
sun.reflect.GeneratedMethodAccessorXXX
这样的动态类名,这是正常的。
6.2 自定义Socket工厂与SSL传输
RMI允许通过
RMISocketFactory
自定义客户端和服务端的Socket创建逻辑。这有两个主要用途:
-
实现SSL/TLS加密
:你可以自定义工厂,创建
SSLSocket,从而为RMI通信提供加密和身份验证,防止网络窃听和中间人攻击。 - 复杂网络环境适配 :例如,需要穿越代理服务器或进行特殊的网络配置时。
6.3 序列化替换与自定义协议
虽然不常见,但RMI确实支持替换默认的序列化机制。你可以通过设置
java.rmi.server.RMIClassLoader
相关的属性,或者自定义
RMIClientSocketFactory
和
RMIServerSocketFactory
,在传输层之上对字节流进行额外的编码/解码(如压缩、加密)。但这会显著增加复杂性和维护成本,通常只在有非常特殊的安全或性能需求时才会考虑。
对于绝大多数应用,我的建议是: 如果对安全有高要求,直接换用更现代的协议(如gRPC),而不是试图去加固古老的RMI 。后者的生态、工具链和安全实践都更为成熟。
7. 总结与个人实践建议
回顾整个RMI反序列化流程,从方法调用的拦截、参数的序列化打包、网络传输、服务端的解包与对象重建,再到结果的返回,每一个环节都蕴含着设计哲学,也潜藏着安全风险。作为开发者,我们不应该把RMI当作一个完全透明的黑盒。
我在实践中总结了几条建议:
- 知其所以然 :对于核心通信机制,满足于“它会工作”是危险的。花时间理解其基本原理,就像理解HTTP协议一样,能在出问题时给你清晰的排查方向。
- 安全左移 :在设计和编码阶段就考虑反序列化安全。对于任何从外部接收的数据流,在反序列化前必须进行严格的校验或使用白名单。将“不信任外部输入”作为铁律。
- 拥抱生态更新 :Java生态在不断发展,提供了更多更好的选择。对于新项目,认真考虑是否真的需要RMI。Spring Boot的远程调用、Apache Dubbo、gRPC等都是更现代、功能更丰富、社区更活跃的替代方案。
-
持续学习与演练
:安全威胁在进化。定期关注Java安全公告,了解新的漏洞和利用方式。在安全的测试环境中,可以尝试使用
ysoserial等工具(严格遵守法律和授权!)对自己的测试服务进行演练,直观地感受攻击是如何发生的,这能极大地提升你的安全意识和防护能力。
最后,记住一点: 漏洞往往存在于逻辑的边界和机制的衔接处 。RMI反序列化问题正是“便捷的远程调用机制”与“脆弱的对象重建机制”在边界上碰撞出的火花。理解了这个流程,你不仅能更好地防御,也能更深刻地理解Java平台本身的力量与复杂性。
1955

被折叠的 条评论
为什么被折叠?



