拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RuoYi 组合拳 RCE 的代码审计与防御加固

RuoYi 组合拳 RCE 的代码审计与防御加固

RuoYi 这套后台框架在国内中小型项目里的普及程度,基本上做 Java 后端的都碰过。但正因为用得多、改得多,围绕它的安全问题也一直没消停过。最近圈子里讨论比较多的一个词叫"组合拳 RCE",说的不是某一个孤立的漏洞编号,而是一类把多个看似不致命的小毛病串起来、最终拿到命令执行权限的攻击路径。这篇文章我不打算写成漏洞公告,而是站在代码审计和防御加固的视角,把这类链条的成因、入口、串联逻辑和封堵手段拆开讲清楚——因为对绝大多数还在跑 RuoYi 系列项目的团队来说,知道"哪里容易被串起来"比记住某个 CVE 编号有用得多。

关键词里出现频率最高的是 RuoYi、RCE 这两个词,其余大多是围绕框架本身的功能模块(定时任务、代码生成、Sa-Token 鉴权、MQTT、进销存等)延伸出来的搜索。这说明很多人其实已经意识到:风险点就藏在框架自带的那些"便利功能"里,只是不清楚它们是怎么被组合起来的。

下面进入正题。

1. 为什么"组合拳"这个词在 RuoYi 场景里特别值得警惕

1.1 单点小瑕疵不等于安全,串起来就可能致命

很多同学对漏洞的理解停留在一个朴素模型上:一个接口有没有做权限校验、一个参数有没有做过滤。这个模型没错,但它有个致命前提——假设攻击者只会用"单个漏洞打单个点"。真实的攻击不是这样运作的。攻击者手里有一堆"看起来危害有限"的东西:一个未授权的信息接口、一个能读取配置的调试端点、一个对方法名缺少白名单的调度模块、一个允许上传任意后缀的文件处理逻辑。单独拿任何一个出来,评级可能都只是"中危"甚至"低危",因为它们单独用的时候要么拿不到关键数据,要么入口够不着。

"组合拳"的精髓就在于把这些低危片段拼成一条完整的链:用信息泄露接口摸清框架版本和路由结构,用鉴权绕过拿到后台入口,再用调度或模板模块实现代码执行。每一步都不是新闻,但连起来就是实打实的高危。这也是为什么很多团队明明打了补丁、升级了依赖,还是会在溯源时发现被拿下了——他们修的是单点,攻击者走的是链。

RuoYi 这类快速开发框架天生容易形成这种局面,因为它为了"开箱即用",内置了大量动态能力:动态调用、动态生成代码、动态执行任务。动态能力越强,攻击面就越大,而这些能力往往是分散在不同模块里的,安全团队很难一次性看全。

1.2 RuoYi 生态的攻击面天然分散在哪些模块

要把组合拳讲明白,先得知道素材在哪。RuoYi 及其衍生版本(前后端分离版、微服务版,以及各种二次开发的办公、进销存、CRM 分支)常见的风险入口大致有这么几类:

模块功能定位潜在风险性质
定时任务调度动态创建/修改任务反射调用、脚本执行
代码生成器按模板生成代码模板注入、路径穿越
参数解析/表达式动态计算、规则匹配表达式注入
鉴权与签名Sa-Token、API 签名白名单绕过、越权
文件上传/导入附件、Excel 导入任意文件、反序列化
系统监控缓存、服务状态信息泄露、调试入口

这张表不是为了吓人,而是说明一个事实:任何一个模块单独看都能找到"合理用途"的解释,但它们的权限边界如果没设计好,就会互相"借力"。调度模块本来只该调用业务 Bean,可它如果能调用到 JDK 里的任意类,就变成了命令执行;代码生成本来只该读写项目目录,可它如果能穿出根路径,就变成了文件落盘。这些都不是设计者原本想要的,但实现上的宽松让它们成为了事实上的危险入口。

我个人的判断是:评估 RuoYi 类项目的风险,不要只盯着版本号和已知 CVE,要盯着"哪些功能允许用户输入间接控制到方法名、类名、表达式或文件路径"。这四个"控制点"是组合拳能不能打起来的关键。

2. 定时任务调度模块:RuoYi 绕不开的高危第一现场

2.1 为什么调度模块天生就容易出问题

RuoYi 自带的定时任务模块,设计目标是让运维能在后台不重启应用的情况下动态添加任务。为了实现"调用任意业务方法",它的底层普遍使用 Java 反射机制:后台把任务配置成"目标字符串 + 调用目标字符串",调度器解析这个字符串,反射出对应的类和类方法去执行。这个设计思路本身在业界很常见,Quartz 生态里也有类似做法。

问题在于,"调用目标字符串"如果没有任何白名单约束,用户的输入就等价于"让 JVM 去反射调用任意一个类的方法"。而 Java 反射能调用到的东西远超业务 Bean——它可以触达 JDK 类库里的许多类,这些类里存在能够间接执行命令、加载类、写文件的静态方法。攻击者要做的,就是把配置字段填成能触发这些行为的组合。看到这里你应该明白了:这是"设计上允许动态" + "实现上缺少白名单"共同造成的问题,不是某一行代码写错了。

这也是为什么单纯升级框架版本往往解决不了这类问题——如果你的二次开发还在沿用旧的调度实现,或者团队自己又加了一个"自定义脚本任务"功能,那攻击面是原生的、跟着代码走的。

2.2 从代码审计视角看这类逻辑的通病

如果你要审计自己项目里的调度模块,我建议按这么几条线索去翻:

  • 输入到反射之间的路径有没有过滤?从 Controller 接收"调用目标",到最终Class.forName(...)或getMethod(...)之间,是否只做了非空校验,而没有做类名/方法名白名单?
  • 白名单是黑名单还是白名单?很多项目写的是"禁止某些关键字",这属于黑名单,天然容易被绕过(大小写混合、编码、字符串拼接)。安全做法应该是"只允许调用指定包下的、已注册的方法"。
  • 有没有引入脚本引擎?如果任务支持 Groovy、JavaScript(Nashorn/GraalJS)之类的动态脚本,那等于把任意代码执行的口子直接开在了后台——这类功能的生产环境使用要极其谨慎。
  • 权限校验覆盖到哪一层?新增任务和修改任务的接口,是否只有超级管理员能访问?有没有可能被越权用户或匿名用户触达?

这里分享一个我审计时常踩的观察点:很多人以为"定时任务必须登录后台才能改",但实际上有些项目的任务接口虽然挂了注解,却因为白名单配置把整个任务路径放行了;还有些项目的鉴权是"前端路由级"的,后端接口并未真正校验。这两点一结合,调度模块就从前台后台之间的隔离墙变成了敞开的大门。

注意:判断一个动态任务功能是否危险,核心不是看它能不能"立即执行",而是看用户输入能否间接决定"调用哪个类、哪个方法"。只要这个决定权在用户手里,风险就已经存在了。

2.3 关于"立即执行"与"下次执行"的常见误判

一个很实际的细节:有些任务配置保存后不会马上跑,要等触发器时间到。很多运维因此觉得"我只要盯着调度结果就安全了"。这个判断不可靠。原因有两点:一是 Quartz 支持startNow()之类的立即触发,任务保存的同时就可能执行;二是真正危险的不是任务本身跑没跑,而是任务里配置的目标方法一旦被调用,副作用可能在任何后续时间点发生。换句话说,验收入口的安全,不能依赖"我还没看到执行结果"。

我在实际排查中遇到过更隐蔽的一种情况:攻击者并不直接让任务执行命令,而是让目标方法做一次"看起来无副作用"的操作,比如加载一个远程资源、写入一个标记文件,之后再通过别的入口接续利用。这让单看调度日志很难立刻察觉异常。所以对调度模块的监控,不能只看"任务是否成功",还要看"任务调用了哪些类"。

3. 鉴权与签名层的缝隙:Sa-Token 配置疏忽带来的连锁反应

3.1 接口鉴权白名单与权限校验的错位

RuoYi 的权限体系普遍基于 Sa-Token 或 Spring Security。无论是哪一套,鉴权逻辑的核心都是"哪些路径免登录、哪些路径需要权限"。白名单配置是重灾区。为了放行登录接口、验证码接口、静态资源、回调接口,开发者往往会写一堆excludePathPatterns或@SaIgnore。这些放行规则如果写得太宽——比如用了通配符把它覆盖到了本该受保护的接口上——就会出现"看似给登录页放行,实际把系统管理接口也放行了"的情况。

这类问题之所以成为组合拳的"第一跳",是因为它让攻击者不需要任何凭据就能摸到后台功能接口。一旦能匿名访问调度、上传、配置读取等接口,后面的事情就顺理成章了。我见过不少项目的白名单是从网上直接抄来的,抄的时候没验证过路径匹配规则,/**一写,整个应用就裸奔了。

3.2 API 签名机制被"形式化"之后的隐患

有些衍生版本引入了 API 签名,本意是让外部调用方按签名规则访问,防止参数被篡改。但如果签名校验只验"有没有带签名参数",或者签名密钥硬编码在可访问的配置里,那这层防护就是形式主义。更麻烦的是,签名机制一旦成为"鉴权绕过"的旁路——比如带签名的请求可以跳过常规权限校验——攻击者会优先盯上这条路径,因为它天然是"信任通道"。

审计签名逻辑时我通常会关注三点:密钥从哪来(是否硬编码)、签名算法是否可被降级(能否指定为none或弱算法)、时间戳与随机数校验是否真正生效(能不能重放)。这三点任何一点缺失,签名层就形同虚设。而一旦攻击者拿到了"合法签名"的通行能力,原本因为鉴权挡住的后台接口就会全部暴露,组合拳的第一环就接上了。

4. 一条典型攻击链的防御视角推演

前面讲了素材和缝隙,现在把链条串起来看。需要强调的是,下面描述的是从防御和溯源角度理解的逻辑顺序,目的是帮助运维和安全同学识别自己在链条的哪一环被突破,不是操作指引。

4.1 侦察阶段暴露了什么信息

链条的第一段通常是信息收集。攻击者会先确认目标是不是 RuoYi 系框架——这很容易,因为默认响应头、错误页、静态资源路径、/prod-api之类的接口前缀都带有明显的框架特征。接着他会尝试访问一些不需要权限的接口,比如获取验证码、系统配置、版本信息等,用来判断框架版本和二次开发程度。

这一段为什么难以完全杜绝?因为"框架指纹暴露"在开源项目里几乎是必然的。我们能做的不是彻底隐藏(收益有限),而是减少匿名可达的信息接口数量,让攻击者拿不到足以判断"该用哪条链"的细节。一个常见误区是:开发者把注意力全放在"堵住执行入口"上,却忽视了"暴露的版本信息本身就是导航仪"。

4.2 从入口到执行的逻辑串联

中段是链条真正咬合的地方。通常有两种走向:

一种是调度模块直连执行。攻击者通过一个鉴权有缝隙的任务接口,提交一个经过精心构造的"调用目标",让框架反射调用到能执行命令的方法。这里的关键节点有两个:入口是否可达、反射是否有约束。只要其中一个没守住,链条就走通了。

另一种是文件落点转执行。攻击者利用代码生成、模板渲染或文件导入功能,把内容写到服务器上的某个目录,再通过另一个入口触发它。这种组合更隐蔽,因为"写文件"和"执行文件"是两个不同接口,单看任何一个都不足以定级为严重。排查时如果只按接口维度各自为战,很容易漏掉它们之间的关联。

无论哪种走向,你都会发现一个共同点:真正让危害升级的往往不是"执行"这一步,而是前面"权限被绕过"或"输入被信任"的几步。也就是说,防守方如果把资源压在最后一步,是防不住的;必须往前压。

4.3 为什么"单点修补"挡不住组合拳

这一点我必须展开说,因为它是最容易被忽视的。假设一个团队发现某接口存在未授权访问,于是给这个接口加了鉴权,补丁上线,觉得万事大吉。但攻击者手里还有别的未授权接口,或者换了一条完全不同的利用路径——比如从文件上传绕到模板执行。单点修补的思维是"哪个洞漏了堵哪个",而组合拳的思维是"我不用你堵的那个洞"。

有效的做法是按"能力"而不是按"接口"来加固:不是"这个接口要鉴权",而是"所有能决定方法名、类名、表达式、文件路径的入口都必须受强约束"。把能力收敛了,攻击者换多少个接口都是白搭,因为底层的能力阀门被关小了。这个思路的转变,是防御组合拳的核心。

5. 代码层加固:把危险入口关进笼子

5.1 调度模块必须做白名单收敛

针对动态调度,我推荐的加固方向是:把"调用目标"从自由字符串改造成受控枚举或注册表。具体可以这样落地——维护一个允许被调度的 Bean 与方法注册表,后台配置时只能从这个注册表里选,而不是手填类名和方法名。这样即便接口被人触达,攻击者也只能在你划定的小圈子里挑,挑不出执行命令的方法。

如果业务实在需要灵活性,退一步的做法是对调用目标做严格的正则白名单:限定包名前缀、限定方法名集合、禁止出现Runtime、ProcessBuilder、Class、getClass这类敏感符号,并且对参数类型做严格校验。但请注意,黑名单式拦截永远只是缓解,不是根治,能上白名单就上白名单。

5.2 表达式与模板引擎的沙箱化

代码生成、规则匹配、动态计算这些功能,背后往往用到了 SpEL、OGNL、FreeMarker、Velocity 之类的表达式或模板引擎。这些组件的默认能力非常强,能调用方法和访问对象属性。加固思路有三层:

  • 输入层:模板内容、表达式字符串如果来自用户,必须经过严格校验,理想情况是完全不允许用户自定义。
  • 引擎层:使用引擎提供的沙箱或受限上下文,关闭危险特性(如禁止调用任意方法、禁止访问类加载器等),限制可用变量集合。
  • 输出层:对生成的文件路径做归一化校验,确保最终落点始终在你允许的目录之内,防止路径穿越。

我特别想提醒的是模板引擎的"路径"问题。很多团队防住了模板内容的注入,却忘了模板文件本身的路径也可能被控制,导致"读任意文件"或"写任意文件"。这两个能力中,写任意文件是可以直接接上 RCE 的,所以目录约束这一环千万别跳过。

5.3 上传与导入功能的边界控制

文件上传和 Excel 导入是另一个高频入口。加固重点在于:后缀白名单 + 内容类型校验 + 存储目录隔离 + 文件名随机化。后缀白名单要从"允许列表"出发,而不是"禁止列表";存储目录要放在 Web 根目录之外,并禁止该目录下文件的执行权限;文件名要随机生成,避免用户可控的路径拼接。

导入功能还要额外注意解析器的安全性。某些解析组件在处理恶意构造的文件时可能触发反序列化或资源耗尽,所以及时升级解析库版本、限制文件大小和解析深度,都是必要的。

6. 运行时可观测:在被拿下之前发现异常

6.1 关键日志与告警点

代码加固是一方面,运行时能不能发现异常是另一方面。我建议至少盯住这几类日志和事件:定时任务的"新增/修改"记录(谁在什么时候改了调用目标)、鉴权失败的集中出现(可能是在试探白名单)、异常堆栈中出现的反射相关类名、以及文件写入目录的非预期变化。这些信号单独出现可能都是正常业务,但短时间内集中出现就值得警惕。

日志的价值在于它能把"散落的单点"关联起来。前面说了组合拳的危害在于串联,那么防守的突破口也在于串联——通过统一的时间线把不同接口的异常访问拼起来看,往往能提前发现链条的痕迹。

6.2 进程与网络行为基线

更进一步的是主机层面的可观测性。正常业务进程不应该频繁地创建子进程、不应该连接到陌生的外部地址、不应该在非预期目录下读写文件。建立这些基线之后,任何偏离基线的行为都能第一时间触发告警。

这一步的意义在于它不依赖你事先知道攻击者用哪条链。无论攻击走的是调度反射还是模板落盘,最终要执行命令、要外联、要落地文件,都会在主机行为上留下痕迹。行为基线是应对未知组合的兜底手段,尤其适合那些二次开发较多、无法完全依赖框架自身安全性的项目。

7. 排查与恢复:怀疑中招后的处置顺序

7.1 快速自检清单

如果怀疑自己的 RuoYi 项目已经被打了组合拳,我建议按这个顺序快速过一遍:

检查项关注点异常信号
调度任务列表是否有非业务方创建的任务调用目标含敏感类名或方法名
鉴权白名单是否存在过宽通配管理接口可匿名访问
上传目录是否有脚本类文件非预期后缀、非预期时间
进程树应用是否派生子进程出现 shell、解释器类进程
网络连接是否有外联陌生 IP、异常端口
账号与日志是否有新账号、清除日志权限变更、日志短缺

这张表不是什么高深工具,就是让你有一条固定的排查路径,避免慌乱中到处乱翻。实际处置时,顺序比工具重要得多。

7.2 清除与回归验证

确认问题后,处置要果断:隔离受影响主机、下线可疑任务、重置相关凭据、清除落地的可疑文件。但清理完不是结束,还要做回归验证——在修复后的环境里,重新尝试原先的入口,确认它们已经不可达或已被约束。很多团队清理后就上线了,结果攻击者一开始就是靠"多个入口",你只堵了发现的这一个,剩下的还在,过几天又被绕进去。

我在实际处置中总结的一点经验:一次性把所有"决定方法名、类名、表达式、文件路径"的入口都过一遍,哪怕某些入口暂时没有发现被利用的痕迹。因为组合拳的特点就是你永远不知道攻击者顺手记下了几个备用入口。宁可多查几个,也别留下后手。

最后分享一个我自己的体会。RuoYi 这类框架的安全问题,本质上不是"哪个版本有漏洞"的问题,而是"动态能力与权限边界没设计清楚"的问题。只要项目里还存在让用户输入能间接决定"调用什么、执行什么、写到哪里"的功能,组合拳的土壤就一直在。与其追着漏洞编号打补丁,不如回到代码里,把这四类控制点一个个收进白名单和沙箱——这件事做扎实了,比任何一次应急都管用。

返回列表