SQL 注入(SQL Injection)常年位居 OWASP Top 10 前列,是后端开发中最危险、也最容易被忽视的漏洞之一。它的本质很简单:应用程序将用户输入直接拼接到 SQL 语句中,导致攻击者可以改变原有 SQL 的语义,从而窃取、篡改甚至删除数据库中的数据。
很多开发者认为“我用了 ORM 就安全了”,或者“我做了转义就没问题”,但实际项目中,SQL 注入依然频繁出现在订单查询、后台搜索、报表导出等场景。本文从攻击原理出发,系统梳理后端开发者必须掌握的几种 SQL 注入防御写法,并给出可落地的代码示例。
一、SQL 注入为什么依然常见
SQL 注入的根源只有一个:把不可信数据当成了 SQL 代码来执行。
典型错误写法如下:
# 危险:字符串拼接
user_id = request.args.get("id")
sql = f"SELECT * FROM users WHERE id = {user_id}"
cursor.execute(sql)
攻击者传入 1 OR 1=1,语句就变成:
SELECT * FROM users WHERE id = 1 OR 1=1
结果就是全表数据泄露。更严重的情况下,攻击者可以通过 UNION SELECT 读取其他表,甚至使用 INTO OUTFILE 写入 WebShell。
常见的注入类型包括:
- 联合查询注入:利用
UNION SELECT获取额外数据。 - 布尔盲注:根据页面返回的真假判断数据内容。
- 时间盲注:利用
SLEEP()等函数判断条件是否成立。 - 报错注入:利用数据库报错信息回显数据。
- 堆叠注入:一次执行多条 SQL 语句。
理解了这些方式,才能有针对性地防御。
二、防御写法一:参数化查询(预编译语句)
这是最核心、最有效的防御手段,没有之一。
参数化查询的原理是:SQL 语句的结构先被数据库解析和编译,参数值随后才被传入,数据库不会把参数值当作 SQL 代码来解析。因此无论输入什么内容,都只会被当作数据。
Python(psycopg2 / PyMySQL)示例:
# 正确:参数化查询
user_id = request.args.get("id")
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
Java(JDBC PreparedStatement)示例:
String sql = "SELECT * FROM users WHERE id = ?";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setInt(1, userId);
ResultSet rs = ps.executeQuery();
Node.js(mysql2)示例:
const sql = 'SELECT * FROM users WHERE id = ?';
connection.execute(sql, [userId], (err, results) => { ... });
注意:? 占位符只能用于值,不能用于表名、列名、ORDER BY 字段等结构部分。如果业务需要动态排序,必须使用白名单校验。
三、防御写法二:ORM 与查询构建器的正确用法
ORM 本身并不天然安全,关键在于是否使用了安全的 API。
危险写法(Django):
# 危险:raw + 字符串拼接
User.objects.raw(f"SELECT * FROM users WHERE name = '{name}'")
安全写法:
# 正确:使用参数
User.objects.raw("SELECT * FROM users WHERE name = %s", [name])
# 更推荐:使用 ORM 自带方法
User.objects.filter(name=name)
MyBatis 中同样要注意:
<!-- 危险:${} 直接拼接 -->
SELECT * FROM users WHERE name = '${name}'
<!-- 正确:#{} 预编译 -->
SELECT * FROM users WHERE name = #{name}
#{} 会生成 ? 占位符,${} 则是直接替换。除非是动态表名、动态排序等极少数场景,否则一律使用 #{}。
四、防御写法三:输入校验与白名单
参数化查询解决的是“值”的安全问题,但对于无法参数化的部分,必须做严格校验。
典型场景:排序字段
ALLOWED_SORT_FIELDS = {"created_at", "updated_at", "price"}
sort_field = request.args.get("sort", "created_at")
if sort_field not in ALLOWED_SORT_FIELDS:
sort_field = "created_at"
sql = f"SELECT * FROM orders ORDER BY {sort_field} DESC"
这里虽然用了字符串拼接,但因为 sort_field 只能来自白名单,所以是安全的。
其他校验建议:
- 数字类型参数强制转换为
int或float。 - 邮箱、手机号等使用正则校验格式。
- 对长度做限制,避免超长输入引发异常。
- 拒绝包含注释符(
--、#、/* */)的输入只是辅助手段,不能作为主要防御。
五、防御写法四:最小权限与纵深防御
即使代码层面做得再好,也建议在数据库层面做好兜底:
- 应用账号最小权限:Web 应用连接数据库的账号只授予
SELECT、INSERT、UPDATE、DELETE,禁止DROP、FILE、GRANT等权限。 - 不同业务使用不同账号:读写分离,敏感表单独授权。
- 关闭详细报错:生产环境不要将数据库错误信息直接返回给前端,避免报错注入。
- WAF 与日志审计:部署 WAF 拦截常见注入特征,同时记录慢查询和异常 SQL,便于事后追溯。
- 定期扫描与代码审计:使用 SQLMap、SonarQube 等工具辅助发现隐患。
六、常见误区
- “用了 ORM 就绝对安全”:
raw()、extra()、字符串拼接的filter依然可能引入注入。 - “转义函数可以替代参数化”:不同数据库、不同字符集下转义规则不同,容易绕过。
- “前端校验就够了”:攻击者可以直接构造 HTTP 请求,前端校验只能提升体验,不能作为安全边界。
- “存储过程一定安全”:如果存储过程内部拼接 SQL,同样存在注入。
七、总结
SQL 注入防御并不复杂,关键在于养成正确的编码习惯:
- 首选参数化查询,所有用户输入都通过占位符传入。
- ORM 使用安全 API,避免
raw拼接和${}替换。 - 无法参数化的部分使用白名单,尤其是表名、列名、排序字段。
- 数据库账号最小权限,做好纵深防御。
- 生产环境关闭详细报错,配合 WAF 和日志审计。
安全不是一次性的工作,而是贯穿开发、测试、上线、运维的持续过程。把参数化查询变成肌肉记忆,是每一位后端开发者最基本、也最重要的安全素养。
未经允许不得转载:任鹏个人博客 » 后端开发者必须掌握的 SQL 注入防御写法


朋友圈点赞图在线生成源码