SQL 注入防御
问题
什么是 SQL 注入?有哪些攻击方式?如何有效防御?
答案
一、SQL 注入原理
SQL 注入是通过在用户输入中嵌入恶意 SQL 代码,使应用执行非预期的 SQL 语句。
用户输入: ' OR 1=1 --
拼接后的 SQL: SELECT * FROM users WHERE name = '' OR 1=1 --' AND password = '...'
^^^^^^^^^^^^^^^^
永真条件,绕过认证
二、常见攻击类型
1. 经典注入(Union-Based)
-- 原始 SQL
SELECT name, email FROM users WHERE id = {user_input}
-- 攻击输入: 1 UNION SELECT username, password FROM admin_users --
-- 实际执行:
SELECT name, email FROM users WHERE id = 1
UNION SELECT username, password FROM admin_users --
-- 效果:查出 admin_users 表中的用户名和密码
2. 盲注(Blind Injection)
页面不直接返回 SQL 结果,但可以通过页面表现推断信息:
-- 布尔盲注:通过 true/false 逐个字符猜解
' AND SUBSTRING(database(), 1, 1) = 'a' --
' AND SUBSTRING(database(), 1, 1) = 'b' --
-- 时间盲注:通过响应时间判断
' AND IF(SUBSTRING(database(), 1, 1) = 'a', SLEEP(5), 0) --
3. 堆叠注入(Stacked Queries)
-- 攻击输入: 1; DROP TABLE users; --
-- 实际执行:
SELECT * FROM users WHERE id = 1; DROP TABLE users; --
4. 二次注入
数据第一次存储时被安全处理,但后续在其他 SQL 中被不安全地拼接使用:
Step 1: 注册用户名 admin'--
Step 2: 存储到数据库(正常存储,因为注册时用了参数化查询)
Step 3: 修改密码时:
UPDATE users SET password = 'new_pass' WHERE name = 'admin'--'
实际效果:修改了 admin 的密码
三、防御策略
核心防御:参数化查询(Prepared Statement)
所有 SQL 查询都必须使用参数化查询,这是最重要、最有效的防御手段。
// ❌ 字符串拼接(危险!)
const sql = `SELECT * FROM users WHERE name = '${username}'`;
// ✅ 参数化查询(安全)
// Node.js mysql2
const [rows] = await connection.execute(
'SELECT * FROM users WHERE name = ? AND email = ?',
[username, email]
);
// PostgreSQL pg
const result = await pool.query(
'SELECT * FROM users WHERE name = $1 AND email = $2',
[username, email]
);
为什么参数化查询安全?
- SQL 语句和参数分开发送给数据库
- 数据库先编译 SQL 语句(确定结构),再绑定参数
- 参数永远是数据,不会被解释为 SQL 代码
ORM 防注入
// Prisma(天然安全,API 不支持 SQL 拼接)
const users = await prisma.user.findMany({
where: { name: username } // 自动参数化
});
// TypeORM(Query Builder 自动参数化)
const users = await userRepo
.createQueryBuilder('user')
.where('user.name = :name', { name: username }) // 参数化
.getMany();
// Prisma 原始查询也安全(tagged template 自动参数化)
const users = await prisma.$queryRaw`
SELECT * FROM users WHERE name = ${username}
`;
危险操作
// ❌ 即使使用 ORM,拼接原始 SQL 仍然危险
const sql = `SELECT * FROM users WHERE name = '${username}'`;
await prisma.$queryRawUnsafe(sql); // 不安全!
// ❌ 动态表名/列名不能用参数化
const table = req.query.table;
const sql = `SELECT * FROM ${table}`; // 不安全!
// ✅ 使用白名单校验
const allowedTables = ['users', 'posts', 'tags'];
if (!allowedTables.includes(table)) throw new Error('Invalid table');
输入验证(纵深防御)
// 类型验证
const id = parseInt(req.params.id, 10);
if (isNaN(id)) throw new BadRequestException('Invalid ID');
// 格式验证(使用 Zod)
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100).regex(/^[a-zA-Z0-9\u4e00-\u9fa5]+$/),
age: z.number().int().min(0).max(150),
});
const data = schema.parse(req.body); // 校验失败会抛 ZodError
最小权限
-- 应用使用的数据库账号只需要必要权限
CREATE USER 'app_user'@'%' IDENTIFIED BY 'strong_password';
GRANT SELECT, INSERT, UPDATE, DELETE ON mydb.* TO 'app_user'@'%';
-- 不授予 DROP, ALTER, CREATE, GRANT 等危险权限
-- 只读账号
CREATE USER 'readonly'@'%' IDENTIFIED BY 'strong_password';
GRANT SELECT ON mydb.* TO 'readonly'@'%';
WAF(Web 应用防火墙)
请求 → WAF(检测注入模式)→ 应用 → 数据库
↓ 拦截
恶意请求返回 403
WAF 是最外层的防护,但不能替代参数化查询(WAF 可被绕过)。
四、防御层次总结
| 层次 | 措施 | 有效性 |
|---|---|---|
| 必须 | 参数化查询 | ⭐⭐⭐⭐⭐ |
| 必须 | 最小权限原则 | ⭐⭐⭐⭐ |
| 推荐 | 输入验证 | ⭐⭐⭐ |
| 推荐 | ORM 框架 | ⭐⭐⭐⭐ |
| 补充 | WAF | ⭐⭐⭐ |
| 补充 | 错误信息隐藏 | ⭐⭐ |
常见面试问题
Q1: 参数化查询为什么能防止 SQL 注入?
答案:
参数化查询将 SQL 语句的结构和数据分离:
- 数据库引擎先对 SQL 模板进行预编译,确定语法结构
- 然后将参数作为纯数据绑定到已编译的语句上
- 参数中的特殊字符(
'、--、;)被视为数据的一部分,不会改变 SQL 结构
模板编译: SELECT * FROM users WHERE name = ?
└── 此处只能是值
参数绑定: ? = "' OR 1=1 --"
实际效果: WHERE name = "' OR 1=1 --" ← 整个字符串作为值比较
Q2: 使用 ORM 就一定安全吗?
答案:
不一定。ORM 的标准 API 是安全的,但以下场景仍可能注入:
- 拼接原始 SQL:
$queryRawUnsafe(userInput) - 动态列名/表名:
orderBy(req.query.sortField) - LIKE 通配符:
LIKE '%${input}%'需要转义%和_ - ORM 的某些高级 API:如 TypeORM 的
where接受字符串拼接
Q3: 如何检测项目中是否存在 SQL 注入风险?
答案:
- 代码审查:搜索字符串拼接 SQL 的模式(
+ "SELECT", 模板字符串包含 SQL) - SAST 工具:SonarQube、Semgrep 等静态分析工具可检测注入风险
- 渗透测试:使用 sqlmap 等工具进行自动化测试
- ESLint 规则:自定义规则禁止危险的 SQL 拼接方式