SQL 注入系统学习(一):从输入到查询结构
本文中的测试方法仅用于 CTF、靶场、本机实验和明确授权环境。
刚开始学习 SQL 注入时,很容易把注意力全放在 Payload 上:看到 id 就加单引号,看到报错就试 UNION SELECT。这种方法偶尔能解题,但题目只要换一个引号、增加一层括号或隐藏错误,原来的 Payload 就失效了。
真正可以迁移的能力是:根据请求和响应,还原用户输入在 SQL 中的位置。
1. SQL 注入究竟是什么
考虑一段危险的 PHP 代码:
1 | $id = $_GET['id']; |
正常访问:
1 | /user.php?id=1 |
数据库收到:
1 | SELECT id, username |
如果本地靶场中的输入是:
1 | 1 AND 1=2 |
最终查询变成:
1 | SELECT id, username |
用户输入已经不再只是一个 id,而是参与了 SQL 条件。这就是问题的核心:
应用把不可信数据拼进 SQL,使数据获得了改变查询结构的能力。
单引号本身不是漏洞,UNION 也不是漏洞。真正的漏洞边界是“数据和 SQL 结构没有分开”。
2. 拿到题目先建立正常响应基线
不要一上来堆复杂语句。先使用完全正常的值:
1 | ?id=1 → 显示 admin |
记录以下内容:
| 项目 | 观察内容 |
|---|---|
| 请求方法 | GET、POST、JSON 或其他 |
| 可控输入 | 参数、Cookie、请求头、路径 |
| 状态码 | 200、302、403、500 |
| 页面特征 | 固定文字、记录数量、第一条数据 |
| 响应长度 | 是否存在稳定差异 |
| 响应时间 | 正常波动范围是多少 |
这一步的作用是建立对照组。后面看到页面变化时,才能判断它更像业务结果、WAF 拦截、数据库错误还是网络波动。
3. 一次只改变一个因素
在授权靶场中,可以逐个测试:
1 | ' |
如果一次同时加入引号、注释、逻辑表达式和函数,页面报错后就无法判断是哪部分造成的。
单引号导致 HTTP 500,只能形成一个推断:
1 | 事实:加入单引号后,响应从 200 变为 500。 |
所以“单引号报错”不是 SQL 注入的完整证明。
4. 用真假条件判断输入是否影响查询
假设参数可能处于数字上下文,可以比较:
1 | ?id=1 AND 1=1 |
对应的 SQL 可能是:
1 | SELECT * FROM users WHERE id = 1 AND 1=1; |
如果真条件与正常页面一致,假条件稳定变为“无记录”,证据就比一次语法错误更强。
需要强调“稳定”:每个条件都应重复观察,避免缓存、随机内容或服务器负载造成误判。
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 | SELECT * FROM articles |
输入两侧不仅有引号,还有 %:
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 | 存在 / 不存在 |
6.4 时间差异
页面内容完全一样,只能观察条件相关的稳定延时。时间判断必须多次重复,并和正常请求的响应时间分布比较。
6.5 当前页面没有反馈
还可能存在:
- 写入后在其他页面触发的二次注入;
- Cookie 或请求头中的隐藏输入;
- 只改变排序的表达式注入;
- 题目源码或日志提供的其他观察点。
没有报错不等于没有漏洞,没有回显也不等于只能使用时间盲注。
7. MySQL 注释的一个常见细节
MySQL 支持:
1 | # 到行尾 |
其中 -- 后必须跟至少一个空白或控制字符,才会被 MySQL 当作行注释。
CTF 中常见的:
1 | --+ |
这里的 + 通常不是注释符的一部分,而是 URL 查询字符串经过表单式解码后变成空格。若请求没有经历这种解码,结果可能不同。
URL 中的 # 还会被浏览器当作 fragment,通常不会发送给服务器。因此在查询参数中使用它时,需要留意 URL 编码以及服务端的解码次数。
8. 一张初期判断清单
1 | [ ] 目标是否属于 CTF、靶场、本机或明确授权环境 |
9. 正确修复
普通数据值应使用参数化查询:
1 | $stmt = $conn->prepare( |
表名、列名、排序方向等 SQL 结构通常不能直接作为普通参数绑定,应使用固定映射或白名单:
1 | $allowed = [ |
关键词黑名单无法替代参数化查询。SQL 存在同义函数、等价运算符、不同空白和多种查询结构,继续添加黑名单只会制造新的边界问题。
10. 总结
这一阶段不需要背很多 Payload,只需要形成四个问题:
1 | 输入在哪里? |
下一篇将继续整理 MySQL 的联合查询:如何判断列数、寻找回显位、处理原查询干扰,以及 UNION、空格或逗号被过滤后应当怎样换路线。