☰
Python防SQL注入:从攻击原理到参数化查询与ORM防护
2026/10/20 13:13:52 网站建设 项目流程

很多刚接触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 '1'='1

拼接后:

SELECT * FROM users WHERE username = 'admin' OR '1'='1' AND password = 'x'

注意运算符优先级,OR的优先级低于AND,所以这句等价于查询所有用户,并从中取任意一条。在登录场景里,只要能查出一条,就算登录成功。攻击者只要随便构造一个能返回结果的值,就有机会以数据库中第一个用户(很可能是管理员)的身份进入系统。

1.2 注入类型不只是"能查出来"这么简单

很多新手以为SQL注入就是让登录框变得"好骗",其实攻击者的手法远比这个多样。我在实际排查和复现中,把常见的注入手法归纳成了下面几种:

注入类型攻击思路典型判断方式攻击目标
联合查询注入在原查询后追加UNION SELECT,直接读取其他表数据通过报错信息判断字段数,再尝试逐步对齐列数脱库、读取敏感字段
布尔盲注通过条件表达式的真/假,观察页面响应差异,逐字符推断数据尝试AND 1=1与AND 1=2的返回是否不同无回显场景下的数据窃取
时间盲注利用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 status=1的语句出现了异常的通配符组合。问题出在搜索框。当时的代码是这样的:

def search_products(keyword): conn = get_connection() sql = "SELECT * FROM products WHERE name LIKE '%{}%' AND status=1".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 从搜索框到信息泄露:一次注入的完整分析步骤

继续说上面那个搜索框案例。发现问题后,我并没有直接"修复"就完了,而是把攻击链完整走了一遍,目的是确认到底有没有造成数据泄露。

排查步骤如下:

  1. 先在测试环境复现该接口,用'单引号作为搜索关键词传入,观察响应是否出现数据库报错。完整重现了:系统会直接返回形如OperationalError: unrecognized token: "'"的异常信息。
  2. 继续传入' OR 1=1 --,发现返回结果变成了全量数据。此时可以确认,拼接点真实存在,且OR逻辑成功执行。
  3. 进一步尝试' UNION SELECT sql FROM sqlite_master --,发现系统直接返回了建表语句。这就意味着,整个库的表结构与字段名已经完全暴露。
  4. 接着可以按图索骥,继续构造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(host="localhost", user="app", password="secret", database="app_db") cursor = conn.cursor() cursor.execute( "SELECT * FROM users WHERE username = %s AND password = %s", (username, password) ) # psycopg2(PostgreSQL),同样是 %s 占位 import psycopg2 conn = psycopg2.connect("host=localhost dbname=app user=app password=secret") cursor = conn.cursor() cursor.execute( "SELECT * FROM users WHERE username = %s AND password = %s", (username, password) )

提示:不管是?还是%s,都不要自己去拼字符串把它们替换掉。参数必须作为独立参数传给execute(),而不是先%格式化再传入。

参数化查询的效果立竿见影。比如之前那个登录查询,如果改成参数化写法,即使用户名传admin' --,数据库也会把这一整串内容当成一个"用户名值"去匹配,而不会把它当作SQL语法去解释。--在数据值里不再有注释功能,OR '1'='1'也不再是逻辑表达式,只是普普通通的字符串内容。

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( col=order_col, dir=direction )

只需要保证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 遇到复杂查询就忍不住写原生SQL

ORM能覆盖大部分常规的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之前,先问自己一个问题——"这句话里有哪部分是攻击者能控制的?"如果回答是"没有",那就放心通过;如果回答是"有一点",那就立刻停下来,把参数改成绑定方式,或者把可变部分改成白名单映射。就靠这一个问题,我挡住了很多原本可能埋雷的代码。希望这个习惯对你也有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询