Python防SQL注入:从攻击原理到参数化查询与ORM防护

📅 发布时间:2026/10/20 13:13:54
Python防SQL注入:从攻击原理到参数化查询与ORM防护
很多刚接触Python Web开发的朋友估计都听过一句警告永远不要用字符串拼接的方式去执行SQL。但老实说我在很长一段时间内虽然知道这个说法却并不真正理解背后的攻击链条是怎么走通的。直到有一次我用一个看似无害的查询语句在测试环境里把整个用户表的哈希值给看了个底朝天才意识到所谓SQL注入离我们写的那几行普通代码远比想象中要近得多。这篇文章不是什么从入门到精通的大而全教程而是围绕Python中如何防止SQL注入攻击这条主线把我实际踩过的坑、排查过的思路、以及最终沉淀下来的防御套路一次性说清楚。我会先从攻击者视角拆解注入是怎么发生的再回到Python生态里最常见的几种危险写法接着给出我目前在用的、经过验证的参数化查询和ORM防护方案最后聊一聊那些即使用了参数化也容易翻车的细节。无论你是刚写完第一个Flask接口还是已经在生产环境维护着一套复杂的数据系统这篇文章应该都能给你一些拿过来就能用的东西。1. SQL注入的本质为什么一段输入能变成代码想要彻底防住SQL注入第一件事不是去背过滤函数而是得先想明白一个问题数据库系统凭什么分不清数据和代码SQL是一种解释型语言。我们发一段字符串给数据库由数据库的解析器去拆解语法再生成执行计划。问题在于当这段字符串里有用户可控的内容时数据库不会自动区分这里是我让你执行的逻辑还是这里是用户塞进来的新语法。它只会老老实实地把拼接结果当成一整段SQL来编译执行。所谓注入本质上就是我们通过精心构造的内容改变了SQL语句原本的语义结构迫使数据库去执行我们额外追加的逻辑。1.1 一个经典的拼接漏洞如何一步步被打穿假设我们有一个最原始的登录查询import sqlite3 def check_login(username, password): conn sqlite3.connect(app.db) cursor conn.execute( SELECT * FROM users WHERE username username AND password password ) return cursor.fetchone() is not None表面上没什么问题吧如果攻击者传入的用户名是admin --那么拼接之后的完整语句就变成了SELECT * FROM users WHERE username admin -- AND password whatever在SQL里--是注释符后面所有的内容都会被忽略。于是这条语句的语义变成了只要存在用户名为admin的记录查询就成功。攻击者根本不需要知道密码。更进阶一点如果攻击者传的是admin OR 11拼接后SELECT * FROM users WHERE username admin OR 11 AND password x注意运算符优先级OR的优先级低于AND所以这句等价于查询所有用户并从中取任意一条。在登录场景里只要能查出一条就算登录成功。攻击者只要随便构造一个能返回结果的值就有机会以数据库中第一个用户很可能是管理员的身份进入系统。1.2 注入类型不只是能查出来这么简单很多新手以为SQL注入就是让登录框变得好骗其实攻击者的手法远比这个多样。我在实际排查和复现中把常见的注入手法归纳成了下面几种注入类型攻击思路典型判断方式攻击目标联合查询注入在原查询后追加UNION SELECT直接读取其他表数据通过报错信息判断字段数再尝试逐步对齐列数脱库、读取敏感字段布尔盲注通过条件表达式的真/假观察页面响应差异逐字符推断数据尝试AND 11与AND 12的返回是否不同无回显场景下的数据窃取时间盲注利用sleep()或pg_sleep()等函数制造时间延迟通过响应耗时猜测数据请求响应从200ms突然变成5秒说明条件判断生效无任何回显、无报错时的数据提取报错注入利用数据库函数报错时把结果带回错误信息如updatexml、extractvalue传入能够触发类型转换错误的表达式观察异常文本里是否包含库名或表名快速提取少量关键数据堆叠注入部分数据库驱动允许一条语句结束后执行分号后的第二条语句比如追加DROP TABLE传入; DROP TABLE xxx; --并观察功能是否异常破坏性攻击绕过业务逻辑所以你看SQL注入不只是绕过登录这一个后果。它可能是悄无声息地一条条把数据猜走也可能是直接让整个库毁于一旦。这也是为什么它能在安全领域里常年霸榜——它影响的是数据可信性这条生命线本身。2. Python应用里最危险的高危写法和排查实录Python的数据库生态其实很庞大从早期的MySQLdb到后来的PyMySQL、psycopg2、sqlite3再到各种ORM每种都提供了不同的调用方式。但调用方式丰富也意味着踩坑方式多样。下面这几个高危写法我都在真实项目里见过其中有几个还造成了线上事故。2.1 字符串拼接、format方式与exec执行让我用一个真实的排查过程来还原这类问题的完整链路。有一段时间我们某内部系统收到一批异常报警说某个查询接口响应时间出现明显抖动同时数据库CPU消耗突然飙升。一开始大家都以为是索引失效后来发现无论怎么优化索引都没用。随后我拉取了近两小时的慢查询日志发现大量形如SELECT * FROM products WHERE name LIKE %...% AND status1的语句出现了异常的通配符组合。问题出在搜索框。当时的代码是这样的def search_products(keyword): conn get_connection() sql SELECT * FROM products WHERE name LIKE %{}% AND status1.format(keyword) # ...攻击者往keyword传入了大量的%和_让LIKE模式的匹配范围急剧扩大导致全表扫描。我检查了请求日志果然有人用重复的%字符做了连续调用这既是一种探测也顺带造成了对数据库的性能冲击。顺着这条线我又翻出了同一代码库里另一个危险用法那条语句直接把用户输入拼进了一个存储过程的动态执行里去def run_by_param(table_name, condition): conn get_connection() sql SELECT * FROM table_name WHERE condition # 这里直接把变量拼进去危险程度直接拉满更极端的场景是有人图方便用exec或eval去执行一段带变量的SQL。Python的exec是能执行任意代码的一旦用户输入进入了这里造成的就不只是数据库泄露而是可能直接反弹一个远程控制通道。这类代码在审计中属于最高优先级处理项碰到了必须立刻下线重构。2.2 从搜索框到信息泄露一次注入的完整分析步骤继续说上面那个搜索框案例。发现问题后我并没有直接修复就完了而是把攻击链完整走了一遍目的是确认到底有没有造成数据泄露。排查步骤如下先在测试环境复现该接口用单引号作为搜索关键词传入观察响应是否出现数据库报错。完整重现了系统会直接返回形如OperationalError: unrecognized token: 的异常信息。继续传入 OR 11 --发现返回结果变成了全量数据。此时可以确认拼接点真实存在且OR逻辑成功执行。进一步尝试 UNION SELECT sql FROM sqlite_master --发现系统直接返回了建表语句。这就意味着整个库的表结构与字段名已经完全暴露。接着可以按图索骥继续构造UNION SELECT去读取目标表的敏感字段。这个过程让我明白一个道理注入的危害程度取决于攻击者的耐心和接口的回显程度。有些接口虽然不直接回显数据库内容但能通过布尔差异或时间差异把数据一点一点抠出来。所以在评估一个系统是否存在SQL注入时不能只看有没有报错回显而要默认只要存在拼接点就得假设数据已经泄露了。2.3 除了SQL语句本身这些配套环节也在放大风险除了直接拼接我发现开发者在日常编码中还会踩几个间接放大注入风险的坑数据库账号权限过高。很多项目为了方便所有的业务查询都用同一个高权限账号甚至是用管理员账号连接。一旦注入点被利用攻击者通过堆叠注入或文件读写直接拿下服务器权限就一点都不奇怪了。异常信息直接回显给前端。这在调试阶段很常见但上线时忘记关闭。攻击者恰恰可以利用这些报错信息来判断注入点、数据库类型和版本。日志系统把原始SQL和参数一起记录下来。如果日志的权限控制不严格敏感查询条件也会一并泄露出去。说实话我在早期开发时不太在乎这些细节总觉得只要功能正常就行。但经历了那次线上数据库CPU被打满、表结构被脱走的事件之后我把这些配套环节全都梳理了一遍。现在的观点很明确SQL注入防护是一个体系SQL语句本身只是核心环节四周的权限、日志、报错处理同样决定了最终风险有多高。3. 正确的防御手段参数化查询是如何把数据和代码彻底分开的说了这么多危险的场景现在聊点能落地的方案。我的观点可能比较直接在Python里防止SQL注入最基本也最有效的做法就是无条件使用参数化查询Prepared Statement / Parametrized Query。3.1 参数化查询的底层原理参数化查询的核心思路是把SQL语句的骨架和数据本身彻底分离。数据库驱动在接收到SQL语句时会先让数据库对完整语句做预编译此时语句中的参数部分以占位符的形式存在数据库已经确定了语句的结构和执行计划。随后用户输入的数据只作为纯值绑定到占位符上不再参与语法解析。用一个通俗的类比来理解SQL语句骨架是一张已经设计好的问卷表格占位符是表格里的空格用户输入是填进空格里的内容。不管你往空格里填什么文字都不会改变问卷本身的结构——哪怕你填了重新设计整份问卷它也只是一段文字内容而不会真的变成新的问卷题目。这就是为什么参数化查询能从根上防住SQL注入占位符里的内容只被当作值处理不会被当作SQL语法解析。3.2 各数据库驱动下的标准写法对比Python里常见的几个数据库驱动写法大同小异但占位符风格各异。我用一个简单的示例来对比# sqlite3使用问号占位 import sqlite3 conn sqlite3.connect(app.db) cursor conn.execute( SELECT * FROM users WHERE username ? AND password ?, (username, password) ) # PyMySQL推荐使用 %s 占位 import pymysql conn pymysql.connect(hostlocalhost, userapp, passwordsecret, databaseapp_db) cursor conn.cursor() cursor.execute( SELECT * FROM users WHERE username %s AND password %s, (username, password) ) # psycopg2PostgreSQL同样是 %s 占位 import psycopg2 conn psycopg2.connect(hostlocalhost dbnameapp userapp passwordsecret) cursor conn.cursor() cursor.execute( SELECT * FROM users WHERE username %s AND password %s, (username, password) )提示不管是?还是%s都不要自己去拼字符串把它们替换掉。参数必须作为独立参数传给execute()而不是先%格式化再传入。参数化查询的效果立竿见影。比如之前那个登录查询如果改成参数化写法即使用户名传admin --数据库也会把这一整串内容当成一个用户名值去匹配而不会把它当作SQL语法去解释。--在数据值里不再有注释功能OR 11也不再是逻辑表达式只是普普通通的字符串内容。3.3 复杂场景该怎么做参数化参数化查询看起来简单但真遇到复杂业务场景很多人会因为不知道怎么处理又偷偷退回字符串拼接。其实这些场景都有对应的处理套路场景一模糊查询# 正确的做法把通配符放在参数里而不是拼在SQL骨架里 cursor.execute( SELECT * FROM products WHERE name LIKE ? AND status ?, (% keyword %, 1) )场景二IN子句# 根据参数数量动态生成占位符再用元组展开 placeholders ,.join([? for _ in ids]) sql SELECT * FROM products WHERE id IN ({}).format(placeholders) cursor.execute(sql, tuple(ids))场景三排序字段与表名排序字段和表名不能直接用占位符因为数据库不会允许把表名、列名作为绑定参数。这是参数化查询的一个边界。我的处理办法是使用白名单映射ALLOWED_COLUMNS {name, price, created_at} ALLOWED_DIRECTIONS {asc, desc} if order_col not in ALLOWED_COLUMNS: order_col created_at if direction not in ALLOWED_DIRECTIONS: direction desc sql SELECT * FROM products ORDER BY {col} {dir}.format( colorder_col, dirdirection )只需要保证col和dir是通过白名单验证后的固定字符串就不会存在注入风险。因为攻击者能控制的范围已经被限制死在一组由我们定义的枚举值里了。场景四分页分页的LIMIT和OFFSET一定要使用参数化。如果直接把前端传来的页码拼进去攻击者传一个0; DROP TABLE users; --就能造成严重破坏。正确写法是cursor.execute( SELECT * FROM products LIMIT ? OFFSET ?, (page_size, (page - 1) * page_size) )这些套路没有一个是复杂的难的是遇到任何可变内容第一反应是参数化实在不能参数化的地方就想尽办法用白名单消灭可变性。把这个思维方式训练出来比背一百条SQL注入payload都管用。4. ORM是万能护身符吗在这几个边角场景里别掉以轻心要说Python里最流行的数据访问方式ORM对象关系映射确实是绕不开的。SQLAlchemy也好、Django ORM也罢它们默认都会做查询参数的转义和绑定所以常规查询用ORM风险确实比裸写SQL低得多。但低风险不等于零风险我在项目中见过不少用着ORM却照样注入的神操作基本都踩在这几个边角上。4.1 遇到复杂查询就忍不住写原生SQLORM能覆盖大部分常规的CRUD但遇到复杂的连表查询、子查询、批量更新时很多人就嫌ORM的写法太绕选择用原生SQL字符串或者直接调text()。问题在于从ORM切换到原生SQL的那一刻很多人会把参数化查询的习惯也一起丢掉。比如# 错误的做法在SQLAlchemy里使用text()时仍然手拼字符串 from sqlalchemy import text result session.execute( text(SELECT * FROM orders WHERE user_id str(user_id)) )正确做法是把参数按text()的绑定参数语法传入result session.execute( text(SELECT * FROM orders WHERE user_id :user_id), {user_id: user_id} )4.2 动态查询构建时的半参数化陷阱另一种场景是开发者在构建动态过滤条件时前面用了ORM的安全方法但到某个需要动态追加条件的地方又突然改成字符串拼接。这样生成的SQL骨架里有一部分是参数化编译过的有一部分则完全暴露在字符串拼接之下。这种半参数化状态最容易让人麻痹大意因为大部分查询是安全的唯独漏出来那一小部分刚好就是被利用的入口。举个例子有人会先构造基础查询为了让排序条件可配置直接把前端传来的column名称拼进了order_by()表达式里query session.query(Product).order_by(text(request.args.get(sort)))如果sort传入的是(SELECT 1 FROM ...text()会把这整段原样交给数据库执行。正确的做法还是那个思路——白名单映射把可编排的范围锁死在已知字段集里。4.3 用了ORM就等于隔离了SQL的误解还有一个观念问题。有些团队觉得我们后端用的是全自动ORM不允许写SQL所以肯定安全。但ORM生成的SQL最终也要经过数据库解析只不过它内部用对了参数化机制罢了。如果ORM给了一些允许构造原始SQL的接口如text()、from_statement()、execute()这些接口的门槛比普通查询高一旦使用安全责任就全部落在开发者头上。它相当于给你发了一张名人堂入场券进不进去是你自己的选择——但进去之后一切靠自己。所以我的经验是常规查询优先走ORM的标准API确实需要原生SQL的地方必须强制使用绑定参数语法并配合代码审查。用制度弥补人性惯性比单纯指望个人自觉靠谱得多。5. 容易翻车的细节清单和我的避坑经验前面讲了不少原则现在把我在多个项目里沉淀下来的细节清单分享出来。这些细节单独看都很小但组合起来足以把SQL注入风险压到很低的水平。5.1 数据库驱动占位符风格不一致导致的低效修改不同数据库驱动占位符风格不一样项目里如果混用MySQL和PostgreSQL代码很容易出现把MySQL的%s风格直接抄到psycopg2里的情况反之亦然。尤其是psycopg2如果SQL语句里本身含有%号比如LIKE %需要写成%%转义。否则就会在执行时报TypeError: not enough arguments for format string。解决思路有两种一是项目内明确指定统一驱动不混用二是在写原生SQL时注释里标清楚当前使用的数据库类型和占位符风格降低后续维护成本。5.2 日志与监控是否会把参数打印出去我见过一个项目日志配置把SQL语句的完整文本记录到了文件里。从排查性能问题的角度看这确实方便但从安全角度看一旦有敏感数据出现日志文件本身就成了被攻击的目标。我现在的做法是日志只记录查询的表名、耗时和影响行数不记录完整SQL文本更不记录参数值。倒不是完全反对记录SQL而是得有脱敏策略和访问审计否则宁可少记一些。5.3 不要完全依赖WAF或输入过滤有不少团队会给应用前置一套WAF规则或者在后端写一个全局过滤器把常见的注入关键词如OR、AND、SELECT、--等都给屏蔽掉觉得这样就不用改代码了。这个想法很危险。理由有三攻击者可以用各种编码变形绕过关键词匹配比如URL编码、注释符拆分、大小写混淆等。过滤器如果做得太激进可能误伤正常业务输入比如用户名里带个--就注册不了账号体验极差。过滤规则维护成本极高永远在追逐攻击者的手段而不是从根本上消除问题。5.4 我的三条核心实践原则如果让我把经验浓缩成最好记的三条我会说第一任何直接传给execute()的SQL哪怕只有一个参数也必须走参数化方式。没有任何例外可言。我见过太多就这一个地方偷懒一下导致了整个系统的沦陷。第二动态表名、列名、排序字段一律进白名单不许直接拼原文。这不是技巧问题这是流程问题。凡是需要动态的元数据创建元数据映射表通过键名去索引让外部输入永远接触不到真实标识符。第三给数据库账号做最小权限切分。只读、读写、DDL分开账号业务连接用最低权限。即使出现注入攻击者能做的事也会被限制在很小的范围内。6. 回顾与一点个人感悟写到这里SQL注入从原理、攻击方式到防御方案已经聊得比较透了。回头看看最早那个简单到有点幼稚的登录拼接代码它的问题不在编码者的技术而在人们常低估一段数据被当成代码执行的影响力。SQL注入之所以致命是因为数据库系统天生无法区分边界而我们开发者的职责就是尽可能让用户输入永远停留在数据这一侧不给它任何被当作代码执行的机会。这几年的开发经历让我有一个体会安全从来不是某个环节的孤立问题而是从代码习惯、框架选型、数据库权限、日志策略到上线流程层层配合的结果。单点做好参数化能防住大多数常规攻击把整个系统的暴露面都收敛起来才能扛住那些真正有针对性的试探。最后分享一个我现在写SQL时会坚持的小习惯写任何一句涉及外部输入的SQL之前先问自己一个问题——这句话里有哪部分是攻击者能控制的如果回答是没有那就放心通过如果回答是有一点那就立刻停下来把参数改成绑定方式或者把可变部分改成白名单映射。就靠这一个问题我挡住了很多原本可能埋雷的代码。希望这个习惯对你也有用。