后台框架带上定时任务模块本来是再常见不过的事,但这类模块一旦能从外部输入表达式或方法名,就很容易变成攻击者手里的跳板。近期我复盘了一个基于 RuoYi 类架构的开源后台系统的定时任务 RCE 漏洞,把黑名单与白名单的防护链路完整梳理了一遍。整个过程看下来,最值得讲的不是漏洞本身,而是绕过黑白名单的三种典型思路,以及防御侧应该怎么对应加固。
这篇文章面向两类读者:一类是正在维护后台管理系统、需要检查自己是否同样中招的开发或运维人员;另一类是安全测试岗的同行,想理解表达式类 RCE 的绕过方法论。我会尽可能把整个过程拆透,从攻击面、校验机制、三种绕过方式到防御排查,一次说清楚。
1. 背景与攻击面:定时任务为什么总被盯上
1.1 定时任务模块的执行链本质
定时任务模块在绝大多数后台框架里的定位是“方便业务同学配置周期执行逻辑”,比如定时刷新缓存、定时生成报表、定时推送消息。它看起来只是管理界面里的一个表单,但底层执行链却非常“重”:配置一个任务,往往要指定一个 Bean 名称、一个方法名,以及一组方法参数。等到触发时间,调度器拿到这些信息,通过反射找到目标方法,再把参数类型转换后执行。
问题恰恰出现在“参数”这个地方。为了配置灵活,很多框架允许任务参数写成字符串,再交给表达式引擎去解析。表达式引擎本来是做配置运算的,比如算个时间、取个环境变量,但它同时也具备调用 Java 类、创建对象、执行方法的能力。于是“配置一个定时任务”和“写一段可执行代码”之间的边界,就变得非常模糊。一个见过世面的攻击者,只要能把参数改成一个带恶意表达式的字符串,就相当于拿到了代码执行权限。
RuoYi 类框架里,任务调用通常维护在系统监控-调度任务菜单下,支持配置调用目标字符串,例如“ryTask.ryParams(‘xxx’)”。后台框架会把这串字符解析成 Spring 环境里的 Bean 方法调用。本来这算是一个封闭的调用接口,问题在于它还支持方法参数的表达式形式,等于给表达式引擎开了一个口子。
1.2 低权限账号放大 RCE 影响
这类漏洞真正可怕的地方,不在于只有管理员才能操作。很多后台系统里的低权限账号,比如只有查看菜单权限的运营账号,也可能拥有“新建定时任务”的权限。定时任务模块本身就是为“非管理员也能配置”设计的,权限模型往往只区分“可不可见”和“可不可编辑”,并不会深入校验该用户能不能执行某个具体的 Java 方法。
从攻击链上说,一个低权限账号新增一条“特殊参数”的定时任务,等待调度器触发,命令就执行了,完全不需要绕过系统登录、也不用打什么中间件漏洞。整个过程就像在后台表单里填了一行字符,但系统实际执行了完全不同的语义。RCE 一旦发生在这种位置,等于直接绕过中间安全设备,因为流量本来就是合法的 HTTPS 请求,数据是正常下发到任务表里的。
这也是为什么我把这个漏洞归类为“业务逻辑型 RCE”,而不是传统的框架反序列化或上传漏洞。它依赖的是业务功能自身的信任边界缺陷。
2. 黑白名单校验机制到底拦住了什么
2.1 黑名单策略与常见清单
为了修复这种表达式注入问题,RuoYi 类框架在任务调用参数的位置加了一层校验,核心思路是“黑名单过滤”。也就是说,在解析表达式或拼接反射调用前,先检查参数字符串里是否包含敏感关键字,如果命中就直接拒绝执行。
常见的黑名单关键字包括但不限于:
java.lang.Runtimejava.lang.ProcessBuilderjavax.namingjndildapexecgetRuntimestart()
这种做法的优点是很直观,写起来也很快,几个字符串过滤判断就能上线。但它的本质是“已知威胁拦截”,不是“未知威胁免疫”。攻击者只要稍微变化一下写法,把关键字拆开、用反射加载、换个入口类,黑名单就失效了。
2.2 白名单策略与执行入口
后续版本里,框架在定时任务配置上增加了白名单策略,也就是只允许调用预先在系统参数里指定的几个 Bean 方法,比如ryTask.ryNoParams、ryTask.ryParams,其他调用一律拒绝。这种方案把执行范围锁死在了业务方法本身,理论上攻击面被缩得很小。
白名单的关键作用在于,它不再跟攻击者玩“猜关键字”的游戏,而是直接限定“你能调用的就是这几个方法”。如果这几个方法本身没有命令执行能力,那表达式写得再花哨也调不到危险类。
但白名单也有两种常见的实现偏差:
- 一种是“白名单判断方法名,但方法名可以拼接或大小写绕过”;
- 另一种是“白名单只管入口方法,但入口方法的参数仍然交给表达式引擎解析”。
第二种偏差最致命,它等于在门口站了保安,但允许访客自己带武器进来。攻击者的关键目标就变成了:找一个能进入白名单的方法,并且这个方法有参数可以被表达式解析。
2.3 两类机制的固有盲区
黑名单的盲区在于它永远落后于攻击手法。今天你拦截了Runtime,明天攻击者用ProcessBuilder;你拦截了ProcessBuilder,攻击者用反射一瞬间拼出Runtime;你拦截了反射关键字,攻击者用脚本引擎绕过去。这种猫鼠游戏没有尽头。
白名单的盲区则在于“入口收窄不等于数据净化”。只要表达式引擎还在参数处理链路里,攻击者就不需要直接在任务字符串里写完整攻击载荷,想办法通过某个方法参数把恶意字符串带进去就行。更麻烦的是,很多框架为了兼容旧配置,白名单开启后还保留了通配格式或动态方法匹配,一个不小心的正则符号就让白名单退化成黑名单的水准。
我见过不少开发者以为升级到白名单版本就安全了,实际上配置里的调用目标仍然保留了*通配,或者允许从数据库读取自定义方法名。只要存在这种“可配置执行目标”的通道,攻击者就能重新获得代码执行空间。
3. 三种绕过技巧的完整拆解
3.1 绕过方式一:绕开 Runtime,改用进程创建入口
黑名单过滤java.lang.Runtime是最常见的操作,因为它是 Java 执行系统命令最直接的类。安全意识足够的人都知道要过滤它,但很多人不知道的是,Runtime.exec底层其实是通过ProcessBuilder来启动进程的。
也就是说,攻击者完全不必碰Runtime这个字符串,直接构造:
new java.lang.ProcessBuilder("whoami").start()如果目标 JDK 环境里ProcessBuilder没有被过滤,表达式引擎会直接执行这一行,返回一个进程对象。更麻烦的是ProcessBuilder还能带参数、可以指定目录、可以链式调用,灵活性比Runtime.exec还高。
我在一些项目的安全测试里专门试过这个方法,很多黑名单清单里写着过滤java.lang.Runtime,但把java.lang.ProcessBuilder漏掉了。这其实不算什么高深技巧,就是一个很朴素的思路:拦截清单不可能写全,那就换个同功能的类。
规避检测的技巧也可以再进一步,用十六进制或 Unicode 转义改写类名。表达式引擎在解析字符串时会把转义还原成实际类名,而黑名单是基于“明文关键字”做的匹配,于是攻击者构造一串\u006a\u0061\u0076\u0061...的转义字符,黑名单里根本看不到java.lang.Runtime这几个字母。
3.2 绕过方式二:反射加类加载器动态拼接
黑名单过滤字符串,攻击者就避开在明文里出现完整类名。反射加类加载器是绕过字符串过滤最经典的方式,核心思路是:先用一个不在黑名单里的类和方法拿到类加载器,再用类加载器去加载目标类。
Java 里每个线程都能拿到上下文类加载器:
Thread.currentThread().getContextClassLoader().loadClass("java.lang.Runtime")这句话本身不包含Runtime关键字之外的危险方法名,很多黑名单对loadClass并不敏感。拿到Class对象之后,再通过getDeclaredMethod去找getRuntime方法,然后invoke调用,最后拿到 Runtime 实例,再getMethod("exec", String.class).invoke(...)。
整个过程拆成多段,几乎每一段的类名都避开了黑名单的关键字列表。黑名单匹配的是“完整的一行攻击载荷”,但反射把载荷拆成了分散在表达式各处的普通方法调用,让简单字符串匹配完全失效。
这种绕过方式的成功前提是两点:
- 表达式引擎允许调用反射相关方法;
- 上下文类加载器没有被安全管理器限定。
很多后台框架跑在默认 JDK 环境下,没有启动安全管理器,Thread.currentThread()随便拿,类加载器完全开放,等于直接把大门钥匙交给了攻击者。
3.3 绕过方式三:脚本引擎桥接表达式与 Runtime
第三种方式是直接用 JDK 自带的脚本引擎。Java 的javax.script包提供了脚本执行能力,其中最常见的入口是:
new javax.script.ScriptEngineManager().getEngineByName("js")拿到脚本引擎后,往里面塞一段脚本代码。绝大多数默认脚本引擎的上下文里可以直接引用 Java 类,甚至可以通过importClass或Packages方式访问任意 Java 类。例如在脚本引擎里写上:
var process = java.lang.Runtime.getRuntime().exec("whoami");这个思路的精妙之处在于,表达式引擎负责创建 ScriptEngineManager 对象,而脚本引擎内部执行的代码属于“另一种语言的解释器”,黑名单过滤器通常只会检查表达式字符串本身。攻击者只要把恶意代码写进脚本里,黑名单的注意力全在表达式的主干上,很难覆盖到脚本内容。
有些框架会连ScriptEngineManager一起放进黑名单,但还是可以换思路:先把脚本代码写到一个临时对象里,再通过表达式去反射调用ScriptEngine相关类。只要字符串过滤不识别javax.script,这条路就走得通。
我更愿意把这种方式理解为“解释器嵌套解释器”:框架用表达式引擎解析任务参数,表达式引擎又用脚本引擎解析脚本字符串,攻击者利用新的解释层把检测层的视线完全隔断。
3.4 三种技巧背后的通用方法论
把三种方式放在一起看,攻击者的方法论其实很清晰:
- 换入口:不碰黑名单里的
Runtime,改用ProcessBuilder; - 拆载荷:用反射把完整的攻击类名和方法名拆成分散片段,让字符串黑名单无从匹配;
- 换解释层:引入脚本引擎,让恶意代码不再以“目标表达式”的形态存在。
这套方法论在几乎所有表达式注入漏洞的利用里都能看到。黑名单做得再好,攻击者只要手里有“换入口”“拆载荷”“换解释层”这三把钥匙,就能不断开锁。真正拦得住这三者的,从来不是更长的黑名单,而是结构性限制。
4. 从防御视角做一次完整复盘
4.1 一次模拟验证的完整流程
为了讲清楚检测要点,我在本地搭了一套 RuoYi 类框架的最小复现环境,按照真实场景做了模拟验证。整个过程分成四步:
第一步,准备一个有定时任务编辑权限的低权限账号,登录后台进入定时任务新增页面。注意这里完全不需要管理员权限。
第二步,在调用目标里填入一个符合业务格式的合法方法,比如ryTask.ryParams('hello'),让任务先能正常创建。这一步是为了过掉前端格式校验。
第三步,修改参数内容,把单引号里的普通字符串替换成攻击表达式。以命令执行为例:
ryTask.ryParams('new java.lang.ProcessBuilder("whoami").start()')第四步,等待任务触发,或者在测试环境手动运行一次定时任务。观察目标服务器上进程是否被创建,同时查看应用日志里任务执行结果是否包含命令回显。
我特意没有使用真实生产环境,也没有使用任何攻击载荷针对线上目标。整个过程都是为了验证“黑名单过滤是否真的会在高危入口之前拦截”,结果发现黑名单只对包含Runtime.exec字样的字符串生效,换成ProcessBuilder后直接放行。
4.2 日志里留下的关键痕迹
从防御角度出发,模拟验证最大的收获是知道攻击请求长什么样。日志里不会直接写着“这是一次攻击”,但有几个特征非常典型:
- 定时任务调用目标里出现了
new、()、java等明显不属于正常方法参数的关键字; - 任务执行日志里出现了非业务异常,比如
ClassNotFoundException、ExpressionException,因为是攻击者在试探类的可用性; - 数据库的任务表字段值带有异常长度,正常任务参数一般是固定参数名,恶意参数往往是一大段表达式;
- 监控平台如果检测进程创建事件,会在任务触发点发现
whoami、id等探测命令的执行。
很多团队忽略了一个事实:定时任务一旦被表达式注入,恶意代码是在业务进程里执行的,它的进程名和普通业务进程完全一样,传统杀软不会认为这是异常。只有从“配置内容”和“执行行为”两个维度同时看,才能发现问题。
4.3 为什么升级框架不等于一劳永逸
复盘过程中有个很容易误导人的结论:升级到高版本框架就行了。这个结论只在“高版本框架默认关闭表达式参数”的前提下成立。实际上,升级只是改变了默认配置,历史存量任务里,攻击者早已写入的恶意表达式不会因为升级自动消失,它们还躺在数据库的任务表里,升级之后照样会被触发执行。
更微妙的地方在于,框架升级后,黑白名单的校验逻辑变了,接口返回的提示信息也变了。但定时任务的执行日志、操作审计日志不会自动清理,攻击者造过的尝试记录就留在那里。如果只升级不排查,相当于把报警记录一直保留在案发现场,却不再回头看。
我在复盘时习惯先做数据库任务表全量导出,把invokeTarget字段单独拉出来做一遍模式匹配扫描。这个动作成本很低,但能发现绝大多数已经被植入的恶意任务。
5. 加固与排查清单
5.1 开发侧:从入口拆掉表达式能力
最有效的修复方案不是继续扩充黑名单,而是从架构上拆掉定时任务对表达式引擎的依赖。如果定时任务只是为了调用固定业务方法,那就不要在参数位置支持任意表达式,改为明确的方法名加参数列表,用 Java 的强类型转换来解析参数,而不是把整段配置字符串丢给表达式引擎。
具体到代码层面,可以这样做:
// 将调用目标拆分为方法名和参数两个字段 // 前端只提交 beanName、methodName、params,不允许提交完整表达式 String beanName = "ryTask"; String methodName = "ryParams"; Object[] args = parseParams(jsonParams); // 参数用 JSON 解析,禁止表达式注入如果业务上确实需要动态调用,那就维护一张白名单表,表里只存允许调用的方法名,并在调用前用精确匹配去校验。白名单校验时要注意两点:一是不能使用包含通配符的模糊匹配,二是白名单判断必须在参数解析之前完成,不能在表达式执行到一半时才去校验。
还有个容易忽略的细节:定时任务的配置入口往往继承了通用表单组件,表单里如果带有富文本或动态 SQL 编辑功能,攻击者可能通过其他字段绕过任务参数校验,直接修改invokeTarget属性。所以修复不仅要看服务端解析逻辑,还要把可编辑字段的写入权限收紧,只有明确允许管理的角色才能改任务配置。
5.2 运行时侧:隔离、监控、最小权限
给定时间任务不可完全去掉的前提下,运行时侧要做三层加固:
第一层是权限隔离。定时任务模块最好独立运行在单独的应用实例上,这个实例的 JVM 启动参数带上安全策略文件,禁止加载反射调用高风险类,禁止访问java.lang.Runtime和java.lang.ProcessBuilder。启动命令里可以加:
java -Djava.security.manager -Djava.security.policy=/path/security.policy -jar app.jar第二层是操作系统账号隔离。定时任务应用进程使用独立的低权限系统账号运行,不给 sudo 权限,不挂载写权限的目录,宿主机的敏感文件目录对应用账号不可读。这样即使表达式注入成功,攻击者的命令执行结果也拿不到多少有价值的东西。
第三层是行为监控。至少要在容器或宿主机侧开启进程创建日志,并在日志分析系统里配置规则:当业务进程创建的子进程是whoami、id、curl、wget、bash等明显非业务命令时,触发告警。这个规则的误报率比较低,因为正常业务定时任务几乎不会在应用进程里拉起 shell 或下载工具。
5.3 自查与检测的几条实操命令
如果怀疑自己的框架版本可能受影响,可以按下面的思路自查一遍:
第一,扫描任务表里的异常字段值。以 MySQL 为例,查询字段里含有关键字的任务记录:
SELECT job_id, invoke_target, create_by, update_time FROM sys_job WHERE invoke_target LIKE '%new %' OR invoke_target LIKE '%Runtime%' OR invoke_target LIKE '%ProcessBuilder%' OR invoke_target LIKE '%ScriptEngine%' OR invoke_target LIKE '%\"%' OR invoke_target LIKE '%T(%';这个查询不一定能覆盖所有绕过变体,但能捞出最直接的攻击痕迹。想更全面,就把invoke_target字段全部导出,用正则做一次特征匹配,重点看有没有T(、new java、getClass、forName这类结构。
第二,检查日志里定时任务触发的异常告警。表达式注入往往不会一次成功,攻击者会反复试错,日志里会出现大量ExpressionEvaluationException、ClassNotFound、MethodNotFound记录。把调度任务相关的日志单独收集,按异常类型统计,出现频率异常的任务需要重点排查。
第三,检查服务器的进程启动时间。命令执行成功后,whoami、curl这类命令只是瞬时进程,不容易被捕捉。如果在全链路监控平台或审计系统里保留了execve系统调用日志,直接按应用进程 PID 筛出子进程记录,就能看到攻击者执行过的所有命令。
5.4 处理已感染任务的标准动作
确认存在恶意定时任务之后,按下面顺序处理:
# 1. 立即暂停调度器,避免恶意任务继续触发 # 2. 备份 sys_job 表数据,保留取证样本 # 3. 删除或禁用所有非预期任务 # 4. 检查攻击者是否通过命令执行落盘了 webshell、计划任务、替换了启动脚本 # 5. 重置该系统关联的密钥、数据库口令、中间件控制台口令这里要注意取证顺序:先备份数据库,再删除任务。不要一上来就直接把恶意任务删掉,那样会丢掉攻击者的配置痕迹,后面想溯源就难了。至少要记录下job_id、invoke_target、create_by、create_time、update_time这几个字段的原始值,再执行删除。
落地检查也要做完整。命令执行权限拿到后,攻击者最喜欢做的事是写一个定时反弹 shell 的脚本,或者把公钥写入应用运行账号的authorized_keys,这样即使后台漏洞被修复,他还能通过 ssh 继续控机器。排查时需要看应用账号的 crontab、系统级的/etc/cron*、/tmp目录下可疑文件,以及.ssh目录里是否多了不认识的公钥。
这个漏洞复盘让我最大的感触是:安全控制里最不值钱的就是黑名单,最值钱的是移除能力。只要定时任务还能被表达式引擎解析,黑名单写多长都只是时间问题。真正稳的做法是设计上取消“字符串即代码”的通道,同时在运行时侧把它可能产生的影响面压到最小。如果条件允许,强烈建议把定时任务模块做成独立服务,跟主业务库分开,权限和网络双重隔离,这样就算某个入口失守,损失也能控制在局部范围,不至于让一台后台机器直接变成攻击者的跳板。