4. SQL 注入防御
ORM 过滤条件与原生 SQL 的边界
ORM 的过滤条件(如 Django 的 filter()、SQLAlchemy 的 where())在正常使用时会把值作为参数绑定,不会把用户输入解释为 SQL 语法。真正的注入风险来自两类写法:一是把用户输入拼进 extra()、raw()、text() 等原生 SQL 片段;二是把用户输入当作字段名、排序方向、表名等标识符传给 ORM,因为参数绑定只能保护值,不能保护标识符。
危险写法与安全改写
下面用 Django ORM 和 SQLAlchemy 两个常见框架展示典型风险点。示例中的 request.GET 代表任意用户可控输入。
风险写法 1:把用户输入拼进
当
extra() 的 where 子句qs = User.objects.extra(where=[f"name = '{name}'"])当
name 为 ' OR '1'='1 时,where 子句被改写为永真条件,返回全部用户。
风险写法 2:把用户输入当作排序字段
当
qs = User.objects.order_by(request.GET['sort'])当
sort 为 (SELECT ...) 或带子查询的表达式时,可能触发基于时间的盲注或数据泄露。
风险写法 3:SQLAlchemy 中拼接
与字符串拼接 SQL 等价,参数绑定完全失效。
text()db.session.execute(text(f"SELECT * FROM users WHERE name = '{name}'"))与字符串拼接 SQL 等价,参数绑定完全失效。
安全改写方案
值过滤:直接使用 ORM 的过滤条件,框架会自动生成绑定参数。
# Django:安全,name 作为参数绑定
qs = User.objects.filter(name=name)
# SQLAlchemy:安全,name 作为绑定参数
stmt = select(User).where(User.name == name)
原生 SQL 中的值:必须使用占位符,把值放在参数列表中,不要拼进 SQL 字符串。
# Django raw:%s 占位符 + params 列表
qs = User.objects.raw("SELECT * FROM auth_user WHERE name = %s", [name])
# SQLAlchemy text::name 命名占位符 + 参数字典
db.session.execute(
text("SELECT * FROM users WHERE name = :name"),
{"name": name}
)
标识符(排序字段、表名、列名):参数绑定无法保护标识符,必须用白名单映射。
ALLOWED_SORT = {
"name": "name",
"created": "-date_joined",
"email": "email",
}
sort_key = request.GET.get("sort", "name")
order_field = ALLOWED_SORT.get(sort_key, "name") # 非法值回退默认
qs = User.objects.order_by(order_field)
判断口诀:问自己“这个输入会变成 SQL 里的值还是结构?”——值走参数绑定,结构走白名单。两者都不能靠转义或过滤特殊字符来兜底。
验证注入是否失效
改造完成后,用以下 payload 验证:
| 测试点 | Payload | 预期结果 |
|---|---|---|
| 值过滤(name) | ' OR '1'='1 | 返回 0 条记录,不返回全部用户 |
| 排序字段(sort) | name; DROP TABLE users-- | 回退默认排序,无异常、无数据破坏 |
| 原生 SQL 参数 | ' UNION SELECT password FROM users-- | 作为普通字符串匹配,不执行 UNION |
| 正常输入 | alice | 正确返回 alice 的记录 |
落地检查清单
- 搜索代码库中所有
extra(、raw(、text(、execute(调用,确认没有 f-string 或+拼接用户输入。 - 检查所有
order_by、values、annotate的参数是否来自用户输入,若是则加白名单映射。 - 确认 ORM 过滤条件中传入的是值而不是格式化后的字符串。
- 对每个原生 SQL 调用点,确认占位符与参数列表一一对应,且参数来自变量而非拼接结果。
- 在测试环境用上表 payload 回归,确认注入路径全部失效且正常功能不受影响。