本文中的测试方法仅用于 CTF、靶场、本机实验和明确授权环境。

刚开始学习 SQL 注入时,很容易把注意力全放在 Payload 上:看到 id 就加单引号,看到报错就试 UNION SELECT。这种方法偶尔能解题,但题目只要换一个引号、增加一层括号或隐藏错误,原来的 Payload 就失效了。

真正可以迁移的能力是:根据请求和响应,还原用户输入在 SQL 中的位置。

1. SQL 注入究竟是什么

考虑一段危险的 PHP 代码:

1
2
3
$id = $_GET['id'];
$sql = "SELECT id, username FROM users WHERE id = $id";
$result = mysqli_query($conn, $sql);

正常访问:

1
/user.php?id=1

数据库收到:

1
2
3
SELECT id, username
FROM users
WHERE id = 1;

如果本地靶场中的输入是:

1
1 AND 1=2

最终查询变成:

1
2
3
SELECT id, username
FROM users
WHERE id = 1 AND 1=2;

用户输入已经不再只是一个 id,而是参与了 SQL 条件。这就是问题的核心:

应用把不可信数据拼进 SQL,使数据获得了改变查询结构的能力。

单引号本身不是漏洞,UNION 也不是漏洞。真正的漏洞边界是“数据和 SQL 结构没有分开”。

2. 拿到题目先建立正常响应基线

不要一上来堆复杂语句。先使用完全正常的值:

1
2
3
?id=1       → 显示 admin
?id=2 → 显示 guest
?id=9999 → 显示 user not found

记录以下内容:

项目 观察内容
请求方法 GET、POST、JSON 或其他
可控输入 参数、Cookie、请求头、路径
状态码 200、302、403、500
页面特征 固定文字、记录数量、第一条数据
响应长度 是否存在稳定差异
响应时间 正常波动范围是多少

这一步的作用是建立对照组。后面看到页面变化时,才能判断它更像业务结果、WAF 拦截、数据库错误还是网络波动。

3. 一次只改变一个因素

在授权靶场中,可以逐个测试:

1
2
3
4
5
'
"
\
(
)

如果一次同时加入引号、注释、逻辑表达式和函数,页面报错后就无法判断是哪部分造成的。

单引号导致 HTTP 500,只能形成一个推断:

1
2
3
事实:加入单引号后,响应从 200 变为 500。
推断:单引号可能影响了后端某一层的解析。
待验证:它影响的是 SQL、应用验证、JSON、模板,还是 WAF?

所以“单引号报错”不是 SQL 注入的完整证明。

4. 用真假条件判断输入是否影响查询

假设参数可能处于数字上下文,可以比较:

1
2
?id=1 AND 1=1
?id=1 AND 1=2

对应的 SQL 可能是:

1
2
SELECT * FROM users WHERE id = 1 AND 1=1;
SELECT * FROM users WHERE id = 1 AND 1=2;

如果真条件与正常页面一致,假条件稳定变为“无记录”,证据就比一次语法错误更强。

需要强调“稳定”:每个条件都应重复观察,避免缓存、随机内容或服务器负载造成误判。

5. 识别输入所在的 SQL 上下文

同一个输入值,在不同 SQL 位置需要完全不同的处理。

5.1 数字型

1
SELECT * FROM users WHERE id = 1;

输入通常不在字符串引号中。还要检查后端是否先执行了 intval()parseInt() 或强类型绑定。

5.2 单引号字符串型

1
SELECT * FROM users WHERE username = 'admin';

此时应画出完整拼接结构:

1
SELECT ... username='  +  用户输入  +  '

需要考虑前后的引号和固定后缀,而不是直接照搬数字型写法。

5.3 LIKE 搜索型

1
2
SELECT * FROM articles
WHERE title LIKE '%mysql%';

输入两侧不仅有引号,还有 %

1
LIKE '%  +  用户输入  +  %'

这也是很多搜索框题目中,普通字符串型 Payload 直接失败的原因。

5.4 括号型

1
SELECT * FROM users WHERE (id = (1));

只处理引号、不处理括号,最终语法仍然不完整。遇到连续报错时,应重新计算固定前缀和固定后缀中各有多少层括号。

5.5 ORDER BY 标识符型

1
$sql = "SELECT * FROM products ORDER BY " . $_GET['sort'];

输入在这里代表列名或表达式,不是普通数据值:

1
SELECT * FROM products ORDER BY price;

排序注入可能不改变页面长度,只改变记录顺序。因此需要观察第一条记录、分页边界或相同数据的排列。

6. 判断题目提供了什么反馈

确定上下文之后,再判断能够通过什么通道观察查询结果。

6.1 数据回显

页面直接显示数据库内容。后续可研究原查询列数、可见列和结果类型。

6.2 错误回显

页面显示 MySQL 错误。错误可以帮助还原语句,但题目也可能隐藏或伪造错误。

6.3 布尔差异

页面不显示目标数据,但真假条件产生不同结果:

1
2
3
存在 / 不存在
登录成功 / 登录失败
有记录 / 空页面

6.4 时间差异

页面内容完全一样,只能观察条件相关的稳定延时。时间判断必须多次重复,并和正常请求的响应时间分布比较。

6.5 当前页面没有反馈

还可能存在:

  • 写入后在其他页面触发的二次注入;
  • Cookie 或请求头中的隐藏输入;
  • 只改变排序的表达式注入;
  • 题目源码或日志提供的其他观察点。

没有报错不等于没有漏洞,没有回显也不等于只能使用时间盲注。

7. MySQL 注释的一个常见细节

MySQL 支持:

1
2
3
# 到行尾
-- 到行尾
/* 块注释 */

其中 -- 后必须跟至少一个空白或控制字符,才会被 MySQL 当作行注释。

CTF 中常见的:

1
--+

这里的 + 通常不是注释符的一部分,而是 URL 查询字符串经过表单式解码后变成空格。若请求没有经历这种解码,结果可能不同。

URL 中的 # 还会被浏览器当作 fragment,通常不会发送给服务器。因此在查询参数中使用它时,需要留意 URL 编码以及服务端的解码次数。

8. 一张初期判断清单

1
2
3
4
5
6
7
8
9
10
[ ] 目标是否属于 CTF、靶场、本机或明确授权环境
[ ] 请求方法和全部可控输入是什么
[ ] 正常值、不存在值分别返回什么
[ ] 特殊字符是否造成稳定变化
[ ] 真条件和假条件是否存在稳定差异
[ ] 输入可能是数字、字符串、LIKE、括号还是标识符上下文
[ ] 页面提供数据、错误、布尔还是时间反馈
[ ] 是否有可靠证据证明后端是 MySQL
[ ] 输入是否经过转义、删除、类型转换或多层解码
[ ] 当前结论是事实、推断还是待验证

9. 正确修复

普通数据值应使用参数化查询:

1
2
3
4
5
$stmt = $conn->prepare(
'SELECT id, username FROM users WHERE id = ?'
);
$stmt->bind_param('i', $_GET['id']);
$stmt->execute();

表名、列名、排序方向等 SQL 结构通常不能直接作为普通参数绑定,应使用固定映射或白名单:

1
2
3
4
5
6
7
$allowed = [
'price' => 'price',
'time' => 'created_at'
];

$sort = $allowed[$_GET['sort']] ?? 'created_at';
$sql = "SELECT * FROM products ORDER BY $sort DESC";

关键词黑名单无法替代参数化查询。SQL 存在同义函数、等价运算符、不同空白和多种查询结构,继续添加黑名单只会制造新的边界问题。

10. 总结

这一阶段不需要背很多 Payload,只需要形成四个问题:

1
2
3
4
输入在哪里?
输入进入了什么 SQL 上下文?
页面提供了什么反馈?
目前有哪些事实,哪些只是推断?

下一篇将继续整理 MySQL 的联合查询:如何判断列数、寻找回显位、处理原查询干扰,以及 UNION、空格或逗号被过滤后应当怎样换路线。

参考资料