SQL 注入怎么防:参数化查询、ORM 边界与五个仍然高危的写法

根治手段只有参数化查询这一条。讲清单引号为什么会闭合、WHERE 后拼接与 ORDER BY 的本质区别、LIKE 通配符和数字类型的坑,以及 ORM 里哪些接口其实拼了字符串——附「症状 → 成因 → 修法」对照表和上线前自查清单。

约 7 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证

一个单引号就进去了:注入到底发生在哪一步

登录框输入 a' OR '1'='1 能绕过鉴权,不是因为数据库「读不懂」你的意思,而是因为你把不可信输入当成了 SQL 文本的一部分去拼。参数值一旦能改变语句的结构,注入就成立了:多出来的引号提前闭合了字符串字面量,后面的 OR '1'='1 就变成了 WHERE 条件的一部分。分清「值」和「语法」是全部防御的起点——让值只能是值,语法只能由你自己写。

✗ 字符串拼接:输入直接改变语句结构
// 输入: a' OR '1'='1  --
query("SELECT * FROM users WHERE email = '" + email + "' AND active = 1");

// 数据库实际收到:
SELECT * FROM users WHERE email = 'a' OR '1'='1' AND active = 1
//                    ↑ 字符串在这里就结束了      ↑ 这段变成了 WHERE 条件
✓ 参数化:输入永远是值
stmt = conn.prepare(
  "SELECT id, email FROM users WHERE email = ? AND active = ?"
);
stmt.execute([email, 1]);

// 数据库实际收到:语句文本与 email 的值分开传输
// email 无论等于什么,都只是那一个槽位里的一个字符串

症状 → 真正的成因 → 修法:一张对照表

排查注入问题不要从头读代码,从异常形态反查更快。下面这些症状每一条都对应一类固定的拼接错误。

症状真正的成因修法
输入带引号时抛 SQL 语法错误字符串被提前闭合改用参数绑定;数字字段也按字符串传
传 a' OR 1=1 -- 返回全部行WHERE 条件恒真WHERE 后的值一律走占位符
报错里出现 UNION SELECT ... 后续表名攻击者在猜列数、枚举库给数据库账号最小权限,别用 root
排序参数传 column 报 unknown columnORDER BY 后面拼了用户输入用白名单映射表名/列名
搜 % 时结果条数暴涨LIKE 通配符未转义转义 % 与 _,并固定加 ESCAPE 子句
接口只在某个字符集下乱码后失效多字节字符把引号「拆」开了统一 UTF-8;连接层禁用多字节注入 trick
单元测试全绿,上线被扫出漏洞测试数据太干净测试数据必须包含引号、注释符、超长值

参数化为什么有效:语句和值走两条路

所谓「预编译」不是把 SQL 提前算好,而是让数据库分两步处理:第一遍解析固定的语句结构,第二遍才把参数值填进去。值到达解析器之前就已经是数据,永远没有机会被当作语法读取。这也是为什么只做「转义」不管用:转义的正确性取决于你枚举得全不全,而参数化把「枚举」这件事从根上去掉了。

五个仍然高危的写法:参数化管不到的地方

参数化只覆盖「值」。下面这些位置语法上就要求放标识符或片段,驱动 API 只能拼字符串,这是必须人工把关的地方。

  1. 表名与列名:MySQL 没有「参数化的列名」。ORDER BY / GROUP BY / 字段列表一律走白名单字典映射,输入只当 key 用。
  2. IN 列表:不要自己拼 a,b,c,用驱动提供的数组绑定(Python 的 IN (%s) 加 seq,Node 的 IN (?) 加数组),或先生成一段固定长度的占位符。
  3. LIKE 的通配符:绑定的是值,但 % 和 _ 仍是 LIKE 自己的语法,必须先转义再绑定,并显式写 ESCAPE。
  4. 数字字段:把 id 拼进 WHERE id = ... 这种写法,即使 id 看起来是数字也要绑定。类型来自客户端时它就是个字符串。
  5. 动态排序方向:ASC / DESC 不能参数化,只能 if/else 二选一;传其他值时回退到默认顺序,不要回显原值。
✗ 排序字段直接来自请求参数
const sql = "SELECT * FROM orders ORDER BY " + req.query.sort + " " + req.query.dir;
// ?sort=id,(SELECT password FROM users LIMIT 1)  → 报错信息直接带出密码
✓ 白名单映射 + IN 数组绑定 + LIKE 转义
const SORTABLE = { createdAt: "created_at", total: "amount_cents" };
const column = SORTABLE[req.query.sort] ?? "created_at";
const dir = req.query.dir === "asc" ? "ASC" : "DESC";

const [rows] = await db.query(
  `SELECT id, total FROM orders WHERE user_id = ? AND id IN (?) ORDER BY ${column} ${dir}`,
  [userId, ids]                     // 数组驱动自动展开为 ?,?,?
);

// LIKE:先转义 % 与 _,值再绑定
const kw = "%" + term.replace(/[\\%_]/g, (m) => "\\" + m) + "%";
await db.query("SELECT id FROM post WHERE title LIKE ? ESCAPE '\\'", [kw]);

ORM 也不是免罪符:哪些接口会拼字符串

  • 原生片段类:whereRaw、rawQuery、queryRaw、extra、RawSQL、UnsafeRaw——这些就是拼接入口,白名单规则照旧适用。
  • 排序类:orderBy 接受字符串字段名时,必须先过白名单;ORM 不会替你校验列名是否存在。
  • 批量写入类:批量 insert 的 values 列表要么走参数批量绑定,要么自己拼——先确认它真的参数化了。
  • 迁移脚本类:迁移里的 SQL 常被开发者信任,但生产表名可能来自配置或分表规则,一样要参数化或白名单。
  • 存储过程类:动态 SQL 拼在过程体内部,逃过了应用的检查,评审时容易整段漏掉。

上线前的五条自查:按这个顺序过一遍

这五条不需要工具就能做,但第四、第五条建议真的动手:脏数据比推理更能暴露问题。

  1. 全仓搜拼接痕迹:把 " + 、${、format(、%s 拼进 SQL 的位置全找出来,逐个判断这个位置是值还是标识符。
  2. 确认驱动用的是真正的绑定:某些 ORM 的 where 传字符串时仍会内联,尤其是嵌套条件与原生片段。
  3. 给每个外部入口配一条恶意输入的集成测试:带单引号、带 --、带 /*、带 UNION SELECT,长度超长,四条都过才算覆盖。
  4. 把脏数据灌进测试库:用 <a class="faq-link" href="/tools/mock-data/">随机测试数据生成</a>造一批姓名、备注、备注里塞引号和逗号的行,再跑一遍全部查询。
  5. 看异常信息是否泄露结构:故意触发一次语法错误,确认对外响应只有一句「请求无效」,SQL 片段和表名不进错误消息、不进日志对外通道。

已经被注入了怎么办:按攻击者已经拿到的东西来处置

发现注入漏洞时,清理输入是第一步,不是最后一步。攻击者可能已经通过同一个洞读过数据、写过后门、种下二阶注入(存储型数据下次被别的查询拼接时再触发)。处置顺序:堵住入口(参数化 + 最小权限账号)→ 审日志找异常查询(大范围 SELECT、information_schema 探测、异常时间的批量读)→ 检查写入侧有没有被改过的数据 → 轮换可能泄露的凭据。把漏洞和凭据分开处理,只修代码不换密钥等于没修。

常见问题

SQL 注入怎么防最彻底?

用参数化查询(预编译语句),让值只能作为值传输。其余做法——转义、加过滤器、黑名单关键字——都依赖「枚举得全不全」,漏一个就失守。补充两条底线:数据库账号给最小权限(应用账号不该有 DROP / GRANT 权限),错误信息不要把 SQL 片段返回给客户端。

ORDER BY 后面能不能用占位符?

不能。标准 SQL 的占位符只代表值,而 ORDER BY 后面要的是列名或表达式,这是标识符,语法上就排除了绑定。正确做法是白名单映射:用 {"createdAt": "created_at"} 这类字典把外部传入的 sort 值换成固定的列名,取不到就回退默认排序。

LIKE 模糊查询怎么防注入?

分两层。通配符层面:把 % 和 _ 转义后加 ESCAPE 子句,否则用户搜一个 % 就会匹配全表。注入层面:值仍然走参数绑定。两者缺一不可——只转义不绑定,攻击者可以从别处绕进来;只绑定不转义,% 本身就是一个有效的放大器。

单元测试都过了,为什么扫描器还能扫出注入?

因为测试数据太干净。测试用例里的参数是 "alice" 这种不含引号的值,拼接出来的 SQL 恰好合法,测试自然通过。补一组 hostile 集成测试:参数里塞 a' OR '1'='1、admin'--、1 UNION SELECT null,null,和超长值各跑一遍,能过才算覆盖。

📂

松开以自动格式化

支持 .json / .sql / .csv / .html / .css / .xml / .yaml 文件