约 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 column | ORDER BY 后面拼了用户输入 | 用白名单映射表名/列名 |
| 搜 % 时结果条数暴涨 | LIKE 通配符未转义 | 转义 % 与 _,并固定加 ESCAPE 子句 |
| 接口只在某个字符集下乱码后失效 | 多字节字符把引号「拆」开了 | 统一 UTF-8;连接层禁用多字节注入 trick |
| 单元测试全绿,上线被扫出漏洞 | 测试数据太干净 | 测试数据必须包含引号、注释符、超长值 |
所谓「预编译」不是把 SQL 提前算好,而是让数据库分两步处理:第一遍解析固定的语句结构,第二遍才把参数值填进去。值到达解析器之前就已经是数据,永远没有机会被当作语法读取。这也是为什么只做「转义」不管用:转义的正确性取决于你枚举得全不全,而参数化把「枚举」这件事从根上去掉了。
参数化只覆盖「值」。下面这些位置语法上就要求放标识符或片段,驱动 API 只能拼字符串,这是必须人工把关的地方。
const sql = "SELECT * FROM orders ORDER BY " + req.query.sort + " " + req.query.dir; // ?sort=id,(SELECT password FROM users LIMIT 1) → 报错信息直接带出密码
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]);这五条不需要工具就能做,但第四、第五条建议真的动手:脏数据比推理更能暴露问题。
发现注入漏洞时,清理输入是第一步,不是最后一步。攻击者可能已经通过同一个洞读过数据、写过后门、种下二阶注入(存储型数据下次被别的查询拼接时再触发)。处置顺序:堵住入口(参数化 + 最小权限账号)→ 审日志找异常查询(大范围 SELECT、information_schema 探测、异常时间的批量读)→ 检查写入侧有没有被改过的数据 → 轮换可能泄露的凭据。把漏洞和凭据分开处理,只修代码不换密钥等于没修。
用参数化查询(预编译语句),让值只能作为值传输。其余做法——转义、加过滤器、黑名单关键字——都依赖「枚举得全不全」,漏一个就失守。补充两条底线:数据库账号给最小权限(应用账号不该有 DROP / GRANT 权限),错误信息不要把 SQL 片段返回给客户端。
不能。标准 SQL 的占位符只代表值,而 ORDER BY 后面要的是列名或表达式,这是标识符,语法上就排除了绑定。正确做法是白名单映射:用 {"createdAt": "created_at"} 这类字典把外部传入的 sort 值换成固定的列名,取不到就回退默认排序。
分两层。通配符层面:把 % 和 _ 转义后加 ESCAPE 子句,否则用户搜一个 % 就会匹配全表。注入层面:值仍然走参数绑定。两者缺一不可——只转义不绑定,攻击者可以从别处绕进来;只绑定不转义,% 本身就是一个有效的放大器。
因为测试数据太干净。测试用例里的参数是 "alice" 这种不含引号的值,拼接出来的 SQL 恰好合法,测试自然通过。补一组 hostile 集成测试:参数里塞 a' OR '1'='1、admin'--、1 UNION SELECT null,null,和超长值各跑一遍,能过才算覆盖。