1. 项目概述:从一次内部安全演练说起
前段时间团队内部做了一次红蓝对抗演练,蓝方同学在资产梳理时发现了一套若依(RuoYi)4.8.0版本的管理后台。这个版本在业内应用非常广泛,很多中小型项目都基于它进行快速开发。我当时负责代码审计部分,目标很明确:在已知若依框架存在历史安全问题的背景下,尝试挖掘新的、可利用的高危漏洞。很快,我的目光就锁定在了“定时任务”这个模块上。在任何一个管理后台中,定时任务功能都像一把双刃剑,它赋予了管理员在后台灵活调度系统任务的能力,但同时也可能因为实现不当,成为攻击者从后台权限到服务器命令执行的跳板。这次要分析的,正是这样一个典型的“定时任务RCE”漏洞。它并非一个简单的配置错误,而是涉及到了若依框架对动态任务调度的实现逻辑、Java反射机制的安全边界以及Spring框架下Bean的管理方式。理解这个漏洞,不仅能帮助我们修复特定版本的问题,更能深入理解在Java Web应用中,功能强大的后台特性如何可能演变为严重的安全风险点。
2. 漏洞原理深度拆解:动态任务调度的“潘多拉魔盒”
若依框架的定时任务模块设计初衷是为了让管理员能够在不重启应用的情况下,动态地添加、修改、删除和触发定时任务。这个功能本身非常实用,但其实现方式却为安全漏洞埋下了伏笔。
2.1 核心问题:反射调用与白名单缺失
漏洞的根源在于
com.ruoyi.quartz.util.JobInvokeUtil
类中的
invokeMethod
方法。这个方法负责执行定时任务定义的具体逻辑。为了支持动态调用,若依采用了Java的反射(Reflection)机制。反射是Java的一项强大功能,它允许程序在运行时检查、调用和修改类、方法、字段等。然而,能力越大,责任越大,反射如果使用不当,就会成为安全的重灾区。
在若依4.8.0及之前版本的实现中,
JobInvokeUtil.invokeMethod
方法大致逻辑如下:
-
通过任务配置的“调用目标字符串”(例如
ryTask.ryParams(‘params’)),解析出类名(ryTask)、方法名(ryParams)和参数(‘params’)。 -
使用
Class.forName()或SpringContextHolder.getBean()来获取对应的类或Spring Bean实例。 -
使用
Method.invoke()来执行解析出的方法。
这里最关键的安全缺失在于
“没有对可调用的类和方法进行任何白名单限制”
。攻击者(或已获取后台权限的用户)在创建或修改定时任务时,可以任意指定一个存在于当前Classpath中的、具有危险方法的类。例如,
java.lang.Runtime
或
java.lang.ProcessBuilder
,这两个类都可以用来执行系统命令。
注意 :很多开发者会误以为后台功能就是安全的,但忽略了“纵向越权”的风险。即一个拥有后台操作权限的用户(可能是被窃取的账号、内部恶意人员),利用后台功能进行更高阶的破坏(如执行服务器命令)。因此,后台功能的安全审计同样至关重要。
2.2 漏洞触发链条:从前端表单到系统命令
让我们把整个攻击链条串联起来,看看一个攻击者是如何一步步实现RCE的:
- 权限获取 :攻击者首先需要获得一个具有“定时任务”管理权限的后台账户。这可以通过弱口令、社会工程学、其他前端漏洞(如SQL注入导致管理员密码泄露)等方式实现。这是漏洞利用的前提。
- 任务创建/编辑 :攻击者登录后台,进入“系统监控 -> 定时任务”模块,点击“新增”或选择一个现有任务进行“编辑”。
-
恶意参数注入
:在任务编辑的表单中,有几个关键字段:
-
调用目标字符串
:这是漏洞利用的核心输入点。原本应填写如
ryTask.ryParams(‘test’)的格式。攻击者会将其篡改为恶意内容。 -
Cron表达式
:设定任务执行时间,可以设置为立即触发(如
* * * * * ?表示每秒执行一次)或指定未来某个时间。 - 任务参数 :传递给调用方法的参数。
-
调用目标字符串
:这是漏洞利用的核心输入点。原本应填写如
-
恶意Payload构造
:攻击者会在“调用目标字符串”中填入类似
java.lang.Runtime.getRuntime().exec(“calc”)的Payload。但直接这样写通常不行,因为若依的解析器期望的是类名.方法名(参数)的格式,且Runtime的exec方法是静态方法调用的一种特殊形式。更常见的利用方式是调用ProcessBuilder:-
调用目标字符串
:
java.lang.ProcessBuilder.start -
任务参数
:需要以ProcessBuilder构造函数能接受的方式传入。由于框架会尝试将参数字符串转化为目标方法的参数类型,这里可能需要一些技巧。一种可行的方式是利用JSON或特定分隔符来传递命令和参数数组。在实际漏洞利用中,攻击者可能会通过调试,发现框架使用
StringUtils.split(参数, “,”)等方式来分割参数,从而构造“cmd.exe, /c, calc”这样的参数字符串,最终让ProcessBuilder接收到一个命令数组[“cmd.exe”, “/c”, “calc”]并执行。
-
调用目标字符串
:
当管理员保存这个恶意定时任务后,根据其Cron表达式,Quartz调度器会在指定时间触发任务,调用
JobInvokeUtil.invokeMethod
,最终通过反射执行了攻击者指定的
ProcessBuilder.start()
方法,从而在服务器上启动了计算器程序(
calc
),证明了命令执行漏洞的存在。
3. 代码审计实操与关键点定位
理解了原理,我们来看看如何在代码中定位和验证这个漏洞。审计过程本身就是一次完整的攻击思路推演。
3.1 环境搭建与准备
首先,你需要一个若依4.8.0的源码环境。可以从其官方Gitee仓库下载对应版本。使用IDEA或Eclipse导入为Maven项目。确保能正常启动(通常访问
http://localhost:80
即可)。建议在本地虚拟机或隔离环境中进行,避免对真实系统造成影响。
审计的入口点很明确:从Web前端的功能点追踪到后端代码。我们知道漏洞出现在“定时任务”功能,所以直接从相关控制器入手。
3.2 关键代码追踪与分析
-
控制器定位
:在项目中搜索与“job”或“task”相关的Controller。很快可以找到
com.ruoyi.web.controller.monitor.SysJobController。这个控制器包含了定时任务的增、删、改、查、触发等所有API。 -
编辑/保存逻辑
:重点关注
edit和editSave方法。editSave方法会接收前端表单提交的数据,并调用服务层的updateJob方法。数据对象是SysJob。 -
深入服务层与工具类
:跟踪
SysJobServiceImpl.updateJob,会发现它最终操作数据库,更新sys_job表。这里看起来是安全的。漏洞不在这里,而在任务 执行时 。我们需要找到任务是如何被执行的。 -
定位任务执行器
:定时任务的执行通常由Quartz框架的Job类完成。在若依中,这个类是
com.ruoyi.quartz.util.QuartzDisallowConcurrentExecution(禁止并发)或QuartzJobExecution(允许并发)。这两个类都继承了Quartz的Job接口,并实现了execute方法。它们的execute方法中都有一行关键代码:JobInvokeUtil.invokeMethod(sysJob);。 就是这里! 我们将目光锁定到JobInvokeUtil。 -
剖析
JobInvokeUtil.invokeMethod:这是漏洞的核心所在。打开这个类,查看其invokeMethod(SysJob sysJob)方法。
// 简化后的漏洞代码逻辑
public class JobInvokeUtil {
public static void invokeMethod(SysJob sysJob) throws Exception {
String invokeTarget = sysJob.getInvokeTarget(); // 获取调用目标字符串,如“ryTask.ryParams('aaa')”
String beanName = getBeanName(invokeTarget); // 解析出beanName,如“ryTask”
String methodName = getMethodName(invokeTarget); // 解析出methodName,如“ryParams”
String methodParams = getMethodParams(invokeTarget); // 解析出参数字符串,如“aaa”
// 关键步骤1:获取Bean实例 - 这里允许获取任意Spring管理的Bean,甚至是系统类
Object bean = SpringContextHolder.getBean(beanName);
// 如果从Spring容器中取不到,则尝试用反射加载类(更危险!)
if (bean == null) {
bean = Class.forName(beanName).newInstance();
}
// 关键步骤2:获取并调用方法 - 没有对方法名进行任何安全检查
Method method = bean.getClass().getMethod(methodName, String.class); // 假设参数是String类型
method.invoke(bean, methodParams); // 反射调用
}
}
关键问题分析 :
-
SpringContextHolder.getBean(beanName):这个方法会尝试从Spring应用上下文中获取名为beanName的Bean。虽然Spring容器中通常不会有java.lang.ProcessBuilder这样的Bean,但这里没有校验beanName的合法性。 -
Class.forName(beanName).newInstance():如果从Spring容器中没找到,代码会直接使用反射根据类名实例化一个对象!这是最危险的路径。攻击者可以将beanName设置为java.lang.ProcessBuilder,那么这里就会成功实例化一个ProcessBuilder对象。 -
bean.getClass().getMethod(methodName, String.class):这里假设方法只有一个String参数。在实际漏洞利用中,攻击者需要找到匹配参数签名的方法。ProcessBuilder的构造方法参数是String...或List,而start()方法是无参的。因此,直接调用ProcessBuilder.start可能不匹配。但攻击者可以寻找其他路径,或者框架可能存在其他重载的invokeMethod方法能够处理数组参数。审计时需要仔细查看参数解析逻辑getMethodParams和后续的方法查找逻辑,看其是否支持可变参数或数组的转换。
实操心得 :在代码审计时,遇到反射调用(
invoke)、类动态加载(forName)、原生命令执行(Runtime.exec,ProcessBuilder)、反序列化(ObjectInputStream)等“高危函数”时,一定要像看到红灯一样停下来,仔细分析其输入是否用户可控,是否有完整的校验和过滤。若依这里的漏洞就是一个典型的“用户输入直接进入高危函数”案例。
3.3 漏洞验证PoC构造
在本地环境中验证漏洞,是确认其真实危害的关键一步。
-
登录后台
:使用默认账号
admin/admin123登录。 -
创建恶意定时任务
:
- 进入“定时任务”列表,点击“新增”。
- 任务名称 :任意,如“TestTask”。
-
任务组名
:默认
DEFAULT。 -
调用目标字符串
:这是Payload的核心。根据对代码的分析,我们需要找到一个能直接执行命令的路径。由于直接调用
Runtime或ProcessBuilder的静态方法可能因参数签名不匹配而失败,一个更可靠的途径是 调用一个已存在于Spring容器中的Bean的某个方法,而这个方法内部包含了危险操作,或者能帮助我们间接执行命令 。 -
但若依4.8.0的漏洞更直接。实际上,由于
JobInvokeUtil中使用了Class.forName(beanName).newInstance(),我们可以直接指定类名为java.lang.ProcessBuilder。然而,ProcessBuilder的构造方法需要参数。我们需要查看JobInvokeUtil中具体的参数解析和反射调用逻辑,看其是否支持将参数字符串转换为String[]并调用ProcessBuilder的构造函数。 -
经过对历史漏洞的分析和实际测试,一个可用的Payload格式可能是:将“调用目标字符串”设置为
java.lang.ProcessBuilder,并在“任务参数”中填写要执行的命令。但具体格式取决于JobInvokeUtil的解析细节。 请注意,公开讨论具体的攻击Payload细节存在风险,且各版本可能存在差异。 安全研究的目的在于定位问题和修复,而非提供攻击工具。在实际审计中,你可以通过调试,跟踪getMethodParams返回的字符串是如何被转换成方法参数的,从而构造出有效的调用链。
-
设置立即触发
:将Cron表达式设置为
* * * * * ?,让任务每秒执行一次,方便观察结果。 -
保存并观察
:保存任务后,查看服务器后台日志或系统进程,确认命令是否被执行。例如,在Windows上执行
calc会弹出计算器,在Linux上执行touch /tmp/test_vul会在/tmp目录下创建文件。
4. 修复方案与安全加固建议
发现漏洞后,更重要的是如何修复和避免同类问题。若依官方在后续版本中修复了此漏洞,其修复思路具有普遍的参考价值。
4.1 官方修复方案解析
在若依的更高版本(如4.8.1之后)中,对
JobInvokeUtil
进行了重大改造:
-
引入白名单机制
:核心修复是
禁止了通过
Class.forName()的任意类加载 。框架规定,invokeTarget字符串只能指向以下两种目标:-
Spring Bean调用
:格式为
Bean名称.方法名(参数)。例如ryTask.ryParams(‘hello’)。系统会严格限制只能调用SpringContextHolder.getApplicationContext().getBean(beanName)能获取到的Bean。这些Bean都是项目内预先定义好的、受管理的业务类,通常是@Service或@Component注解的类,从根本上杜绝了加载Runtime、ProcessBuilder等危险系统类的可能性。 -
静态类方法调用
:格式为
包名.类名.方法名(参数)。例如com.ruoyi.common.utils.StringUtils.trim(‘ abc ‘)。但这里也做了严格限制,通常只允许调用项目自身com.ruoyi包下的工具类,或者像java.lang.String这样无害的系统类。通过包名前缀的白名单或黑名单进行过滤。
-
Spring Bean调用
:格式为
-
移除危险的反射路径
:彻底删除了
if (bean == null) { bean = Class.forName(beanName).newInstance(); }这段代码。这意味着,如果调用目标不是一个有效的Spring Bean名称,任务执行将直接失败,而不会尝试去动态加载类。 -
增强参数校验
:对
invokeTarget的格式进行了更严格的正则匹配校验,确保其符合xxx.xxx(xxx)的规范,防止注入特殊字符破坏解析逻辑。
4.2 企业级安全加固实践
对于使用若依框架或自行开发类似动态任务功能的企业,可以参考以下加固措施:
-
最小权限原则
:
- 功能权限 :严格限制“定时任务”管理功能的访问权限。只有极少数核心运维人员才应拥有创建、修改任务的权限。普通业务管理员不应具备此权限。
-
执行权限
:运行Java应用的操作系统用户,应使用权限最低的专用用户(如
www-data,nobody),而非root。这样即使命令执行成功,能造成的破坏也有限。
-
输入校验与白名单
:
-
强制白名单
:这是最有效的措施。维护一个允许被定时任务调用的类和方法白名单。只允许调用业务需要的、经过安全审核的类和方法。例如,只允许调用
com.yourcompany.task.*包下的类。 -
严格格式校验
:对前端传入的“调用目标字符串”进行强格式校验,使用正则表达式确保其完全符合
[允许的包名/Bean名].[允许的方法名](参数)的格式。
-
强制白名单
:这是最有效的措施。维护一个允许被定时任务调用的类和方法白名单。只允许调用业务需要的、经过安全审核的类和方法。例如,只允许调用
-
代码审计与安全扫描
:
-
定期审计
:将
invoke、forName、exec、eval等关键词纳入代码审计的检查清单,定期对项目代码进行扫描。 - 使用SAST工具 :集成静态应用安全测试工具(如Fortify, Checkmarx, SonarQube with security plugins)到CI/CD流程中,自动发现此类漏洞模式。
-
定期审计
:将
-
日志与监控
:
- 详细日志 :在任务执行的关键节点(如解析目标字符串、反射调用前)记录详细的日志,包括操作人、时间、调用的目标和方法。这些日志是事后追溯和异常发现的重要依据。
- 行为监控 :对服务器上突然创建的陌生进程、对外网络连接等异常行为进行监控和告警。
5. 漏洞挖掘的延伸思考与技巧
通过这个案例,我们可以总结出一些在代码审计中挖掘类似漏洞的通用思路和技巧。
5.1 寻找“动态性”强的功能点
凡是提供了“动态”、“自定义”、“可配置”功能的后台模块,都是审计的重点。除了定时任务,还有哪些类似的功能点?
- 工作流/审批流引擎 :允许用户自定义流程节点和脚本。
- 报表工具 :允许用户自定义SQL查询或数据处理公式。
- 规则引擎 :允许用户配置业务规则,这些规则可能被解析为脚本执行(如Groovy, MVEL)。
- 模板渲染 :允许用户上传或编辑模板(如邮件模板、页面模板),如果渲染引擎支持执行表达式(如某些Thymeleaf、FreeMarker的不安全配置),可能导致模板注入(SSTI)。
- 数据转换/脚本任务 :允许用户编写一小段代码(JavaScript, Python, Shell)来处理数据。
审计这些功能时,要紧紧抓住 “用户输入是否最终被解析或执行” 这条主线。
5.2 审计反射调用链的通用方法
当在代码中看到
Method.invoke()
、
Class.forName()
、
Constructor.newInstance()
时,采用以下步骤进行审计:
- 溯源输入 :这个被反射调用的类名、方法名、参数,其来源是哪里?是否是HTTP请求参数、数据库存储字段、配置文件?能否被用户控制?
-
分析路径
:从用户输入点到反射调用点,中间经过了哪些处理?有没有进行过滤、校验、编码?校验逻辑是否可以被绕过?(例如,黑名单过滤
Runtime,但可以用java.lang.Runt+ime拼接绕过?)。 -
评估危害
:如果用户可控,可以调用哪些危险的类和方法?除了
Runtime.exec,还有:-
ProcessBuilder.start():命令执行。 -
java.lang.ClassLoader.defineClass():动态定义恶意类。 -
javax.script.ScriptEngine.eval():执行脚本代码(如JavaScript)。 -
java.io.FileOutputStream.write():任意文件写入。 -
java.net.Socket.connect():发起网络连接。
-
- 尝试构造利用链 :在安全测试环境中,尝试构造Payload,验证漏洞是否真实存在。注意,要在隔离环境进行。
5.3 针对若依框架的进一步审计方向
若依作为一个集成了大量功能的快速开发平台,除了定时任务,还有其他值得深入审计的模块:
-
文件上传与下载
:检查文件上传的路径、类型过滤是否可绕过,下载功能是否存在路径遍历(如
../../etc/passwd)。 - SQL查询功能 :一些动态报表或数据查询功能,是否将用户输入直接拼接到了SQL语句中,导致SQL注入。
- 表达式解析 :系统中是否使用了OGNL、SpEL、MVEL等表达式语言?其解析器是否安全?历史上有过多起表达式注入漏洞(如Struts2系列漏洞)。
-
第三方依赖
:检查
pom.xml中引入的第三方库版本,是否存在已知的公开漏洞(CVE)。可以使用OWASP Dependency-Check等工具进行辅助扫描。
这个RuoYi定时任务RCE漏洞的分析过程,是一次非常经典的从功能点入手、追踪代码流、定位危险函数、理解利用条件、最终提出修复方案的完整代码审计实践。它深刻地提醒我们,在追求功能强大和灵活性的同时,必须对安全保持最高的警惕,尤其是当用户输入与系统的强大能力(如反射、命令执行)相遇时,必须设立坚固的边界。
3787

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



