参数化查询通过不同的接口参数传递数据库指令与实际取值。数据库把指令解释为 SQL,再把各个参数用于需要值的位置。这种边界既适用于 SQLite 这样的嵌入式引擎,也适用于客户端连接服务端的数据库;它不依赖一次网络交互,更不要求应用显式管理命名的服务端语句。重点在真实调用如何传值,而不是有没有某个名为“预编译”的包装函数。

坏掉的样子

字符串拼接会拆掉这条边界。姓名中的一个引号可能提前结束原本的字面量,后面的文本就有机会变成条件或注释。仅仅禁用分号无法修复问题,因为攻击者可以在不追加第二条语句的情况下,改变同一条查询的含义。同样,一个接口即使拒绝一次执行多条语句,也仍然需要正确绑定参数,不能把这个限制当作注入防护已经完成的证据。

绑定可以防止值变成 SQL 语法,但所选运算符仍会按自身规则解释这个值。对于 LIKE,百分号匹配一段字符,下划线匹配一个字符。因此,一个经过绑定的搜索串,仍可能比用户期望的普通文本搜索匹配更多记录。这属于模式语义的问题,不说明绑定失效,却说明“输入不会执行成 SQL”还不等于“搜索符合产品要求”。

另外一些错误也不会造成注入,却会给出错误结果。把逗号分隔的文本交给一个 IN 占位符,它仍是一个标量,不会自动展开成列表。把列名当作值绑定,得到的仍是文本,不是列引用。因此,类型检查、允许的查询结构与业务权限,都需要在绑定之外独立成立。

快速验证

直接运行下面的完整 Python 脚本,只需要标准库 sqlite3。它创建内存数据库作为样本,完成断言后关闭连接。记录中的运行环境为 Python 3.12.14 与 SQLite 3.53.1。故意保留的脆弱查询仅用于比较三个虚构用户的结果,不连接任何真实业务数据:

import sqlite3

with sqlite3.connect(':memory:') as db:
    db.execute('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)')
    db.executemany('INSERT INTO users VALUES (?, ?)', [(1, 'Alice'), (2, 'A_1'), (3, 'Ab1')])
    payload = "' OR 1=1 --"
    # Deliberately vulnerable comparison, only against this in-memory fixture.
    unsafe = "SELECT id FROM users WHERE name='" + payload + "' ORDER BY id"
    assert sorted(db.execute(unsafe).fetchall()) == [(1,), (2,), (3,)]
    assert db.execute('SELECT id FROM users WHERE name=?', (payload,)).fetchall() == []
    print('concatenated: 3 rows; bound: 0 rows')
    for value in ("O'Reilly", r'a\b', 'x; DROP TABLE users; --'):
        assert db.execute('SELECT ?', (value,)).fetchone() == (value,)
    assert db.execute('SELECT count(*) FROM users').fetchone() == (3,)
    print('quote, backslash, semicolon: literal round trips')
    assert db.execute('SELECT id FROM users WHERE name LIKE ? ORDER BY id', ('A_1',)).fetchall() == [(2,), (3,)]
    literal = 'A_1'
    escaped = literal.replace('!', '!!').replace('%', '!%').replace('_', '!_')
    assert db.execute("SELECT id FROM users WHERE name LIKE ? ESCAPE '!' ORDER BY id", (escaped,)).fetchall() == [(2,)]
    assert db.execute('SELECT id FROM users WHERE name=?', (literal,)).fetchall() == [(2,)]
    print('bound LIKE: 2 rows; escaped LIKE and equality: 1 row')
    assert db.execute('SELECT id FROM users WHERE id IN (?)', ('1,3',)).fetchall() == []
    assert db.execute('SELECT id FROM users WHERE id IN (?,?) ORDER BY id', (1, 3)).fetchall() == [(1,), (3,)]
    assert db.execute('SELECT ? FROM users LIMIT 1', ('name',)).fetchone() == ('name',)
    assert db.execute('SELECT id FROM users ORDER BY id LIMIT ? OFFSET ?', (1, 1)).fetchall() == [(2,)]
    assert db.execute('SELECT id FROM users ORDER BY id LIMIT ?', (-1,)).fetchall() == [(1,), (2,), (3,)]
    assert db.execute('SELECT id FROM users ORDER BY id LIMIT ?', (0,)).fetchall() == []
    print('IN needs separate values; bound column is a literal; LIMIT/OFFSET bind')
    print('negative LIMIT removes the cap; zero LIMIT returns no rows')
db.close()

拼接查询返回三个编号,绑定查询面对相同输入则没有任何匹配。恶意输入中的注释还会删掉原先追加的排序部分,因此断言先对脆弱查询的结果排序,再比较编号集合;实验没有依赖未声明顺序的查询恰巧按插入顺序返回。这也展示了注入会改变原指令的多个部分,不只是多匹配一行那么简单。

接下来的断言确认引号、反斜杠和带有分号的字符串全部原样往返,表中记录数仍为三。这些检查真正调用了 SQLite 驱动,并非模拟字符串转义,也不是看查询文本中有没有问号。审阅真实调用点时,应把 SQL 文本与参数传入方式一起看,不能只审一个看似安全的查询模板,却忽略调用方已经提前拼接了值。

绑定后的模式 A_1 同时匹配名字 A_1 和 Ab1。指定感叹号作为转义字符并转义下划线后,结果才缩小到原样名字。如果搜索文本本身可能包含感叹号,必须先转义这个转义字符,再处理百分号和下划线。若需求只是完全相等匹配,直接使用等号更简单,在本例中也只返回那个原样名字,没有必要先引入模式语义再尝试消除它。

单个参数中的 1,3 没有匹配任何编号;两个占位符分别绑定两个整数,才返回编号一和三。后一个查询返回字面文本 name,说明值占位符不能用来选择某列。最后,数量和偏移量都采用数字绑定,返回编号二。分页不需要把数字先转换为字符串再拼进 SQL。SQLite 的 LIMIT 绑定负一会取消数量上限,绑定零则不返回记录;新增断言展示了,即便语法传参安全,应用仍需独立规定页大小范围,不能把已绑定整数直接当成资源限制已经成立。

检查完整数据路径

占位符必须符合实际驱动接口。Python 的 SQLite 接口支持问号与命名参数,Psycopg 则使用另一套记法。参数集合应独立传入,不应先做宿主语言的格式化。即使某个值来自自己的数据库,再次用于查询时也仍是数据;它曾经被存储过,并不能使下一次把它拼进 SQL 的操作自动安全。持久化来源不是语法信任标记。

对于长度变化的列表,只构造必要的占位符结构,再逐项绑定;驱动提供受支持的集合能力时,可按其文档使用。同时定义空列表语义,并遵守参数数量上限。动态标识符和排序关键字需要在允许的查询结构中选择;对任意 SQL 文本删掉几个标点,并不能可靠替代这项选择,也可能改变合法输入的含义。

即使所有值都已绑定,仍应限制页大小,并按对象检查权限。参数化保护的是 SQL 语法边界,不是整个业务操作。验证应同时覆盖正常返回、看起来像攻击的字符串以及所用运算符的特殊输入,避免一次安全改写悄悄扩大搜索范围、改变排序,或者因为原本就缺少权限条件而继续暴露其他用户的记录。

延伸阅读

来源