后台框架带上定时任务模块本来是再常见不过的事但这类模块一旦能从外部输入表达式或方法名就很容易变成攻击者手里的跳板。近期我复盘了一个基于 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目录里是否多了不认识的公钥。这个漏洞复盘让我最大的感触是安全控制里最不值钱的就是黑名单最值钱的是移除能力。只要定时任务还能被表达式引擎解析黑名单写多长都只是时间问题。真正稳的做法是设计上取消“字符串即代码”的通道同时在运行时侧把它可能产生的影响面压到最小。如果条件允许强烈建议把定时任务模块做成独立服务跟主业务库分开权限和网络双重隔离这样就算某个入口失守损失也能控制在局部范围不至于让一台后台机器直接变成攻击者的跳板。