SQL 注入防护是每一个后端与数据库工程师都绕不开的必修课。所谓 SQL 注入,就是攻击者把恶意 SQL 片段拼进原本正常的查询里,改变语句语义、越权读取甚至篡改数据。真正做到数据库安全,核心只有一句话:永远用参数化查询(预编译),再叠加输入校验、最小权限与 Web 层过滤的纵深防御。本文用可落地的代码,带你把这条防线从原理到上线全部打通。
一、SQL 注入到底是怎么发生的
漏洞的根因几乎都是“字符串拼接”。当用户输入被直接拼进 SQL 文本,攻击者只需输入一个单引号加 OR 条件,就能让查询逻辑彻底失控。
// 危险写法:用户名密码直接拼接到 SQL
String name = request.getParameter("name");
String pwd = request.getParameter("pwd");
String sql = "SELECT * FROM users WHERE username='" + name + "' AND password='" + pwd + "'";
// 攻击者输入 name = admin' --
// 拼接后变成:
// SELECT * FROM users WHERE username='admin' -- ' AND password='xxx'
// “--” 把后面的条件注释掉,直接绕过密码校验
同样的陷阱也存在于 Python、Node、Go 等几乎所有语言。关键结论:只要 SQL 是“动态拼出来”的,就不安全。
二、第一道防线:预编译与参数化查询
预编译(Prepared Statement)把“SQL 模板”和“参数值”分两次发送给数据库:数据库先编译带占位符的模板,参数随后以纯数据形式传入,永远不会改变 SQL 的语法结构。这是 SQL 注入防护最根本、最有效的手段。
// Java:使用 PreparedStatement(占位符 ?)
String sql = "SELECT id, username FROM users WHERE username = ? AND password = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, name);
ps.setString(2, pwd);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) { /* 安全读取 */ }
}
}
# Python:DB-API 风格参数化(使用 %s 占位)
sql = "SELECT id, username FROM users WHERE username = %s AND password = %s"
cur.execute(sql, (name, pwd))
# SQLAlchemy 推荐写法
from sqlalchemy import text
result = conn.execute(
text("SELECT id FROM users WHERE username = :u AND password = :p"),
{"u": name, "p": pwd}
)
各语言都内置了参数化接口,务必使用原生占位符,而不是自己拼接后再“转义”。
| 语言 / 框架 | 安全写法 | 危险写法 |
|---|---|---|
| Java JDBC | PreparedStatement + ? | Statement + 字符串拼接 |
| MyBatis | #{param} | ${param} |
| Python | execute(sql, params) | execute(sql % vars) |
| Go | db.Query(sql, args…) | fmt.Sprintf 拼接 |
| Node | ? 占位 / 参数数组 | 模板字符串拼接 |
三、ORM 就一定安全吗
ORM 能挡住大部分注入,但“原生 SQL”和“动态表名/字段名”依旧是重灾区。以 MyBatis 为例,#{} 会参数化,而 ${} 是纯文本替换,常用于排序字段、动态表名,一旦拼接外部输入就立刻破防。
<!-- 危险:${} 直接替换,orderBy 来自前端即注入 -->
<select id="list">
SELECT * FROM orders ORDER BY ${orderBy}
</select>
<!-- 安全:白名单约束排序字段 -->
<choose>
<when test="orderBy == 'create_time' or orderBy == 'amount'">
ORDER BY ${orderBy}
</when>
<otherwise>ORDER BY create_time</otherwise>
</choose>
记住:凡是 ORDER BY、GROUP BY、表名、列名这类不能用占位符的位置,必须用白名单枚举,绝不能接收任意字符串。
四、输入校验与白名单
参数化是底线,输入校验是加分项。对类型、长度、格式做严格约束,能在注入到达数据库之前就拦掉大量恶意请求。
import re
def safe_username(value: str) -> str:
# 只允许字母数字下划线,长度 3-32
if not re.fullmatch(r"[A-Za-z0-9_]{3,32}", value or ""):
raise ValueError("非法用户名")
return value
# 枚举型参数用集合白名单
ALLOWED_SORT = {"create_time", "amount", "id"}
sort = payload.get("sort", "create_time")
if sort not in ALLOWED_SORT:
sort = "create_time"
五、最小权限与纵深防御
即便应用层有疏漏,数据库自身也要能兜底。给业务账号只授予“刚好够用”的权限,禁止 DROP、FILE、SUPER 等高危操作,能极大缩小注入的破坏面。
-- 为 Web 业务创建只读/受限账号,而非使用 root
CREATE USER 'app_ro'@'10.0.%' IDENTIFIED BY 'strong_password';
GRANT SELECT, INSERT, UPDATE, DELETE
ON shop.orders TO 'app_ro'@'10.0.%';
-- 严禁授予 DROP, ALTER, FILE, GRANT OPTION
FLUSH PRIVILEGES;
| 防御层 | 具体手段 | 作用 |
|---|---|---|
| 代码层 | 预编译 / 参数化 | 根治注入 |
| 校验层 | 白名单 / 正则 / 类型约束 | 提前拦截 |
| 数据层 | 最小权限账号 | 缩小破坏面 |
| Web 层 | WAF / 限流 / 过滤 | 外部兜底 |
| 运维层 | 定期安全扫描 | 持续发现 |
六、Web 层与 WAF 协同
在 Nginx 反向代理 层叠加限流与基础过滤,可以在恶意请求打到应用前先降速、封禁高频探测。WAF 不能替代参数化,但能拦掉明显的扫描和自动化攻击,属于纵深防御的重要一环。
# 限制单 IP 请求速率,缓解盲注扫描
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://backend;
}
七、如何自检:用 SQLMap 思路做安全测试
防护到位与否,要用攻击者的视角验证。仅限对自己拥有授权的系统进行自检:在测试环境用 sqlmap 跑一遍关键接口,确认无法被注入。切勿对未授权系统扫描。同时可参考 数据脱敏实战 的思路,把敏感字段在存储与展示层双重保护,即便被拖库也不泄密。
# 仅对自有测试环境授权扫描
sqlmap -u "https://test.example.com/api/user?id=1" \
--batch --level=2 --risk=1
# 正向验证:确认参数化接口对任意输入返回一致结果
curl "https://test.example.com/api/user?id=1' OR '1'='1"
# 期望:被参数化为普通字符串,不会返回全部数据
八、落地清单
把下面这张清单贴进 Code Review 规范,新接口上线前逐条核对:所有查询使用参数化占位符;动态表名/排序字段必须白名单;数据库账号最小权限;Web 层叠加限流与 WAF;CI 中接入安全扫描。配合 数据库设计规范 与 MySQL 索引底层原理 一起落地,既能防住注入,也能稳住性能。扎实的 Docker 化部署与 GitHub Actions 流水线,则让这套安全规范可复现、可审计。
| 检查项 | 通过标准 |
|---|---|
| 查询写法 | 全部参数化,无字符串拼接 |
| 动态片段 | 表名/排序走白名单 |
| 数据库账号 | 业务账号无 DROP/FILE 权限 |
| Web 入口 | 限流 + WAF 已启用 |
| 持续验证 | 测试环境定期安全扫描通过 |
九、常见误区:转义函数不是银弹
很多团队把 mysql_real_escape_string 之类的转义函数当成万能解药,这是危险的。转义只对“字符串上下文”有效,遇到整数拼接、LIKE 通配符、或字符集不一致时依然可能失效。转义是最后一道补充,绝不是替代参数化的借口。
// 误区:用转义函数包裹后再拼接
String sql = "SELECT * FROM t WHERE id = " + safeEscape(id);
// 若 id 来自 "1 OR 1=1",转义对数字上下文无效,仍被注入
// 正解:整数也用参数化,或先转成强类型再加占位符
int idVal = Integer.parseInt(id); // 类型校验先行
String sql2 = "SELECT * FROM t WHERE id = ?";
ps.setInt(1, idVal);
| 误区 | 真相 |
|---|---|
| 转义函数能防一切注入 | 只对字符串上下文有效,数字/标识符无效 |
| ORM 自动防注入 | 原生 SQL、${} 动态片段仍会破防 |
| 前端校验就够了 | 前端可被绕过,后端必须兜底 |
| WAF 能替代参数化 | WAF 只是外层缓解,不是根因修复 |
十、与数据库规范协同落地
SQL 注入防护要融入整体数据库工程。参考 MySQL 深度调优 中的账号与连接治理思路,把“最小权限账号 + 参数化模板”写进团队的数据库使用规范;再结合 数据库设计规范实战 的命名与分层约定,让安全成为默认选项而非事后补丁。定期用压测(参见 数据库压测实战)验证防护逻辑没有拖慢核心查询,才能既安全又高性能。
SQL 注入防护不是某一个工具的事,而是一条从代码、数据到运维的链。把预编译当成肌肉记忆,再用校验、权限、WAF 层层加固,你的数据库才能真正稳得住。




