为什么 SQL 注入是头号 Web 风险?
SQL 注入(SQL Injection)连续多年排在 OWASP Top 10 的榜首,原因不是它多难,而是它简单且致命:攻击者只需要在输入框里输入一段精心构造的字符串,就可能拿到整张用户表的明文、绕过登录、甚至 DROP TABLE。
它之所以能发生,根源只有一个:把用户输入当成 SQL 代码执行了。所有防御手段,本质都是在"结构"与"数据"之间建立不可逾越的边界。
本文从攻击原理讲起——只有先看懂攻击者怎么想,才能理解为什么某些"防御"是自欺欺人——然后逐层给出纵深防御手段与一份可直接落地的检查清单。
第一部分:攻击原理
字符串拼接:注入的温床
几乎所有注入都始于同一种错误写法——字符串拼接查询:
// 危险:用户输入被直接拼进 SQL
$sql = "SELECT * FROM users WHERE username = '" . $_POST['username'] . "' AND password = '" . $_POST['password'] . "'";
如果攻击者在用户名里输入:
' OR '1'='1' --
最终执行的 SQL 变成:
SELECT * FROM users WHERE username = '' OR '1'='1' --' AND password = '...'
OR '1'='1' 恒为真,-- 注释掉后面所有条件——无需密码即可登录。
五种常见攻击手法
| 手法 | 原理 | 典型 Payload 形态 |
|---|---|---|
| 联合查询注入 | 用 UNION SELECT 把查询结果拼接到目标列,直接读取其他表 |
' UNION SELECT username,password FROM users -- |
| 报错注入 | 触发数据库报错,让错误信息把数据带出来 | ' AND extractvalue(1,concat(0x7e,(SELECT password FROM users LIMIT 1))) -- |
| 布尔盲注 | 页面表现只有"真/假"两种,逐字符爆破数据 | ' AND (SELECT ASCII(SUBSTRING(password,1,1)))>100 -- |
| 时间盲注 | 用 SLEEP() / WAITFOR DELAY 制造可观测延迟 |
' AND IF(1=1,SLEEP(5),0) -- |
| 二阶注入 | 恶意数据先入库(此时无害),之后被另一处拼接 SQL 使用 | 注册用户名 foo'; DROP TABLE logs;--,管理后台查询时触发 |
共同点:全部依赖"输入被当作 SQL 执行"。只要断了这条链,五种手法一起失效。
容易被忽略的注入点
- ORDER BY / LIMIT 参数:
ORDER BY ${sort}不接受占位符,是经典盲区,必须白名单校验 - 表名 / 列名:动态表名同样无法参数化,只能白名单映射
- LIKE 与正则:
LIKE '%${keyword}%'里的%、_通配符会被误解义 - 数字型字段:
id=${id}不加引号时,注入面更直接 - 搜索/排序/分页:几乎所有"可排序列表页"都可能是入口
第二部分:纵深防御(按优先级排列)
第 1 层:参数化查询(Prepared Statement)——必做
把 SQL 结构交给数据库预编译,输入只作为参数绑定:
// Java JDBC —— 占位符 ? 由驱动处理
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM users WHERE username = ? AND password = ?");
ps.setString(1, username);
ps.setString(2, password);
ResultSet rs = ps.executeQuery();
# Python sqlite3 / psycopg2 —— ? 或 %s 占位符
cur.execute(
"SELECT * FROM users WHERE username = %s AND password = %s",
(username, password),
)
// Node.js mysql2 —— ? 占位符
await conn.execute(
"SELECT * FROM users WHERE username = ? AND password = ?",
[username, password]
);
// PHP PDO —— 同样是占位符
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ? AND password = ?");
$stmt->execute([$username, $password]);
为什么有效:数据库把模板预编译后,参数值只作为数据流进入,与语句结构无关。输入 ' OR '1'='1 也只是一段普通字符串。
第 2 层:ORM 与查询构造器的正确用法——绝大多数项目的主战场
现代项目通常用 ORM,但三个场景会悄悄破防:
# 危险:raw SQL 拼接 —— ORM 的逃生门
User.objects.raw(f"SELECT * FROM users WHERE name = '{name}'")
# 危险:排序字段拼接 —— 占位符救不了的位置
User.objects.order_by(f"{sort_field}") # sort_field 必须白名单
# 安全:查询构造器 —— 参数化由框架保证
User.objects.filter(username=username, password=password)
经验法则:查询构造器能表达的需求,一律用它;必须写原生 SQL 时,仍然用占位符;排序/表名等无法参数化的位置,用白名单映射表。
第 3 层:输入校验(白名单优先)——最后一道防线,但不背主责
校验只负责"数据是否符合预期格式",不能作为注入防御的唯一手段(黑名单必输,见 FAQ):
// 白名单校验:id 必须是 1-8 位数字
if (!/^\d{1,8}$/.test(req.query.id)) return 400;
// 枚举白名单:sort 只能取固定值
const SORTS = ['created_at', 'updated_at', 'title'];
const sort = SORTS.includes(req.query.sort) ? req.query.sort : 'created_at';
第 4 层:最小权限——把"失守后的损失"压到最低
即使前几层全部失守,数据库账号的权限决定了损失上限:
| 场景 | 应该给 | 常见错误 |
|---|---|---|
| Web 应用连接库 | 仅 SELECT/INSERT/UPDATE/DELETE,限定目标库 |
给了 root / DBA |
| 备份账号 | 仅 SELECT |
给了写权限 |
| 迁移/运维 | 独立高权限账号,用完即弃 | 与应用共用账号 |
关键实践:应用账号永远不要有 DROP/TRUNCATE/GRANT 权限;线上禁止用 root 连库。这样即使被注入,攻击者能读的也只是一个库的一张表,而不是整个实例。
第 5 层:纵深与兜底
- WAF(如 ModSecurity / Cloudflare WAF):拦截已知攻击模式的流量,但对变体失效,只能作为兜底不能作为主防
- 数据库防火墙 / 查询审计:记录所有 SQL,出事后可追溯
- 错误信息脱敏:生产环境关闭详细 SQL 错误输出,防止报错注入和信息泄露
- 连接池最小化:每个应用独立账号,不跨应用复用
第三部分:完整修复示例
一个典型的"可搜索用户列表"页面的前后对比:
// ❌ 修复前:搜索词直接拼接
$sql = "SELECT * FROM users WHERE name LIKE '%" . $_GET['q'] . "%' ORDER BY " . $_GET['sort'];
// ✅ 修复后:参数化 + 排序白名单
$sorts = ['id' => 'id', 'name' => 'name', 'created' => 'created_at'];
$sort = $sorts[$_GET['sort']] ?? 'id';
$stmt = $pdo->prepare("SELECT * FROM users WHERE name LIKE ? ORDER BY {$sort} LIMIT 50");
$stmt->execute(['%' . $_GET['q'] . '%']);
常见误区
| 误区 | 真相 |
|---|---|
| "加了引号转义就安全" | addslashes/mysql_real_escape_string 只能挡简单引号闭合,编码绕过与无引号注入照样打穿 |
| "过滤了关键字就安全" | 黑名单永远追不上变形(见 FAQ) |
| "内网系统不用防" | 内网同样会被横向移动攻击打到 |
| "ORM 自动帮我防了" | 原生 SQL / 排序 / 表名场景仍然裸奔 |
| "WAF 挡得住" | WAF 是兜底不是主防,绕过研究从未停止 |
生产环境检查清单
部署前逐条过一遍:
- [ ] 代码中不存在"用户输入 + SQL 字符串拼接"(
grep检查$_GET/req.query/request.form与 SQL 关键字同现的位置) - [ ] 所有查询使用参数化或 ORM 查询构造器
- [ ] 排序、分页、表名等无法参数化的位置全部白名单映射
- [ ] 应用数据库账号只有必要的 DML 权限,无 DDL 权限,非 root
- [ ] 生产环境关闭详细 SQL 报错输出
- [ ] 日志保留 ≥ 30 天,记录关键操作(登录、导入、导出)
- [ ] 密码字段使用 bcrypt/Argon2 哈希存储(绝不明文)——参考哈希与加密算法速查表
- [ ] 上线前跑一次自动化扫描(sqlmap 自测或商业扫描器)
小结
SQL 注入的本质是"把数据当代码执行"。防御的第一性原理只有一条:永远让结构走编译、让数据走参数。参数化查询是必须项而非加分项;ORM 要在正确的用法下才安全;白名单校验与最小权限决定失守后的损失上限。把这几层叠起来,SQL 注入这条 OWASP 头号风险就真正关上了门。
写 SQL 时可用 SQL 格式化/美化工具 快速检查语句结构,SQL 语法细节可查 SQL 速查表;应用内其他安全环节(口令哈希、加密算法选型)参考哈希与加密算法速查表。