做 Django 项目做得久的人多半都遇到过这样的场景某天做代码评审翻到一个导出报表的接口里面赫然一行cursor.execute(SELECT * FROM orders WHERE status %s % status)写这行代码的同事还觉得“我在 Django 里ORM 会自动帮我转义的”。这种误解在团队里传播得相当广——很多人以为只要用了 Django 框架SQL 注入就自动被挡住了于是把注意力全放在 XSS 和权限校验上反而在最容易出事的那几行手写 SQL 上完全松懈。真实情况是Django 的 ORM 确实把参数化查询做得很扎实但它只覆盖“你用 ORM 标准写法”的那部分代码一旦你伸手去拿cursor、写raw()、拼RawSQL、或者把用户传来的字段名塞进**kwargs框架的保护伞就没有了。这篇内容我想系统地聊一遍 Django 里防 SQL 注入的完整链条ORM 的保护边界到底在哪里、哪些写法会把这层保护撕开、怎么在存量代码里把这些入口挖出来验证、以及数据库层面还能怎么兜底。适合已经写过一段时间 Django、接手的项目有一定体量、准备或者正在做安全治理的开发者也适合刚入门但不想将来给自己挖坑的同学。1. ORM 的保护伞到底覆盖到哪一步1.1 一条 filter() 在数据库里长什么样先把最基础的东西说透后面很多判断都建立在这个认知上。当你写下Order.objects.filter(statuspaid, amount__gt100)的时候Django 并不是把这两个值拼进 SQL 字符串而是经历了 Query → WhereNode → 编译成 SQL 模板 参数列表的过程。最终交给数据库驱动的是两个东西一段带占位符的 SQL和一个参数元组大致是SELECT ... FROM order WHERE (order.status %s AND order.amount %s)参数是[paid, 100]。这两者是被分开传输的。数据库驱动在解析这条 SQL 的时候已经把执行计划定死了——它知道这里只有一个字符串比较和一个数值比较参数只是“填进去的值”无论这个值里面包含、--、; DROP TABLE还是UNION SELECT它都只是一个字符串字面量永远不会被重新解析成 SQL 语法。这就是参数化查询prepared statement的本质结构先于数据确定数据无法改变结构。你可以拿sql_with_params()亲手验证一下这比看任何文档都直观qs Order.objects.filter(statuspaid, amount__gt100) sql, params qs.query.sql_with_params() print(sql) print(params)输出的 SQL 里你只会看到%s看不到paid。这就是“值是参数”的直接证据。反过来说如果某个写法最终拼出来的 SQL 里直接出现了用户输入的字符那它就已经脱离了参数化保护不管外面包了几层 Django 的类。1.2 参数化只保护“值”不保护“结构”这是整个话题最关键的一句话值得单独拎出来。SQL 语句由两部分组成结构表名、列名、排序方向、运算符、函数名和值具体的字面量。参数化机制只能保护值因为参数的本质就是“延迟绑定的字面量”。表名和列名不行——数据库在执行计划阶段就需要知道查哪张表、用哪个索引它不可能等到参数绑定时才决定FROM后面跟什么。于是所有注入的变种本质上都是在攻击“结构位”。当你看到下面这类代码的时候就要立刻警觉field request.GET.get(sort, created_at) qs Order.objects.order_by(field) # 结构位来自外部order_by接收的是字段名它最终会变成ORDER BY xxx里的标识符。Django 会对字段名做一个合法性校验在names_to_path里非法字段会抛FieldError这在某种程度上形成了一层“意外的防护”。但这种防护是靠异常实现的不是设计层面的白名单某些路径比如别名、表达式、注解名的校验强度并不一致而且一旦有人用try/except把FieldError吞掉然后 fallback 到拼接防线就直接没了。更稳妥的做法永远是显式白名单这一点后面会展开。同样的道理适用于动态表名、动态列名、values()的字段列表、annotate()的别名、RawSQL里的片段、extra()里的where、JSON 字段的 key 路径、全文检索的字段名。凡是“决定 SQL 长什么样”的输入都不能来自用户只能来自你自己的常量表。1.3 真正会破防的四类写法把 Django 项目里可能引入注入的入口归个类其实就那么几处我在做代码审计的时候基本就是照着这个清单去扫类别典型 API风险点是否可参数化原生 SQL 执行cursor.execute()、connection.cursor()手写 SQL 字符串可必须传 params原生查询入口Model.objects.raw()、RawSQL()片段拼接可必须传 params结构位动态化order_by()、values()、**kwargs、annotate别名标识符注入不可只能白名单已废弃入口extra(where..., select...)、extra(tables...)完全是裸拼接不可应重构掉这四类里前两类是“你主动写 SQL”只要养成传 params 的习惯就不会出事后两类更隐蔽因为代码读起来很像正常的 ORM 调用评审时一眼扫过去根本发现不了。我见过最典型的例子是queryset.filter(**{f{key}__icontains: value})key来自前端的筛选项配置value是参数化的key不是——参数化保护住了值但结构位被用户控制了。判断标准很简单如果一段用户输入最终变成了 SQL 里的“名字”而不是 SQL 里的“值”那它就绕过了参数化必须按白名单处理。2. raw() 与 cursor.execute() 的两种命运2.1 传不传 params差的不是一点半点Model.objects.raw()和cursor.execute()这两个 API 长得几乎一样raw(sql, params)、execute(sql, params)第二个参数是可选的。正是这个“可选”害了很多人——不传 params 的时候它们就是纯粹的字符串执行器一点防护都没有。下面这段代码是教科书级别的错误示范def search_orders(request): keyword request.GET.get(q, ) sql SELECT * FROM order WHERE code LIKE %%%s%% % keyword return list(Order.objects.raw(sql))注意这里它连%都手动拼接了看上去还挺“讲究”实际上keyword传进来一个 OR 11就全线崩塌。正确写法是把%的部分留在 SQL 里把变量交给 paramsdef search_orders(request): keyword request.GET.get(q, ) sql SELECT * FROM order WHERE code LIKE %s return list(Order.objects.raw(sql, [% keyword %]))raw()的占位符统一写%s不管底层是哪种数据库Django 会负责转成后端需要的风格。这一点很多人不知道以为raw()里得按数据库写?或者:name结果为了“兼容”反而去手工拼接了非常可惜。cursor.execute()同理from django.db import connection with connection.cursor() as cursor: cursor.execute( SELECT code, amount FROM order WHERE status %s AND amount %s, [paid, 100], ) rows cursor.fetchall()这里有个常被忽略的细节connection.cursor()是个上下文管理器它的__exit__只负责关闭游标不会回滚事务。如果你在一个atomic()块里用裸游标写数据出异常之后的清理逻辑得自己操心别以为with帮你摆平了一切。2.2 占位符与百分号转义那些坑前面提到%s是统一写法但它有个副作用当你的 SQL 里同时出现了“占位符”和“字面量百分号”的时候字面量百分号必须写成%%。因为驱动会先做一次格式化扫描看到%就找后面的字符如果后面跟着的不是s行为就不可预期了。# 错误LIKE 里的 % 被当成了格式化符号 cursor.execute(SELECT * FROM order WHERE code LIKE %abc% AND status %s, [paid]) # 正确字面量百分号写成 %% cursor.execute(SELECT * FROM order WHERE code LIKE %%abc%% AND status %s, [paid])这个坑我在两个项目里都踩过表现是运行时抛IndexError: tuple index out of range或者ValueError: unsupported format character看起来莫名其妙因为 SQL 语法本身没错。还有一种更阴的情况SQL 是动态拼出来的今天恰好没有%测试全绿明天运营要求加个模糊匹配代码一改就炸而且炸在第 200 行开外的 SQL 上排查半天。另一个是单引号的问题。有些人为了“保险”在占位符外面再加一层引号cursor.execute(SELECT * FROM order WHERE code %s, [code]) # 大错特错这样写的结果是参数被当成字符串插进去之后外面又被引号包了一层code里如果本来就有单引号数据库直接报语法错误如果数据库的配置比较宽松比如某些模式下甚至可能被绕过。占位符外面永远不要加引号加了就是自己给自己挖坑。2.3 标识符没法参数化那就只能靠白名单表名和列名的参数化在所有主流数据库里都是不支持的这不是 Django 的短板是 SQL 协议本身的限制。所以处理这类需求正确姿势只有一条路映射表。ORDER_FIELD_MAP { created: created_at, amount: amount, status: status, } def apply_ordering(qs, raw_field): field ORDER_FIELD_MAP.get(raw_field) if field is None: field created_at return qs.order_by(field)关键点是白名单里的值必须是代码里写死的常量而不是“从数据库里查出来的字段列表”——后者本质上还是可控数据。有人喜欢用[f.name for f in Model._meta.get_fields()]去动态校验看起来很聪明但这样做同时也把_meta里所有的关联字段、反向关系、甚至id都放行了攻击面比你想的大。宁可手写十来行常量也不要图省事用反射。还有些场景确实需要动态表名比如分表查询。这时候的思路是先用一个字典把“逻辑名”映射到“物理模型”然后走 ORMSHARD_MAP {2024: Order2024, 2025: Order2025} model SHARD_MAP.get(year) if model is None: raise ValueError(unsupported shard) return model.objects.filter(statuspaid)如果确实绕不开原生 SQL还有最后一招connection.ops.quote_name()。它会把标识符按当前数据库的规则加引号能在一定程度上减少因名字里带特殊字符导致的语法问题。但要清楚quote_name是转义不是校验它挡不住逻辑上的越权比如用quote_name包了个order表名你依然能查到你本不该查的表。真正的防线是白名单quote_name只是辅助。3. 藏在字段名里的注入面怎么挖出来3.1 filter({...}) 与 Q({...}) 的键名来源这类代码在带筛选器的列表接口里极其常见写法大概是def build_query(params): q Q() for key, value in params.items(): q Q(**{key: value}) return Order.objects.filter(q)value被参数化了没问题key直接变成了查询路径。攻击者可以构造status__startswith、created_at__year__gt这类键名来窥探数据结构更极端的情况下如果后端的 Django 版本较老、或者某个自定义 Lookup 注册时没有做好校验还可能引出意料之外的 SQL 片段。即便只是“窥探”在真实的攻防里也足够有价值了——攻击者可以靠报错差异一点点摸清字段名然后发起下一步。正确的处理方式是把前端的筛选参数翻译成内部的、固定的查询参数FILTER_SPECS { status: lambda v: Q(statusv), min_amount: lambda v: Q(amount__gtev), keyword: lambda v: Q(title__icontainsv), } def build_query(params): q Q() for key, value in params.items(): builder FILTER_SPECS.get(key) if builder is None: continue q builder(value) return Order.objects.filter(q)这样做还有一个额外的好处筛选逻辑变成了可枚举的产品要新增筛选项你改一个字典就完事前端的乱传参数也不会影响后端行为。我一直觉得这是“安全设计顺带解决了可维护性”的典型例子。同样的道理在 DRF 的filter_backends、ordering_fields、search_fields上成立。DRF 自带的OrderingFilter有ordering_fields白名单配好了就没问题怕的是有人自己写了个get_queryset里面直接order_by(request.query_params.get(sort))。3.2 order_by、values、annotate 的别名面values()和values_list()接受字段名列表同样是结构位。有些接口设计成“让前端决定返回哪些列”图省流量这个需求本身合理但实现上必须把前端传来的列名映射到允许的集合ALLOWED_COLUMNS {id: id, code: code, amount: amount} def select_columns(qs, requested): cols [ALLOWED_COLUMNS[c] for c in requested if c in ALLOWED_COLUMNS] if not cols: cols [id] return qs.values(*cols)annotate()的情况稍微复杂一点。注解的别名是你自己起的一般不会有问题但注解的内容如果用了F()加动态字段名一样会出事# 危险field 来自外部 qs.annotate(diffF(field) - F(baseline))F()的参数会走names_to_path和order_by是同一套校验同样只能算“意外防护”。只要字段名是外来的一律走白名单没有例外。还有一种容易忽略的场景聚合时用Sum(amount)字段名写死当然没事但如果有人写了个通用的统计接口字段名从配置中心或者数据库里的“自定义报表”表里读出来那就变成了二次注入——攻击者先污染配置再触发查询。这种链路在我们做渗透测试的时候特别喜欢找因为开发者往往只盯着 HTTP 入参忘了数据本身也可能是攻击载荷。3.3 RawSQL、extra、JSON key 这几个老面孔RawSQL是 Django 官方提供的表达式入口用来在 ORM 里塞一段原生 SQL 片段from django.db.models.expressions import RawSQL qs Order.objects.annotate( rankRawSQL(SELECT COUNT(*) FROM order o2 WHERE o2.amount %s, [threshold]) )它支持的第二个参数是 params用了就是安全的不用就是灾难。我在审计时看到过RawSQL(famount {threshold})这种写法那个f前缀就是危险信号本身——凡是 SQL 字符串前面带着f基本可以直接判定为待修复。extra()现在已经不推荐使用了新版本里标记为废弃它的where、select、tables参数都是纯裸拼接没有任何参数化能力。历史项目里如果还有大面积的extra()建议列个清单逐个重构成annotateRawSQL或者干脆用原生 SQL 加参数。JSON 字段这块要单独提一句。__has_key、__contains这类查询键名和值都会被参数化这一层是安全的。但如果你用的是KeyTextTransform这类表达式它的 key 参数会直接变成 SQL 里的路径表达式from django.db.models.fields.json import KeyTextTransform qs.annotate(vKeyTextTransform(user_key, payload)) # user_key 外来就危险处理方式和前面完全一致映射表。JSON 字段的结构本身就是业务约定的一部分让用户随便指定 key 本来就不合理。3.4 LIKE 通配符与 __in 列表的边界情况这两个不算真正的注入但经常被混淆一并说清楚。__contains、__icontains、__startswith这些 lookupDjango 内部会做通配符转义用户输入里的%和_会被加上ESCAPE处理所以q100%不会变成“匹配任意内容”。这点可以放心。但要注意它的性能影响icontains生成的是LIKE %xxx%前缀通配符导致索引失效几十万行的表上查一次就是全表扫描。这不是安全问题是性能问题但经常和安全问题一起被提出来因为它们都跟“用户输入进入了 SQL”有关。__in是彻底参数化的。Order.objects.filter(id__in[1, 2, 3])生成的是IN (%s, %s, %s)列表长度决定占位符数量。这里有个真实的坑如果列表长度不受控比如id__inrequest.GET.getlist(ids)而前端可以传一万个 id生成的 SQL 会有上万个占位符直接触发数据库的语句长度上限或者参数上限PostgreSQL 是 65535SQLite 更低。轻则报错重则拖垮连接池。所以对列表类参数一定要做长度截断这个是纯工程经验文档不会告诉你但线上真的会出事。__regex也要留意。它本身是参数化的不会注入但用户可以通过构造灾难性回溯的正则表达式把 CPU 打满属于另一种形式的攻击。如果接口允许传正则最好限制长度和复杂度或者干脆不开放这个能力。4. 存量项目里怎么把这些问题揪出来4.1 先用特征字符串把可疑点全捞一遍接手一个跑了几年、几十万行代码的项目靠人肉读是不现实的。我的做法是先做一轮粗筛把范围从“全项目”压缩到“两三百行”然后逐个看。grep -rnE \.raw\(|RawSQL|\.extra\(|cursor\.execute|connection\.cursor|executemany \ --include*.py . | grep -v /migrations/排除migrations目录很重要不然你会被历史迁移文件里的 SQL 淹掉。扫出来的结果按文件分组先看views.py、services.py、reports.py这类高概率位置再看utils.py。第二遍扫拼接痕迹这些才是真正的危险信号grep -rnE execute\(\s*f\|execute\(\s*\[^\]*%[sd]\\s*%|RawSQL\(\s*f\ --include*.py .第三遍扫动态字段名grep -rnE order_by\(|values\(|values_list\(|\*\*\{|\*\*kwargs --include*.py .这一遍会命中大量正常代码别慌重点看参数是不是来自request、query_params、序列化后的 payload。判断方法很土但很有效把命中行的变量往上追三层看到底是不是从 HTTP 层流下来的。除了手写 grep静态扫描工具也值得跑一遍。开源的 Python 安全扫描工具里有针对“硬编码 SQL 表达式”的规则能识别%拼接和f-string拼接这两种模式配上 CI 能当门禁使。但它对动态字段名这类问题基本无感所以只能作为辅助不能作为唯一手段。4.2 构造 POC 验证别靠猜扫出来的点不要凭经验直接下结论要构造一个能跑通的验证。方法很朴素写个单元测试用恶意载荷打进去然后看两件事——有没有报错、connection.queries里记录的 SQL 长什么样。from django.test import TestCase from django.db import connection class InjectionProbeTest(TestCase): def test_search_payload(self): payload OR 11 self.client.get(/api/orders/, {q: payload}) executed [q[sql] for q in connection.queries] for sql in executed: self.assertNotIn(payload, sql)这里的关键是connection.queries它只在DEBUGTrue且测试环境下有内容。断言“载荷没有原样出现在 SQL 里”是个很粗糙但很有效的判据——如果它出现了说明这段 SQL 是拼接的基本可以确诊。需要注意DEBUGTrue时connection.queries会累积所有查询在测试里要把settings.DEBUG打开或者用CaptureQueriesContext精确捕获某一段。另外这个机制会带来明显的内存开销生产环境千万别开。对于结构位的问题POC 的构造要换个思路传一个不存在但形似字段名的 key比如filter(**{not_a_field__icontains: x})看会不会抛FieldError。如果抛了说明 Django 的路径校验在起作用风险等级下降如果没抛而是执行成功了那说明这个 key 被某段自定义逻辑吞掉了得顺着往下查。4.3 修完之后怎么确认真的修好了修完一遍我会跑一张回归清单逐项确认检查项验证方式通过标准原生 SQL 全部带参数全量 grep 人工复核无裸拼接残留结构位全部走白名单传入非法字段名返回默认排序/字段不报 500异常路径不泄漏 SQL构造触发 DB 错误的请求返回通用错误页参数长度受控传超长列表、超长字符串被截断或拒绝不拖垮数据库旧入口已清理搜索extra(无残留“传入非法字段名时返回 500”这个情况要特别注意。很多团队修的时候只加了个白名单判断但没处理 fallback结果非法输入直接抛异常等于把错误路径暴露给了攻击者。正确的行为是静默降级到默认值而不是抛错。还有一点修完不要只跑一遍功能测试就完事。要专门写一组“恶意输入”的测试用例固化下来形成回归网。这些用例不会随时间变化却能防止后来的人在重构时把白名单又改回动态拼接——这种情况我在真实项目里见过不止一次。5. 代码之外的兜底数据库和配置层5.1 应用账号的权限一定要收紧代码写得再好也不该把数据库的全部权限交给应用。这是纵深防御里最便宜、收益最高的一招。原则就三条应用账号只给 DMLSELECT/INSERT/UPDATE/DELETE不给 DDLCREATE/DROP/ALTER不给任何管理类权限。-- 示意具体语法因数据库而异 GRANT SELECT, INSERT, UPDATE, DELETE ON app_db.* TO app_user%;做不到这一点会怎样假设真的被注入成功攻击者能做的就远不止“读数据”。有了DROP权限可以删表有了文件读写权限可以往服务器上落文件有了跨库查询权限可以横向摸到别的业务库。权限收紧之后即便某一行代码失守损失也能被限制在“当前账号可见的数据范围内”。再细一点还可以按业务拆账号。报表类接口本来只需要读就给它一个只读账号定时任务需要写单独配一个写入账号。多配几个连接的成本远低于一次数据泄露的代价。Django 的多数据库配置支持这个玩法DATABASES里定义多个 alias业务代码里用using()指定。5.2 DEBUG 模式和错误页是二次泄漏的重灾区DEBUG True上线这是老生常谈但每年都能遇到的事故。在 Django 的调试页里一次数据库异常会直接把完整的 SQL 语句、参数、甚至数据库连接的配置片段打在页面上。攻击者根本不需要注入成功只要想办法让你报个错就能免费拿到你的表结构。上线检查清单里我一定会有这几条DEBUG False ALLOWED_HOSTS [your-domain]同时配上自定义的 404 和 500 模板把 Django 默认的错误页替掉。默认页面在某些配置下依然会带一些提示信息自定义模板能彻底断掉这条路。日志也是同理。很多人习惯在except里把异常对象直接打日志而数据库异常的str()往往包含完整的 SQL。日志文件如果权限没管好或者被采集到了公共的日志平台这个泄漏面比网页还大。我的做法是在业务层捕获异常后只记录“某类操作失败”和请求 ID具体的 SQL 留在调试级别、且只在必要时才开启。5.3 从日志里找信号而不是等出事有了正常日志就可以做一些简单的检测。数据库层或应用层日志里出现下面这些模式基本可以判定有人在试探参数里出现UNION、SELECT与注释符--的组合参数里出现information_schema、pg_catalog这类元数据表名参数里出现sleep(、pg_sleep(这类延时函数大量请求在短时间内触发数据库语法错误这些规则误报率不低比如正常业务里也可能有带--的备注文本所以不适合做阻断只适合做告警和人工跟进。真正有价值的是“语法错误突增”这个信号——正常业务很少产生数据库语法错误一旦某个接口的语法错误率突然拉高十有八九是有人在手工构造载荷。别把这类检测当成主力防线。它是“出事之后的第二只眼睛”用来判断影响范围和攻击者的意图不能替代代码层的修复。6. 把这件事变成团队习惯6.1 在合并请求上加一道自动门禁个人的警觉靠不住人会累、会忘、会离职。能长期生效的是流程。我的建议是在 CI 里挂两个检查一个静态扫描一个自定义的 grep 规则。自定义规则可以写得很简单比如禁止在业务代码里出现execute(后面紧跟f的模式if grep -rnE execute\(\s*f --include*.py app/; then echo 检测到疑似拼接 SQL请改用参数化写法 exit 1 fi规则粗糙没关系它的作用是“让人停下来看一眼”而不是“精确判断是否安全”。很多时候问题就出在“顺手写了没多想”一道会失败的检查足以把人的注意力拉回来。静态扫描工具可以作为第二步覆盖更复杂的模式比如变量拼接、字符串 format、跨行的 SQL 构造。这类工具的误报不少所以建议只对新增代码生效增量扫描避免历史代码把门禁卡死团队很快就把它关掉了。6.2 评审时我会重点盯的几个信号做代码评审的时候我不可能逐行读完所以会先扫信号词。看到下面这些就停下来细看SQL 字符串前面带f或者后面跟%order_by()、values()、filter()的参数来自request出现**{和}的组合出现extra(参数名里带sort、order、field、column、table这类词且来源是 HTTP 层这五个信号覆盖了我见过的绝大多数问题。特别是最后一条业务代码里凡是参数名叫field的我都会追一遍来源因为“动态字段”这个需求本身就是高危需求实现方式稍有不慎就会出事。反过来如果一个改动里新增了白名单字典、新增了类型转换int()、UUID()那是好信号。类型转换其实是一种非常朴素但有效的防御把用户输入强转成整数、UUID 这类结构化类型转换失败就拒绝注入空间直接归零。对于主键类的场景我一直推荐在 URL 层就用类型转换器把参数卡死比如路径参数用int:pk比在视图里再判断要早一步。6.3 三个最常见的认知误区最后说几个我在带人和评审中反复纠正的误解这些想通了前面所有内容就串起来了。第一个误区是“用了 ORM 就安全”。ORM 保护的是它自己生成的 SQL你手写的那部分它管不着。而恰恰是手写的那部分往往出现在报表、大屏、数据导出这些“ORM 不好写”的场景里风险密度比普通接口高得多。第二个误区是“转义一下就行”。很多人喜欢写个escape()函数把单引号变成两个单引号觉得这样就安全了。问题在于不同数据库、不同字符集下的转义规则并不一致而且转义只能处理值处理不了结构。参数化是让数据库自己处理转义是你自己猜数据库怎么处理前者是根本方案后者是权宜之计。遇到需要“手工转义”的代码正确做法是重写成参数化而不是把转义函数写得更完善。第三个误区是“内部接口不用管”。后台管理、内部工具、给运营用的导出接口这些地方最容易被忽视因为“只有自己人用”。但内部工具的账号往往是共享的、弱口令的、甚至直接暴露在内网任意可达的位置攻击者一旦进来第一个翻的就是这些“不用管”的地方。我处理过的真实案例里出问题的接口有相当比例是内部报表。我自己干活的时候有个习惯每当写出一个带字符串拼接的 SQL 片段就先在心里问一句“这段代码如果被公开到代码仓库里我会不会觉得难为情”。这个自检很简陋但拦下来的问题比我读过的任何安全手册都多。参数化查询这件事没有灰色地带要么传 params要么就是在赌运气。
阅读完成 · 觉得有帮助?