← 返回博客首页

SQL 注入防护完全指南:原理、攻击手法与纵深防御

为什么 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 速查表;应用内其他安全环节(口令哈希、加密算法选型)参考哈希与加密算法速查表

广告

常见问题

为什么参数化查询能防止 SQL 注入?

参数化查询(Prepared Statement)把 SQL 语句的结构和数据彻底分开:语句模板在服务端预先编译,用户输入只作为参数绑定传入,数据库引擎按参数值而非可执行代码处理它。因此即使用户输入 `' OR '1'='1`,它也只是字符串字面量,永远无法改变语句结构。这是防御 SQL 注入的第一且最有效的手段,几乎对所有主流数据库都成立。

用了 ORM 就一定安全吗?

不一定。ORM 的查询构建器在『链式调用 + 类型安全 API』的正确用法下是安全的,但三个场景会破防:一是拼接原生 SQL(`raw`/`query` 方法)时直接把用户输入拼进字符串;二是把用户可控的表名/列名/排序字段拼进 ORDER BY 或 LIMIT——这些位置不接受占位符;三是盲目信任 ORM 的过滤而跳过输入校验。ORM 只是工具,参数化原则仍然要自己守住。

黑名单过滤(过滤关键字和引号)为什么不够?

因为注入手法会不断变形:编码绕过(URL 编码、双重编码、Unicode 变体)、大小写与注释混写(`/**/`、`#`、`--`)、数据库方言差异(MySQL 的 `/*!...*/`)、以及不依赖关键字的注入(数字型注入、堆叠查询)。维护一份永远追不上攻击者变形的黑名单,本质是‘用正则对抗图灵完备的语法解析’,必输。防御应当转向白名单:只接受符合预期格式的值,其余一律拒绝。

发现线上系统疑似被 SQL 注入攻击,第一时间该做什么?

按顺序处理:① 立即确认是否有数据被读取或篡改(比对备份、检查关键表记录与审计日志);② 若确认被注入,先隔离(下线或切断数据库访问),保留现场日志与请求样本用于取证,不要急着清日志;③ 修复代码——所有用户输入一律改参数化,涉密字段轮换密钥与口令;④ 通知受影响用户并评估合规披露义务;⑤ 复盘:数据库账号是否用了最小权限、日志是否保留足够长、监控告警为何没触发。先止血、再补根、最后复盘,顺序不能乱。

← 返回博客首页