SQL 注入为什么能成立:从一条拼接语句看懂风险来源
网络安全实战 90 天 · Week 3 注入风险 | 难度 ★★ 入门 | 关键词:SQL 注入 参数化查询 输入校验 代码审计 自查
本周开始接触注入类风险。这篇的落点不是「怎么打」,而是「为什么会成立、怎么在自己代码里查出来、怎么改掉」。全文只在自建的 DVWA 靶场内做只读观察,所有 payload 落在 127.0.0.1 上,不指向任何真实站点。
一、先分清两个概念:拼接和参数化
SQL 注入的成因只有一个:用户输入被当成 SQL 语法的一部分参与解析。反过来,只要输入在进入 SQL 之前已经确定为「数据」,注入就无从谈起。这条界线划清了整套防护思路的起点。
| 做法 | 写法示例 | 输入的角色 | 注入风险 |
|---|---|---|---|
| 字符串拼接 | "... WHERE user_id = '$id'" | 语法的一部分,参与解析 | 存在 |
| 参数化查询 | "... WHERE user_id = ?" + 绑定参数 | 纯数据,不参与解析 | 不存在 |
| 整数强转 | intval($id) | 先转成整数再拼接 | 数字型可挡,字符型需另处理 |
说明 本文所有实验都在本机 Docker 里运行的 DVWA 靶场(http://127.0.0.1/)内进行,属于安全自查与代码审计练习。真实业务系统上的任何注入验证,都必须在书面授权范围内进行。
二、实验环境与准备
靶场在第一周已经搭好,这里只做可用性确认,避免把「环境没起来」误判成「注入没成功」------这是排查顺序上的第一步,也是很多人卡住的地方。
2.1 确认容器与页面状态
bash
bash
docker ps | grep dvwa
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1/
容器在跑、页面返回 200,环境就算就绪。这里顺便把 access log 的位置记下来,后面观察请求形态要用到。

2.2 关于日志观察
比起盯页面输出,我更习惯先看服务端日志。同一个参数值在日志里的原始形态,能直接告诉你后端有没有对输入做处理------这一步不需要任何特殊工具,是自查阶段成本最低的取证手段。
三、三种反应:注入是怎么被「看见」的
在测试环境的输入框里依次试三个值,观察页面出现的三种不同反应。这三种反应构成了后面所有判断的基础。
bash
text
1
1'
1' OR '1'='1
输入 1 时页面正常返回一条记录。输入 1' 时页面抛出了 SQL 语法错误,并且连带把数据库版本和查询语句的前半段打印了出来。第三个值让页面一次列出了全部 5 个用户。


自查要点 这三种反应对应的正是三个需要重点关注的配置项:①错误信息是否对外输出(对应图 2 的信息泄露);②查询是否缺少 LIMIT 约束(对应图 3 的全表返回);③输入是否被当作语法参与解析(对应三者的共同根因)。三个都关掉,这类问题就基本不可见了。
四、读懂那一行代码:从现象回到原因
报错只是现象,源码才是原因。测试环境的 View Source 功能会把后端代码贴出来,这是整篇里价值最高的一步。
原始代码把 $id 直接拼进字符串,中间没有任何处理:
php
text
$id = $_GET['id'];
$sql = "SELECT first_name, last_name FROM users WHERE user_id = '$id'";
把输入拼进去之后,实际执行的语句变成下面这样。WHERE 后面的条件被替换成了恒真表达式,所以整张表都被返回:
sql
text
SELECT first_name, last_name FROM users WHERE user_id = '1' OR '1'='1'
4.1 为什么一个单引号就能破坏语法
SQL 里的字符串字面量靠引号配对来界定边界。输入中多出来的一个引号,会提前把字面量关闭,其后的内容就被解析器当成语法而不是数据。这个动作在整个输入处理链条里只有一个前提:输入最终要经过字符串拼接才进入 SQL。改成参数化查询,这个前提就不存在了。
4.2 注释符在其中扮演什么角色
测试环境没有 SQL 登录页,登录绕过这一条无法直接复现,但它的核心机制可以在这里完整观察。原语句是 WHERE user_id = '$id',输入 1' -- 之后,引号被闭合,后面剩余的一截被注释掉,语法仍然成立。
bash
text
1' --
1' #
1' OR '1'='1' --
1' -- 只返回 user_id=1 那一条,说明后面的内容确实被注释掉了;1' # 效果相同,是 MySQL 特有的注释写法;最后一条返回全部。在实际的登录校验场景里,被注释掉的正好是密码比对那一段------这就是「免密登录」的全部原理。

五、自查清单:五个能立刻动手的检查点
把上面的分析落成可执行的自查动作。这五条不需要任何扫描器,在代码里就能查完。
| 检查点 | 怎么查 | 不合格时的整改动作 |
|---|---|---|
| 拼接式 SQL | 全局搜索字符串拼接的 SQL,看有没有变量直接进入引号内 | 改为参数化查询,占位符 + 绑定参数 |
| 错误信息外露 | 构造一个会产生语法错误的输入,看页面是否回显数据库报错 | 关闭 display_errors,统一异常页 |
| 全表返回 | 看查询是否带 LIMIT,列表页是否限制单次返回量 | 加 LIMIT,分页参数做整数校验 |
| 数据库账号权限 | 查连接账号的授权范围是否包含 DROP / FILE 等 | 按最小权限授权,只给业务需要的库表 |
| 日志留痕 | 查 access log 是否记录了完整查询串,保留周期多长 | 开启日志并设置保留期,便于事后追溯 |
重点 这五条里,第一条是根因,其余四条是纵深。只做第一条就能消掉绝大多数风险;但只做后面四条而不动拼接,问题只是被藏起来,换个入口还会出现。
六、踩坑记录
| 坑点 | 症状 | 解决办法 |
|---|---|---|
| 容器没启动 | 页面打不开,docker ps 也没有记录 | docker start 容器名,或加 --restart=always |
| 单引号不报错 | 输入后页面照常返回 | 说明不是字符型场景,改查数字型分支 |
| 没有返回全表 | 只返回一条记录 | 查询带了 LIMIT 1,看源码确认 |
| 注释符失效 | 加了注释仍然报语法错误 | MySQL 的 -- 后必须跟空格;改 # 或 %23 |
| 报错被关闭 | 页面白屏或只显示固定提示 | display_errors 关了,转为对比响应差异 |
逐条说清楚:
-
容器没启动:宿主机重启后 Docker 不会自动拉起容器。给容器加 --restart=always,或者每次手动 docker start,两行的事,但能省掉后面半小时的自我怀疑。
-
单引号不报错:不要急着换 payload。先判断类型------字符型看引号,数字型看算术表达式,方向错了后面全是白试。
-
没有返回全表:返回一条不代表没注入成功,很可能只是被 LIMIT 挡住了。看源码确认,再看能不能绕过分页限制。
-
注释符失效:MySQL 的 -- 后面必须跟一个空格,写成 '-- 会报错。在 URL 里空格会被编码,所以工具里常见 --+ 或 %23。
-
报错被关闭:这是好事,不是坏事。把重点从「看报错」转到「看响应差异」,判断成本更高但依然可行。
七、小结
-
注入的本质是数据被当成了语法,不是一个特定的字符串把戏。理解这一句,所有变体都能推。
-
一个单引号之所以有效,前提是输入经过了字符串拼接。消除这个前提,比研究怎么过滤引号有效得多。
-
报错信息是双刃剑:它帮你定位问题,也在帮别人定位你的系统。上线前统一异常页是必做项。
-
防护的优先级是「参数化查询 > 最小权限 > 错误收敛 > 日志留痕」,顺序不能反。
下一篇记录自动化的注入检测工具怎么用、什么时候不该用,以及它误报的几种典型场景。