教程
🛡️

网络安全

Web 安全、渗透测试、密码学与安全合规的系统化教程。

SQL 注入:把用户输入当 SQL 执行的那一刻

从原理和危害讲清 SQL 注入,覆盖报错注入、布尔盲注、时间盲注、POST/表单注入、宽字节注入,并给出参数化查询等根本防御手段。

· 更新于 2026-07-18 阅读量 --

SQL 注入(SQLi)是 Web 安全里最经典、最高频、危害最大的漏洞之一,也是 OWASP A03 注入的代表。它对应我们二阶段学的 SQL——正因为后端把”用户输入”直接拼进了 SQL 语句,攻击才可能发生。这一篇把它讲透。

以下手法仅在授权靶场(如 sqli-labs、DVWA)中练习。对真实站点测试属违法。

原理:拼接出来的灾难

看一段”有洞”的后端代码(PHP 伪代码):

$id = $_GET['id'];
$sql = "SELECT * FROM users WHERE id = $id";
// 用户输入 id=1,拼成:SELECT * FROM users WHERE id = 1

如果用户输入 id=1 OR 1=1,拼出来变成:

SELECT * FROM users WHERE id = 1 OR 1=1

1=1 永远为真,于是返回了所有用户!这就是注入的本质:用户输入被当成了 SQL 代码的一部分,而不是单纯的数据。

危害有多大

  • 绕过登录admin' -- 让密码校验被注释掉;
  • 拖库UNION SELECT 把其他表的数据一并读出;
  • 脱裤改密码:写文件、改管理员密码;
  • 拿服务器:某些数据库支持读写文件、执行命令(如 MySQL 的 into outfile 写 webshell)。

SQLi 能让整个数据库裸奔,所以它是”高危中的高危”。

判断注入点:先找”能控制的地方”

任何用户可控、又拼进数据库查询的地方都可能是注入点:

  • URL 参数:?id=1?category=2
  • 表单字段:登录框、搜索框;
  • Cookie、HTTP 头(如 User-Agent);
  • 排序参数 ?order=id、limit 参数等。

判断方法:在参数后加 '",看页面是否报错、是否异常;或输入 1 AND 1=11 AND 1=2,对比页面差异。

联合查询注入(UNION):直接读数据

当页面会把查询结果”回显”出来时,用 UNION 把别的表拼进来:

?id=-1 UNION SELECT username, password FROM admin

要点:

  1. 让原查询”不返回”(如 id=-1),好让联合的结果独占页面;
  2. UNION 两侧的列数必须一致,先用 ORDER BY n 测出列数;
  3. 找到页面”显示哪几列”,把想读的数据放到对应列。

这是最直观的注入,靶场 sqli-labs 前几关就是它。

报错注入:让数据库”自己说出秘密”

有些页面不回显数据,但会把数据库报错显示出来。这时故意制造一个报错,让报错信息里带上我们想要的数据:

?id=1 AND extractvalue(1, concat(0x7e, (SELECT database()), 0x7e))

extractvalue / updatexml 这类函数遇到特殊 XPath 会报错,报错信息里就夹着 database() 的结果。常见函数还有 floor(rand()) 报错。报错注入不需要页面回显数据列,只要”报错可见”就能用。

布尔盲注:看不见结果,就问”是或否”

很多页面既不回显也不报错,但你输入对错,页面”长相”会不同(比如”文章存在”vs”文章不存在”)。这时用”真/假”问题一点点问出数据:

?id=1 AND (SELECT substr(database(),1,1))='a'  -- 猜库名第一个字符是不是 a

如果页面正常 → 猜对了;异常 → 猜错了。一个个字符试,像福尔摩斯问问题。配合脚本自动化,能慢慢”问”出整张表。

时间盲注:连”是/否”都不给,就比快慢

最狠的情况:对错页面完全一样,啥提示都没有。那就用”响应时间”当信号——真就睡 5 秒,假就立刻返回:

?id=1 AND IF(SUBSTR(database(),1,1)='a', SLEEP(5), 0)

页面卡了 5 秒 → 猜对了。时间盲注最慢,但最通用。sqlmap 的 --technique=T 就是它。

POST / 表单注入

注入不止在 URL。登录框、搜索框提交的 POST 数据,如果拼进 SQL 一样有洞:

-- 登录框用户名填:admin' --
-- 密码随便,SQL 变成:
SELECT * FROM users WHERE user='admin' -- ' AND pass='xxx'
-- 密码部分被注释,直接登录 admin

测试 POST 注入要用 Burp 改请求体,或表单里直接填 payload。

宽字节注入:绕过的特殊姿势

当程序用 addslashes()mysql_real_escape_string() 转义单引号(变成 \'),但数据库连接用了 GBK 这类宽字节编码时,攻击者可输入 %df'

  • %df + \(0x5c)= 一个合法汉字 %df%5c,于是 \ 被”吃掉”,单引号逃脱。

这是历史遗留编码问题,现在 UTF-8 站点少见,但老系统仍可能遇到。防御是统一用 UTF-8 并设置 SET NAMES utf8

防御:根本解法只有一条

参数化查询(预编译语句) 是唯一根治 SQLi 的办法:

// 错误:拼接
$sql = "SELECT * FROM users WHERE id = $id";

// 正确:参数化(PDO 示例)
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$id]);

原理:SQL 结构和数据分开传输给数据库,数据库先编译”骨架”,再把 $id 当作纯数据填进去——无论用户输入什么,都不会变成代码。各语言都有对应写法:Java 的 PreparedStatement、Python 的 ? 占位符、Node 的 ? 参数。

辅助手段(不能替代参数化):

  • 输入校验:id 必须是数字就强转 intval()
  • 最小权限:Web 用的数据库账号只给必要权限,别给 FILESUPER
  • 关掉错误回显:生产环境 display_errors=Off,避免泄露表结构;
  • WAF:拦常见 payload,但只是缓解,不是根治。

更多实战案例:联合查询注入一步步来

假设接口 ?id=1 返回用户信息,后端 SELECT name,email FROM users WHERE id=$id。我们判断它是数字型注入后,先测列数:?id=1 ORDER BY 3 报错、ORDER BY 2 正常,说明查两列。再让原查询无结果、用 UNION 接管:?id=-1 UNION SELECT 1,2 看回显位;然后 ?id=-1 UNION SELECT database(),version() 直接拿到库名和版本;接着 ?id=-1 UNION SELECT table_name,2 FROM information_schema.tables WHERE table_schema=database() 爆表;再爆列、再拖数据。这就是手工联合注入的完整链路,理解它比只会用工具重要,因为很多环境工具跑不出来。

更多实战案例:报错注入与盲注

当页面不回显查询结果(无回显位),可用报错注入:?id=1 AND extractvalue(1,concat(0x7e,(SELECT password FROM admin LIMIT 1))) 让数据库把数据塞进错误信息返回。若连错误都不显示,就用盲注:布尔盲注靠 AND length(database())>5 看页面真假变化一位位猜;时间盲注靠 AND IF(length(database())>5,sleep(3),0) 看响应是否延迟 3 秒。盲注慢但稳,常配合脚本自动化。

更多实战案例:堆叠与二次注入

堆叠查询 ?id=1;DROP TABLE x 在某些数据库(如 SQL Server、部分 PHP+MySQL 配置)可一次执行多条语句。二次注入更隐蔽:先注册用户名 admin'-- 存进数据库(被转义存储),之后某功能取出这个用户名拼进 SQL 时,转义符已失效,单引号重新生效导致注入。它绕过了”输入即过滤”的直觉,是很多 CTF 和真实漏洞的考点。

常见坑

  1. 只防字符串不防数字:数字型 id=$id 没引号,简单的转义对数字无效,要用整数校验或预编译。
  2. 以为 WAF 拦了就安全:WAF 可被内联注释、编码、分块绕过,根因在代码。
  3. 预编译用错prepare("... ?id=$id ...") 若把变量拼进 SQL 再预编译,等于没防;占位符必须在 SQL 模板里。
  4. 忽略 NoSQL 注入:MongoDB 等用对象查询,字符串拼接同样可被 {"$gt":""} 绕过。

进阶:修复的正确姿势

永远用参数化查询(预编译):把 SQL 写成带 ? 或命名占位符的模板,参数单独传,数据库把参数当数据绝不当代码。ORM 框架(如 MyBatis 的 #{}、Hibernate)默认安全,但用 ${} 拼接又会回到危险。最小权限:给 Web 数据库连接账号只授必要权限,禁止 FILEDROP 等危险操作,即便被注入也损失可控。

小测验

  • 问题1:联合注入先测什么?答案:用 ORDER BY 测查询的列数。
  • 问题2:没有回显位用什么注入?答案:报错注入或布尔/时间盲注。
  • 问题3:预编译为什么能防注入?答案:参数与 SQL 分离,参数被当数据不当前码。

更多实战案例:用 sqlmap 辅助与手工结合

工具 sqlmap 能自动跑注入:sqlmap -u "url?id=1" --dbs 列出数据库,--tables -D db 列表面,--dump -T users 拖数据,--os-shell 在条件满足时拿 shell。但它不是万能:有 WAF、需登录、参数在 body、有 CSRF token 时容易失败,这时要手工配合(加 --cookie--data--random-agent--tamper 绕 WAF)。理解手工原理才能用好工具、也能在工具失效时自己上。

常见坑(终补)

  1. 只防字符串不防数字:数字型没引号,要整数校验或预编译。
  2. 以为 WAF 拦了就安全:WAF 可绕过,根因在代码。
  3. 预编译用错:占位符必须进 SQL 模板,不能先拼变量再预编译。
  4. 忽略 NoSQL 注入:MongoDB 等对象查询同样可被绕过。

进阶(终补):修复正确姿势

永远用参数化查询(预编译):SQL 写成带占位符的模板,参数单独传,数据库把参数当数据不当前码。ORM 框架默认安全,但用 ${} 拼接又回到危险。最小权限:给 Web 数据库账号只授必要权限,禁止 FILE、DROP 等,即便被注入损失可控。结合输入校验与输出编码,纵深防御。

小测验(终补)

  • 问题1:联合注入先测什么?答案:用 ORDER BY 测查询列数。
  • 问题2:没有回显位用什么注入?答案:报错注入或布尔/时间盲注。
  • 问题3:预编译为什么能防注入?答案:参数与 SQL 分离,参数被当数据不当前码。

实战要点:SQL 注入防御的落地

落地三件事:一、所有 SQL 用参数化查询(预编译),占位符进模板、参数单独传;二、ORM 用安全写法(如 MyBatis 的 #{},绝不用 ${});三、最小权限——Web 数据库账号只授必要库表权限,禁 FILE、DROP 等危险操作。再叠加输入校验(类型、长度、格式)和输出编码,纵深防御。记住:参数化是根本,其他是补充。

易错提醒

别以为”转义引号”就安全(宽字节、编码绕过多);别以为 WAF 拦了就安全(可绕过);别把变量先拼进 SQL 再预编译(等于没防);别忽略 NoSQL 注入(MongoDB 对象查询同样危险)。防御要治本,参数化是唯一可靠手段。

自测

  • 联合注入先测什么?答:用 ORDER BY 测查询列数。
  • 没有回显位用什么注入?答:报错注入或布尔/时间盲注。
  • 预编译为什么能防注入?答:参数与 SQL 分离,参数被当数据不当前码。

延伸思考:注入的家族

SQL 注入只是注入家族一员。还有命令注入(拼进 shell)、LDAP 注入(拼进目录查询)、XPath 注入、NoSQL 注入(MongoDB 的 $gt 绕过)、ORM 注入、模板注入(SSTI)、Header 注入(CRLF)。它们的共同根因是”把用户输入当指令/代码的一部分拼接执行”。所以修复的通用原则是:永远分离”数据”与”指令”,用参数化/白名单/编码处理用户输入。

一句话自测

  • 注入家族的共同根因?答:把用户输入当指令/代码拼接执行。
  • 通用修复原则?答:分离数据与指令,用参数化、白名单、编码处理输入。

这一篇你该记住的

SQLi 的本质是”用户输入被当 SQL 代码执行”,因为后端把参数直接拼进语句。危害从绕过登录到拖库拿服务器。利用手法按”页面反馈”分四档:回显用 UNION、报错用 extractvalue、无回显用布尔盲注、啥都没有用时间盲注;还有 POST 注入、宽字节注入等变体。唯一根治是参数化查询,辅以输入校验、最小权限、关错误回显。

SQLi 是”数据变代码”。下一篇 XSS 是另一种”数据变代码”——只不过这次代码跑在受害者浏览器里,能偷 cookie、劫持会话,危害同样巨大。