Python SQL注入防护实战:从参数化查询到纵深防御

发布时间:2026/10/19 10:43:42
Python SQL注入防护实战:从参数化查询到纵深防御 说到 Python 里的 SQL 注入攻击我见过太多人第一反应是“我们项目用的都是参数化查询不会出事的。”但真实情况远没有这么乐观。我接手过的几个存量项目里几乎都能通过搜索框、排序参数、批量删除接口把整张表数据拖出来甚至直接改掉业务数据。SQL 注入攻击本身并不神秘真正可怕的是你觉得自己已经防住了实际上最容易被利用的几个入口恰恰藏在那些“看起来没什么问题”的代码里。这篇文章我结合自己处理过的真实问题场景把 Python 生态下的 SQL 注入防护拆开揉碎讲清楚。里面不会有空泛的安全理论更多是我们平时写接口、改老代码、做系统重构时真正会遇到的问题。适合正在做 Web 后端开发、想把自己项目里的查询彻底排查一遍的朋友看完可以直接照着改。1. 从一条带漏洞的查询开始SQL 注入是怎么被利用的1.1 注入点远不止登录框那些容易被忽略的入口一提到 SQL 注入很多人第一反应就是登录框里的用户名和密码。确实经典的 OR 11登录绕过就是这种场景。但我在实际项目里发现登录框往往已经被框架或者安全规范堵住了真正还露在外面的注入点分布得更加隐蔽。常见的高危位置有这么几类搜索功能里的模糊匹配字段比如WHERE order_no LIKE %...%用户输入直接被拼进 SQL 模板。列表页的排序参数比如ORDER BY后面的字段名很多项目是从前端直接传进来的。批量操作比如WHERE id IN (1,2,3)如果把这些 id 简单 join 成字符串再拼进 SQL就有注入风险。表名或者列名动态传入的场景比如报表系统、动态查询构造器这类系统为了“灵活”经常把表名字段名暴露给前端。LIMIT和OFFSET分页参数虽然它们的注入危害比字符串拼接小一些但 MySQL 里也存在通过LIMIT配合PROCEDURE ANALYSE这类技巧搞事的可能性不能完全放任不管。我带过一个模拟项目 X 的订单查询系统搜索框、排序、分页都有代码是典型的“能跑就行”风格。后来做安全排查的时候我把所有拼过字符串的 SQL 语句全列出来整整十来个入口几乎每个都能被利用。这不是个别现象很多老项目都是这么过来的。1.2 攻击者利用的核心逻辑闭合、注释和联合查询要理解怎么防先得知道攻击者是怎么打的。举个简单例子假设我们有这样一条 SQLSELECT id, order_no, amount FROM orders WHERE order_no LIKE %{keyword}%当用户输入的keyword是 OR 11 --拼接之后整个 SQL 就变成SELECT id, order_no, amount FROM orders WHERE order_no LIKE % OR 11 -- %拆开看其实就三步用自己带进去的单引号把原来的 LIKE 条件闭合掉。构造一个恒真条件11让整条 WHERE 过滤失效。用--把后面原本用来收尾的%注释掉避免语法错误。结果就是整张订单表全被查出来。如果攻击者再用UNION SELECT联合查询可以进一步拖出用户表、配置表、任何他有权限读的数据。如果输出被隐藏他还可以用布尔盲注或者时间盲注逐位猜解数据内容。这些技术本身不复杂但在“拼接型”代码面前成功率极高。1.3 Python 并不是天然免疫参数化的正确姿势和常见误区很多人以为 Python 自带安全光环这其实是个误解。Python 的 DB-API 规范PEP 249确实规定了参数化查询的能力但前提是你要把参数正确地传给execute()而不是自己拼好再传。看下面两种写法的区别# 错误写法看似用字符串格式化实则和直接拼接一样危险 sql SELECT * FROM users WHERE username {} AND password {}.format(username, password) cursor.execute(sql) # 正确写法参数和 SQL 模板分离 sql SELECT * FROM users WHERE username %s AND password %s cursor.execute(sql, (username, password))第一种方式哪怕你只是在某个环节里用format()做了拼接攻击者传进来的内容依然会按 SQL 代码来解析。第二种方式数据库驱动会把%s当作占位符SQL 结构先被数据库预编译用户输入只作为纯字符串参数绑定进去单引号、注释符、分号统统失去了“代码身份”。不过参数化并不是万能钥匙下面这些场景它就不管用ORDER BY后面的字段名、LIMIT后面的部分语法在不同驱动里表现不一样。动态表名、动态列名这些属于 SQL 结构的一部分不是参数。使用text()包裹但内部还是字符串拼接的 SQLAlchemy 写法。参数化是地基但不是全部。2. 被很多人忽略的防护分层别把希望全压在一条参数化上2.1 为什么单点防护不够绕过参数化的几种思路安全领域的共识是纵深防御Web 开发同样如此。参数化查询解决了 90% 的问题剩下的 10% 才是真正让你夜不能寐的部分。举个例子。某个系统里所有动态值都做了参数化但排序字段是这样的order_column request.args.get(sort) sql fSELECT * FROM products ORDER BY {order_column} cursor.execute(sql)参数化再彻底也挡不住这里的{order_column}因为排序字段在 SQL 语法里属于结构位置数据库驱动不可能帮你做语义判断。攻击者传入id; DROP TABLE products; --如果在多个语句同时执行的数据库配置下这条 SQL 可能就会产生严重后果。再比如动态表名。很多报表系统允许前端选择“查询哪张表”如果后端直接FROM {table_name}拼接攻击者传个系统表名就能读取元数据。这类风险只能靠应用层白名单去拦参数化帮不上忙。2.2 四层防护体系从 SQL 模板到数据库权限我给自己手头的项目定了一套四层防护每一层都独立存在不依赖其他层第一层是 SQL 模板层。所有 SQL 语句的静态模板写死在代码里用户输入只能作为参数绑定不允许参与 SQL 结构拼接。这是成本最低、效果最明显的一层也是整个防护体系的基石。第二层是输入校验层。不管参数化做得再好进入业务逻辑的数据都该做类型和格式校验。比如page、page_size必须是整数id必须是数字状态枚举必须来自指定集合。校验不仅是防注入也是在保护后面的业务逻辑不被脏数据干扰。第三层是动态结构白名单层。凡是 SQL 中不能参数化的位置表名、列名、排序字段、聚合函数一律使用白名单映射。前端传什么不重要代码里只认白名单里的键取不到就用默认值。这个我在下一章会完整演示。第四层是数据库权限层。应用连接数据库的账号原则上只授予业务所需的增删改查权限不给DROP、ALTER、CREATE这类高危权限。不同模块用不同的账号查询账号和写入账号分开这样即使某一层被绕过攻击者在数据库里的活动半径也被大大压缩。四层里面任何一层被攻破后面的层还能兜底。安全从来不是一道锁而是多道门。2.3 误区澄清ORM 不等于绝对安全ORM 框架比如 SQLAlchemy、Django ORM它们内置的查询构造通常走参数化但这不等于用 ORM 就万事大吉。我在评审别人代码时经常看到这种写法session.execute(text(SELECT * FROM users WHERE name name ))这种写法等于把 ORM 的安全底层全绕开了比用裸游标拼接还隐蔽因为从外观看你用的是 ORM 的execute()审查代码的人容易直接跳过。还有一种常见坑是在 ORM 里用order_by传入拼接字段query.order_by(text(request.args.get(sort)))text()本质上就是一个 SQL 片段载体里面的内容会被当作 SQL 去解析风险极高。ORM 的定位应该是帮你用更安全的方式构造查询而不是给你一个绕过安全的借口。3. 从漏洞到加固一个查询接口的完整改造实录3.1 漏洞代码体检模拟项目 X 的订单查询模拟项目 X 里有一个订单查询接口功能很简单根据订单号模糊搜索、指定排序字段、分页返回。原代码如下典型的老项目风格def search_orders(keyword, sort_field, page, page_size): sql f SELECT id, order_no, amount, status FROM orders WHERE order_no LIKE %{keyword}% ORDER BY {sort_field} LIMIT {page_size} OFFSET {page_size * (page - 1)} cursor.execute(sql) return cursor.fetchall()我拿几个经典的测试输入试了一下keyword OR 11 -- 返回了全表数据。sort_field amount; DROP TABLE orders -- 如果数据库允许多语句执行整张表直接没了。即使用户输入只是多传一个也会直接触发 500 语法错误暴露后端数据库类型。这个接口集齐了三个典型问题字符串拼接、动态结构无校验、分页参数未做强类型约束。3.2 第一步改造把动态值全部参数化先把搜索条件和分页参数用占位符传参SQL 模板变成静态字符串def search_orders(keyword, sort_field, page, page_size): sql SELECT id, order_no, amount, status FROM orders WHERE order_no LIKE %s ORDER BY %s LIMIT %s OFFSET %s cursor.execute(sql, (f%{keyword}%, sort_field, page_size, page_size * (page - 1)))这一步改完之后搜索框的注入已经堵死了。但注意ORDER BY %s还是有问题——占位符在ORDER BY后面通常会被数据库当成一个字符串字面量MySQL 里甚至可能直接报错根本达不到动态排序的效果。所以排序字段还得单独处理。3.3 第二步改造排序字段走白名单映射排序字段不能参数化那就用白名单把外部传参限制在几个固定值里SORT_COLUMNS { id: id, order_no: order_no, amount: amount, status: status, created_at: created_at, } def search_orders(keyword, sort_field, page, page_size): sort_column SORT_COLUMNS.get(sort_field, created_at) sql SELECT id, order_no, amount, status FROM orders WHERE order_no LIKE %s ORDER BY {sort_column} LIMIT %s OFFSET %s .format(sort_columnsort_column) cursor.execute(sql, (f%{keyword}%, page_size, page_size * (page - 1)))SORT_COLUMNS.get(sort_field, created_at)这一步很关键前端传什么我们都只认白名单里的键取不到就退回默认排序字段。这样即使有人传id; DROP TABLE映射结果也只是默认值created_at不可能进入 SQL。更保险的做法是连排序方向也做白名单SORT_DIRECTIONS {asc: ASC, desc: DESC} direction SORT_DIRECTIONS.get(sort_direction.lower(), DESC)3.4 第三步改造处理 LIKE 模糊匹配的转义问题很多人不知道参数化解决了注入但没解决通配符问题。用户如果输入一个%LIKE %%%会把表里所有数据匹配出来虽然不叫注入但带来的数据泄露后果和注入一样严重。所以LIKE查询里的特殊字符还要单独转义def escape_like(value): return ( value.replace(\\, \\\\) .replace(%, \\%) .replace(_, \\_) ) keyword_pattern f%{escape_like(keyword)}%SQL 里对应写成WHERE order_no LIKE %s ESCAPE \\三个字符%、_、\是模糊查询的通配符和转义符。%匹配任意多个字符_匹配任意单个字符\用来转义前面的两个符号。如果不处理用户输入_时会在查询时匹配任意字符导致结果超出预期这在配置表、编码表的精确匹配场景里尤其危险。转义之后用户不管输入什么都老老实实按字面内容去匹配。3.5 完整改造后的最终代码SORT_COLUMNS { id: id, order_no: order_no, amount: amount, status: status, created_at: created_at, } SORT_DIRECTIONS {asc: ASC, desc: DESC} def escape_like(value): return ( value.replace(\\, \\\\) .replace(%, \\%) .replace(_, \\_) ) def search_orders(keyword, sort_field, sort_direction, page, page_size): sort_column SORT_COLUMNS.get(sort_field, created_at) direction SORT_DIRECTIONS.get(sort_direction.lower(), DESC) try: page int(page) page_size int(page_size) except (TypeError, ValueError): page, page_size 1, 20 page max(page, 1) page_size min(max(page_size, 1), 100) keyword_pattern f%{escape_like(keyword)}% sql SELECT id, order_no, amount, status FROM orders WHERE order_no LIKE %s ESCAPE \\ ORDER BY {sort_column} {direction} LIMIT %s OFFSET %s .format(sort_columnsort_column, directiondirection) cursor.execute(sql, (keyword_pattern, page_size, page_size * (page - 1))) return cursor.fetchall()这个版本基本可以放心上线。改造完成后我用之前的测试 payload 重新跑了一遍搜索框注入不再起效排序字段注入被白名单拦掉分页参数无论传什么都会落到合法整数范围。提示page_size做上限控制是很有必要的。如果不限制攻击者传个9999999一次查询可能把几百万行数据拉出来数据库直接被打满接口慢慢变成灾难。虽然这不算 SQL 注入但属于同一类风险值得一起修复。4. 实操环节五个 Python 数据库驱动的参数化写法对照4.1 常用驱动的占位符差异Python 生态里数据库驱动五花八门占位符规则不完全一致。新手最容易在这个环节翻车。我整理了一份实用对照表驱动占位符参数传递示例sqlite3?cursor.execute(SELECT * FROM t WHERE id ?, (id,))psycopg2 (PostgreSQL)%scursor.execute(SELECT * FROM t WHERE id %s, (id,))PyMySQL (MySQL)%s或%(name)scursor.execute(SELECT * FROM t WHERE id %s, (id,))pyodbc (SQL Server)?cursor.execute(SELECT * FROM t WHERE id ?, (id,))asyncpg (PostgreSQL)$1、$2await conn.fetch(SELECT * FROM t WHERE id $1, id)有一个细节坑了我自己很多次单参数传参时占位符?或%s需要的是一个序列所以(id,)带逗号的才是元组(id)不带逗号就是普通值传进去直接报错“不是序列”。我见过不止一个同事在这个位置怀疑自己的数据库驱动有问题折腾半天发现就是少了逗号。写多参数时推荐用字典方式传参代码的可读性好很多sql SELECT * FROM orders WHERE status %(status)s AND created_at %(start)s AND created_at %(end)s cursor.execute(sql, {status: paid, start: 2024-01-01, end: 2024-06-01})4.2 SQLAlchemy 里动态构造安全查询的完整姿势SQLAlchemy 是 Python 最常用的 ORM只要按它的推荐方式写安全性基本有保障。关键在于别绕回text()拼接老路。常规查询from sqlalchemy import select, and_ stmt select(Order).where( and_( Order.order_no.like(keyword_pattern, escape\\), Order.status status, ) ).order_by(sort_column).limit(page_size).offset(offset) orders session.execute(stmt).scalars().all()如果确实需要用原生 SQL正确方式是配合绑定参数from sqlalchemy import text stmt text( SELECT id, order_no, amount FROM orders WHERE order_no LIKE :pattern ORDER BY id LIMIT :limit OFFSET :offset ) params { pattern: keyword_pattern, limit: page_size, offset: offset, } orders session.execute(stmt, params).all()注意:pattern、:limit这些是命名绑定参数和%s一样会把值当字符串绑定。这里有个高发雷区有人用text()包裹 SQL 以后又把参数用format()填进去比如text(... LIKE %{kw}%.format(kwkw))这种操作等于自己把防护网剪了一个洞。再怎么把结果传给execute()SQL 里已经是攻击者的代码了。排序字段在 ORM 里的安全逻辑和原生 SQL 一样还是要走白名单sort_column SORT_COLUMNS.get(sort_field, created_at) order_clause getattr(Order, sort_column) stmt stmt.order_by(order_clause.desc())4.3 批量操作和 IN 条件的安全处理批量删除、批量更新是注入重灾区。常见错误写法是把一组 id 拼接成(1,2,3)直接丢进 SQL。正确的做法是用execute()的批量接口cursor.executemany( DELETE FROM orders WHERE id %s, [(item_id,) for item_id in item_ids] )或者用动态生成占位符的方式placeholders , .join([%s] * len(item_ids)) sql fSELECT * FROM orders WHERE id IN ({placeholders}) cursor.execute(sql, item_ids)placeholders是由代码自己生成的%s列表item_ids里的每个元素都会通过参数绑定进去不存在注入窗口。但要注意len(item_ids)不能太大一次性传上万个占位符性能会很难看建议分批处理每批 500 到 1000 个。如果使用的是 SQLAlchemy直接传入列表即可stmt select(Order).where(Order.id.in_(item_ids))4.4 存储过程也不是免死金牌一些老系统为了“安全”把业务逻辑写进存储过程再由 Python 调用。存储过程本身不会自动防注入。如果存储过程内部是用动态 SQL 拼接的漏洞同样存在只是从应用层转移到了数据库层。举个例子CREATE PROCEDURE search_orders(IN kw VARCHAR(255)) BEGIN SET s CONCAT(SELECT * FROM orders WHERE order_no LIKE %, kw, %); PREPARE stmt FROM s; EXECUTE stmt; DEALLOCATE PREPARE stmt; END;这段存储过程里的kw依然可以注入。所以在审查库存量系统时我把存储过程里的动态 SQL 也视为普通 SQL 代码同等对待该参数化就参数化该白名单就白名单别因为是数据库层就想当然。5. 常见问题与排查速查参数化之后为什么还会被注入5.1 排查套路从调用形态看穿拼接真相我在帮某个项目做安全排查时见过一个特别有迷惑性的写法sql SELECT * FROM users WHERE name %s % user_input cursor.execute(sql)从形式上看它用了%s好像是在“参数化”。实际上%s % user_input是 Python 字符串格式化用户输入已经先被拼进 SQL 文本之后才传给execute()。真正的参数化必须把参数放在execute()的第二个参数里让驱动去处理绑定而不是提前在字符串层面完成替换。所以排查第一件事就是看 SQL 语句的最终形态是在哪里形成的。如果最终提交给数据库的字符串里已经包含了完整的用户输入那不管中间用了什么模板、什么格式化方式都是注入。5.2 ORDER BY、LIMIT、表名这类动态结构的注入怎么查动态结构位置是参数化的死角排查也是最费劲的。我一般从这几个方向入手全局搜索代码里的ORDER BY、GROUP BY、LIMIT、OFFSET逐个看后面跟着的是不是白名单映射。搜索text()的调用点SQLAlchemy 项目里text()是最容易被滥用的入口。搜索f前缀的字符串只要 SQL 模板用了 f-string十有八九存在拼接。搜索format(和%这两种操作符在 SQL 相关变量上的使用。我常用一个简单粗暴但很有效的检查方法给接口传一个带明显的标点符号的值比如aaabbbcc然后看数据库日志里的 SQL 语句长什么样。如果日志里出现了这个值的原始形态说明是拼接如果 SQL 模板里只有占位符日志中实际执行时才会展开参数那说明参数化是到位的。5.3 日志和报错信息泄露另一个容易忽视的风险排查过程中我还发现很多项目喜欢在日志里打印完整的 SQL 语句包括参数值。这样做本身不会引发 SQL 注入但会带来两个问题一是敏感数据泄露。SQL 语句里通常带着手机号、身份证号、密码摘要等字段值日志文件如果被拖库这些数据就全暴露了。二是报错信息返回给前端。数据库语法错误会暴露具体的关键字、表名、字段名这些信息对攻击者做二次利用非常有价值。我的建议是生产环境日志里只记录参数个数、耗时、错误码等元信息不要把完整 SQL 连同参数打出来。如果确实需要调试把日志级别调成 DEBUG 并且只在内网环境打开。5.4 常见问题速查表现象直接原因修复方式搜索框传 OR 11 --能查出全部数据SQL 字符串拼接了用户输入改为参数化查询ORDER BY传特殊字符导致 500 或语法错误排序字段直接拼接白名单映射排序字段LIMIT传超大数字拉垮数据库分页参数未限流int()强转 上限控制LIKE 查询返回了用户没预期到的数据%和_通配符未转义escape_like转义 ESCAPE \使用 ORM 仍然被注入绕过了 ORM 的查询构造器手动拼了 SQL统一走 ORM 查询方法或绑定参数单参数 execute 报错“不是序列”省略了元组逗号写成(value,)应用使用数据库 root 账号权限过大被注入后危害放大建立最小权限账号分层授权6. 最后再提醒一次把这几条变成写代码的默认习惯我处理过不少安全漏洞复盘一个反复出现的规律是几乎所有被利用的注入点都不是写了什么高深代码导致的而是图省事、赶进度、觉得“这个接口内部用没事”。安全问题的根往往不在技术上在习惯上。我自己后来定了三条默认规则想分享给你第一条写任何数据库操作之前先打标点SQL 模板里的动态值一律问自己一句“这个位置能不能用占位符”。能用占位符的地方绝不拼接不能用的地方立刻写白名单映射。这两步走完95% 的注入风险已经没了。第二条凡是用户传来的值默认都是不可信字符串。所有强类型字段先做类型校验哪怕是分页参数这种看起来无关紧要的数字也值得花两行代码去限制范围。我见过太多人只盯着搜索框和登录框最后翻车在分页参数上。第三条权限边界要卡死。应用账号、报表账号、管理账号分开不同系统用不同账号数据库账号永远不做多余授权。万一代码层被突破至少数据库这一层还能再挡住一轮。SQL 注入防护不是什么高深学问核心就是把每一次数据访问都当成潜在的被攻击入口来写。这篇文里的代码和思路你可以在自己项目里照着排查一遍重点盯搜索、排序、分页、批量操作这四类接口改完你会睡得踏实很多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询