Python中操作SQLite时,字符串格式化拼SQL语句的做法仍然随处可见。通过f字符串或百分号把用户输入直接嵌入WHERE条件,代码写起来短,但等于把SQL语法控制权交给了外部输入。攻击者只要能构造出包含单引号、注释符或恒真条件的字符串,就可能让一条查询返回不应暴露的数据。要真正解决问题,需要让数据与SQL结构分离,Python标准库sqlite3的参数化查询提供了这个能力。下面先从漏洞成因入手,再说明参数化查询的正确用法和底层原理。

SQL注入到底是怎么发生的
SQLite的sqlite3模块提供了execute方法直接执行SQL语句。快速实现的脚本通常会把变量拼进SQL字符串,例如使用f字符串:
import sqlite3
conn = sqlite3.connect("example.db")
cursor = conn.cursor()
username = "' OR 1=1 --"
password = "任意密码"
sql = f"SELECT * FROM users WHERE name = '{username}' AND password = '{password}'"
cursor.execute(sql)
rows = cursor.fetchall()
print(rows)
当username的值为' OR 1=1 --时,最终执行的SQL相当于:
SELECT * FROM users WHERE name = '' OR 1=1 --' AND password = '任意密码'
SQLite会把--后面的内容当作注释忽略,因此实际生效的条件变成name='' OR 1=1。由于1=1恒为真,整条查询会返回users表中的所有行。如果这是登录校验逻辑,攻击者不需要知道任何账号密码就能拿到全部用户数据。
比恒真条件更危险的是联合查询注入。攻击者可能输入' UNION SELECT id, password FROM admins --,如果原查询列数匹配且数据库权限足够,就能把管理员表的内容带出来。虽然SQLite默认单条语句执行限制了堆叠注入,但SELECT、UNION、子查询等方式仍然足够造成严重数据泄露。因此,彻底修复问题不能靠过滤单词或手动加转义符,而要从SQL执行机制上切断用户输入参与语法解析的可能。
参数化查询的两种标准写法
sqlite3模块的execute方法支持参数绑定,最简单的是问号占位符。SQL语句中需要插入值的位置写成?,实际数据通过第二个参数以元组形式传入。这样SQL模板和参数值被分开传递给数据库驱动:
import sqlite3
conn = sqlite3.connect("example.db")
cursor = conn.cursor()
username = "' OR 1=1 --"
password = "任意密码"
sql = "SELECT * FROM users WHERE name = ? AND password = ?"
cursor.execute(sql, (username, password))
rows = cursor.fetchall()
print(rows) # 只会返回真实匹配的行,不会把输入当SQL执行
问号占位符按照参数顺序依次对应,适合参数较少的场景。当SQL条件很多时,只靠位置容易记错,可以使用命名占位符。sqlite3支持:name形式的命名参数,传入字典即可:
sql = "SELECT * FROM users WHERE name = :name AND password = :password"
cursor.execute(sql, {"name": username, "password": password})
rows = cursor.fetchall()
批量操作同样应该参数化。循环拼接多条INSERT语句效率低,而且一旦某条数据里出现特殊字符就容易出错。使用executemany配合参数列表可以一次性完成批量写入:
users = [("alice", "pass1"), ("bob", "pass2"), ("carol", "pass3")]
cursor.executemany("INSERT INTO users(name, password) VALUES (?, ?)", users)
conn.commit()
无论问号占位符还是命名占位符,核心逻辑都一样:SQL语句在传给数据库时已经固化了结构,参数值不会与SQL关键字混在一起。开发者不需要手动处理单引号、双引号、反斜杠或分号,数据库驱动会负责保证这些内容只作为数据出现。
参数化查询为什么能防注入
数据库执行一条SQL语句通常分为解析和绑定两个阶段。SQLite在准备阶段会先扫描SQL模板,完成词法分析、语法分析,生成对应的执行计划。参数占位符在这个阶段被识别为值的位置,而不会参与语法结构构建。真正的参数值在执行阶段才被绑定到已经解析好的语句上,不再重新解析。因此攻击者输入里的单引号、--注释符、OR关键字等,都只能停留在字符串内部,无法改变SQL语句结构。
字符串拼接之所以危险,是因为用户输入直接进入了SQL解析过程。比如单引号可以闭合前面的字符串字面量,--可以注释掉后面的条件,OR 1=1可以制造恒真表达式。手动转义单引号的方案并不可靠,不同数据库对反斜杠、引号、注释符的规则存在差异,而且开发者很容易漏掉某个入口。参数化查询把这件事从应用代码转移到了数据库驱动层,驱动根据数据库类型做正确的值编码,比应用层自行猜测要可靠得多。
参数化查询并非只适用于SELECT语句。INSERT、UPDATE、DELETE以及建表、索引操作中涉及具体值的部分都可以使用占位符。常见误区是认为参数化只能在查询时生效,实际上只要SQL结构固定、数据从外部传入,就应该使用参数绑定。另一个误区是试图把参数占位符用在表名或列名上,比如SELECT * FROM ?,这实际不能按预期工作,也达不到防注入效果。标识符的拼接需要另一套方案。
动态表名和列名仍需白名单校验
占位符只能绑定值,不能绑定表名、列名、排序字段等标识符。如果代码写成SELECT * FROM ? WHERE id = ?,SQLite不会把第一个?理解为表名,而是当作一个字符串表达式,最终语法错误或逻辑错误。同理,ORDER BY ?也无法按列排序。因此当业务确实需要根据用户选择动态生成表名或列名时,不能依赖参数化查询,必须通过白名单或映射来控制。
白名单校验的思路是:用户只能从预先定义好的集合中选择,实际拼进SQL的字符串来自受信任的代码而不是原始输入。下面是一个简单示例:
ALLOWED_TABLES = {"users", "orders", "products"}
table = input("输入表名: ")
if table not in ALLOWED_TABLES:
raise ValueError("非法表名")
sql = f"SELECT * FROM {table} WHERE id = ?"
cursor.execute(sql, (record_id,))
这里table变量只可能等于users、orders或products之一,用户输入无法随意进入SQL结构。而id的值仍然使用占位符参数绑定。对于列名也可以采用映射方式,例如将用户可见的sort_by字段映射到真实列名:column_map = {"name": "user_name", "time": "created_at"},再从中取值拼接。
防注入的核心原则可以概括为:值用参数化,标识符用白名单。除此之外,还应当给数据库账号配置最小权限,避免在代码中使用executescript直接运行来自用户的文本,并控制错误信息回显,不让堆栈或SQL语句暴露给前端。只要养成先写SQL模板、再填参数的习惯,绝大多数SQLite注入风险都能在代码层面被消除。

