后端开发者必须掌握的 SQL 注入防御写法

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 只能来自白名单,所以是安全的。

其他校验建议:

  • 数字类型参数强制转换为 intfloat
  • 邮箱、手机号等使用正则校验格式。
  • 对长度做限制,避免超长输入引发异常。
  • 拒绝包含注释符(--#/* */)的输入只是辅助手段,不能作为主要防御。

五、防御写法四:最小权限与纵深防御

即使代码层面做得再好,也建议在数据库层面做好兜底:

  1. 应用账号最小权限:Web 应用连接数据库的账号只授予 SELECTINSERTUPDATEDELETE,禁止 DROPFILEGRANT 等权限。
  2. 不同业务使用不同账号:读写分离,敏感表单独授权。
  3. 关闭详细报错:生产环境不要将数据库错误信息直接返回给前端,避免报错注入。
  4. WAF 与日志审计:部署 WAF 拦截常见注入特征,同时记录慢查询和异常 SQL,便于事后追溯。
  5. 定期扫描与代码审计:使用 SQLMap、SonarQube 等工具辅助发现隐患。

六、常见误区

  • “用了 ORM 就绝对安全”raw()extra()、字符串拼接的 filter 依然可能引入注入。
  • “转义函数可以替代参数化”:不同数据库、不同字符集下转义规则不同,容易绕过。
  • “前端校验就够了”:攻击者可以直接构造 HTTP 请求,前端校验只能提升体验,不能作为安全边界。
  • “存储过程一定安全”:如果存储过程内部拼接 SQL,同样存在注入。

七、总结

SQL 注入防御并不复杂,关键在于养成正确的编码习惯:

  1. 首选参数化查询,所有用户输入都通过占位符传入。
  2. ORM 使用安全 API,避免 raw 拼接和 ${} 替换。
  3. 无法参数化的部分使用白名单,尤其是表名、列名、排序字段。
  4. 数据库账号最小权限,做好纵深防御。
  5. 生产环境关闭详细报错,配合 WAF 和日志审计。

安全不是一次性的工作,而是贯穿开发、测试、上线、运维的持续过程。把参数化查询变成肌肉记忆,是每一位后端开发者最基本、也最重要的安全素养。

未经允许不得转载:任鹏个人博客 » 后端开发者必须掌握的 SQL 注入防御写法

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏