qinghuan
← 回到知识探索

后端现场 / 动手实验

参数已经绑定,搜索 20% 为什么还是匹配了一大片?

在真实 SQLite 中分别检查 SQL 参数、LIKE 通配符和搜索产品规则,用十行对照记录说明每一层究竟改变了什么。

展开本文目录
  1. 下载完整练习包
  2. 一句输入,可能经过两套不同规则
  3. 先用四个相似标签,看清百分号和下划线
  4. 指定一个转义符,再把用户部分按文字处理
  5. 十行记录要同时保留输入、参数和结果
  6. 运行完整脚本,留下可重新检查的搜索账本
  7. 按字面搜索,仍然需要约定大小写和空输入
  8. 带回项目时,先固定搜索承诺再考虑索引
  9. 记住这三点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
  • 能读懂 SELECT、WHERE 与简单 Python 函数。
  • Python 3.10 或更新版本,包含标准库 sqlite3。
  • 知道用户输入应通过占位符传给 SQL,无需安装数据库服务。

商品标签里有“20%折扣”,你在搜索框输入“20%”,却连“200折扣”和“20_折扣”也找到了。检查代码后发现,SQL 明明用了问号占位符,没有拼接用户输入。问题并不一定是绑定失败:输入进入了另一个会解释特殊符号的地方。

本篇用六个虚构标签搭一个小搜索器,比较“用户填写模式”和“用户填写要找的文字”两种接口。产物是一份十行搜索矩阵,保留原输入、真正传入的参数和命中的编号,不依赖截图猜测发生了什么。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • like_literal.py:完整示例
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的实际输出
  • verification.json:源码与产物验证记录
  • SHA256SUMS.txt:文件内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B like_literal.py

解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。真实临时 SQLite 数据库中的参数查询与 LIKE 模式;不代表其他数据库、中文分词或搜索性能。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

一句输入,可能经过两套不同规则

先看查询的固定形状:WHERE name LIKE ?。问号代表一个值的位置,绑定让后面的输入作为这个值交给数据库。假如输入含单引号,它仍属于参数里的文字,不会因此成为 SQL 语句的新片段。表名、排序方向等结构也不能指望靠这个值占位符任意替换。

但 LIKE 的右侧本来就是一个模式。数据库拿到参数后,还会按模式规则判断哪些名称符合它。参数绑定负责交接值,并没有承诺把这个值里所有符号都改成普通文字。理解这两层,才能避免“用了问号,所以搜索含义一定正确”的推断。

我们的两种搜索都使用占位符,没有故意运行拼接 SQL 的危险分支。对比的变量只有模式怎样构造。因此观察到命中变多时,应该检查模式含义,而不是把本实验写成已经成功实施了一次 SQL 注入。

先用四个相似标签,看清百分号和下划线

编号一到四分别是“20%折扣”“200折扣”“20_折扣”“20!折扣”。原始模式分支把输入两端加上百分号,表示前后可以有其他内容。输入 20% 时,最终参数是 %20%%,中间那个百分号也成为通配符,所以四条都匹配。

输入 20_ 时,最终参数是 %20_%。下划线表示一个字符,因此百分号、数字零、下划线和感叹号都能填这个位置。这里的“一个字符”不是“一个字节”;本例也没有进一步把它承诺成人眼所见的一个完整字形。

如果产品确实提供高级模式搜索,这种行为可以是功能。但普通名称搜索往往希望用户输入什么就找什么。先写清接口承诺,再决定是否保留通配符,不能在修复时悄悄改变已经对用户开放的搜索语言。

指定一个转义符,再把用户部分按文字处理

本例给 LIKE 增加 ESCAPE '!',约定感叹号用于说明后一个特殊符号应按文字理解。literal_pattern 先把输入中的 ! 变成 !!,再处理百分号和下划线,最后才加上用于包含搜索的首尾百分号。这样用户自己的符号与程序添加的符号承担不同职责。

顺序很关键。如果先给百分号加感叹号,最后又统一把感叹号翻倍,刚刚加入的标记也可能被改变。输入 20! 的对照专门检查转义符本身,避免只测百分号通过,就误以为所有输入已经覆盖。参数仍然通过问号传入,转义函数没有替代绑定。

转义符在 SQLite 中必须是一个字符;脚本另用两个感叹号作为 ESCAPE 值,确认它会被拒绝。这是 SQL 表达式的约束,与搜索词里面允许有多少个感叹号不同。前者固定在程序里,后者由转换函数逐个处理。

十行记录要同时保留输入、参数和结果

五个输入各走两次查询,共得到十条记录。除了前面的三个符号案例,还加入 O'Reilly 和一段看起来像 SQL 条件的文字。它们都留在绑定值中;后者没有命中任何标签,标签总数仍为六,说明这份固定样本没有因为文字外观改变语句结构。

别只核对结果条数。四条变成一条,可能因为程序漏掉了全部有效名称,碰巧只留下一条。脚本按编号排序并断言完整编号列表,CSV 中也保留真正绑定的参数,读者可以把每一个百分号追溯到用户输入或程序添加。

这些样本有意很小,方便你逐条检查。它们验证本函数和当前 SQLite 环境下的语义,没有覆盖所有数据库方言、驱动设置和排序规则,更没有证明整个应用不存在其他 SQL 构造问题。

运行完整脚本,留下可重新检查的搜索账本

把下方完整代码保存为 like_literal.py,或从练习包解压得到同名文件,在该目录运行。程序只创建新的内存数据库,退出时关闭连接;不读已有数据库文件。脚本旁的 like-output 目录会留下 report.json 和 matches.csv,同名教学结果在下次运行时更新。

先预测 20% 和 20_ 的两组编号,再运行并核对。制作时使用 Python 3.14.0、SQLite 3.50.4,十条查询对照通过,末行出现 PASS。版本号也写进 JSON,你换环境后应保留自己的记录,而不是把制作时的成功当成当地执行成功。

展开收起代码 · 小实验python · 70 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Actual SQLite LIKE comparisons; only a fresh in-memory database is used."""
from contextlib import closing
from pathlib import Path
import csv
import json
import sqlite3
import sys


def literal_pattern(term):
    if not isinstance(term, str) or not term or "\x00" in term:
        raise ValueError("use a non-empty text term without NUL")
    # Escape the escape character first, then the pattern metacharacters.
    return "%" + term.replace("!", "!!").replace("%", "!%").replace("_", "!_") + "%"


def main():
    records = [(1, "20%折扣"), (2, "200折扣"), (3, "20_折扣"),
               (4, "20!折扣"), (5, "O'Reilly"), (6, "ordinary")]
    expected = {"20%": ([1, 2, 3, 4], [1]), "20_": ([1, 2, 3, 4], [3]),
                "20!": ([4], [4]), "O'Reilly": ([5], [5]),
                "' OR 1=1 --": ([], [])}
    rows = []
    with closing(sqlite3.connect(":memory:")) as db:
        db.execute("CREATE TABLE label(id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
        db.executemany("INSERT INTO label VALUES (?, ?)", records)
        for term, (raw_ids, literal_ids) in expected.items():
            for mode, pattern, sql, wanted in [
                ("bound_pattern", "%" + term + "%", "SELECT id FROM label WHERE name LIKE ? ORDER BY id", raw_ids),
                ("bound_literal", literal_pattern(term), "SELECT id FROM label WHERE name LIKE ? ESCAPE '!' ORDER BY id", literal_ids),
            ]:
                ids = [r[0] for r in db.execute(sql, (pattern,))]
                assert ids == wanted, (term, mode, ids)
                rows.append({"term": term, "mode": mode, "bound_value": pattern, "matched_ids": ids})
        assert db.execute("SELECT COUNT(*) FROM label").fetchone()[0] == 6
        case_rules = db.execute("SELECT 'a' LIKE 'A', 'æ' LIKE 'Æ'").fetchone()
        assert case_rules == (1, 0), "this lesson expects SQLite's built-in LIKE without ICU/overrides"
        try:
            db.execute("SELECT 'x' LIKE ? ESCAPE '!!'", ("x",)).fetchone()
        except sqlite3.OperationalError:
            invalid_escape_rejected = True
        else:
            raise AssertionError("two-character ESCAPE accepted")
    rejected = []
    for term in ["", "a\x00b", None]:
        try:
            literal_pattern(term)
        except ValueError:
            rejected.append(repr(term))
        else:
            raise AssertionError("invalid search term accepted")
    report = {"python": sys.version.split()[0], "sqlite": sqlite3.sqlite_version,
              "scope": "real SQLite, built-in LIKE, in-memory fixture; no performance test",
              "records": records, "comparisons": rows,
              "ascii_case_match": bool(case_rules[0]), "non_ascii_case_match": bool(case_rules[1]),
              "two_character_escape_rejected": invalid_escape_rejected,
              "rejected_terms": rejected}
    out = Path(__file__).resolve().parent / "like-output"
    out.mkdir(exist_ok=True)
    (out / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    with (out / "matches.csv").open("w", encoding="utf-8-sig", newline="") as file:
        writer = csv.DictWriter(file, fieldnames=["term", "mode", "bound_value", "matched_ids"])
        writer.writeheader()
        writer.writerows({**r, "matched_ids": json.dumps(r["matched_ids"])} for r in rows)
    print(json.dumps(report, ensure_ascii=False, indent=2))
    print("PASS: 10 bound-query comparisons, escape validation and explicit input policy")


if __name__ == "__main__":
    main()

怎样运行:python -B like_literal.py(Python 3.10+,仅标准库;在解压后的脚本目录执行。) 2026-10-15 已用 Python 3.14.0 执行这份代码并通过自带检查;下方是当次输出。真实临时 SQLite 数据库中的参数查询与 LIKE 模式;不代表其他数据库、中文分词或搜索性能。

运行后对照结果
{
  "python": "3.14.0",
  "sqlite": "3.50.4",
  "scope": "real SQLite, built-in LIKE, in-memory fixture; no performance test",
  "records": [
    [
      1,
      "20%折扣"
    ],
    [
      2,
      "200折扣"
    ],
    [
      3,
      "20_折扣"
    ],
    [
      4,
      "20!折扣"
    ],
    [
      5,
      "O'Reilly"
    ],
    [
      6,
      "ordinary"
    ]
  ],
  "comparisons": [
    {
      "term": "20%",
      "mode": "bound_pattern",
      "bound_value": "%20%%",
      "matched_ids": [
        1,
        2,
        3,
        4
      ]
    },
    {
      "term": "20%",
      "mode": "bound_literal",
      "bound_value": "%20!%%",
      "matched_ids": [
        1
      ]
    },
    {
      "term": "20_",
      "mode": "bound_pattern",
      "bound_value": "%20_%",
      "matched_ids": [
        1,
        2,
        3,
        4
      ]
    },
    {
      "term": "20_",
      "mode": "bound_literal",
      "bound_value": "%20!_%",
      "matched_ids": [
        3
      ]
    },
    {
      "term": "20!",
      "mode": "bound_pattern",
      "bound_value": "%20!%",
      "matched_ids": [
        4
      ]
    },
    {
      "term": "20!",
      "mode": "bound_literal",
      "bound_value": "%20!!%",
      "matched_ids": [
        4
      ]
    },
    {
      "term": "O'Reilly",
      "mode": "bound_pattern",
      "bound_value": "%O'Reilly%",
      "matched_ids": [
        5
      ]
    },
    {
      "term": "O'Reilly",
      "mode": "bound_literal",
      "bound_value": "%O'Reilly%",
      "matched_ids": [
        5
      ]
    },
    {
      "term": "' OR 1=1 --",
      "mode": "bound_pattern",
      "bound_value": "%' OR 1=1 --%",
      "matched_ids": []
    },
    {
      "term": "' OR 1=1 --",
      "mode": "bound_literal",
      "bound_value": "%' OR 1=1 --%",
      "matched_ids": []
    }
  ],
  "ascii_case_match": true,
  "non_ascii_case_match": false,
  "two_character_escape_rejected": true,
  "rejected_terms": [
    "''",
    "'a\\x00b'",
    "None"
  ]
}
PASS: 10 bound-query comparisons, escape validation and explicit input policy

按字面搜索,仍然需要约定大小写和空输入

本例采用 SQLite 内置 LIKE,没有加载 ICU 扩展或覆盖同名函数。它同时执行 a 与 A、æ 与 Æ 两个对照,得到前者匹配、后者不匹配。转义了通配符,并不会自动获得面向所有语言的大小写折叠,更不等于统一了所有看起来相似的文字。

函数另行拒绝空文字、含 NUL 的文字和非字符串输入。这是本小应用主动选择的输入政策:空搜索不自动展示全部标签,避免读者误以为任何空输入都应该变成两个百分号。它不是 SQLite 的通用限制,也不能直接替业务决定空搜索的用户体验。

如果需要区分大小写、支持全角半角转换或按语言分词,应另定规则并增加相应样本。不要把这些要求偷偷塞进名为 escape 的函数,让调用方无法判断它到底改变了哪些字符和搜索含义。

带回项目时,先固定搜索承诺再考虑索引

检查实际代码时,可以沿三步走:SQL 结构是否固定,输入作为哪个参数传入,这个参数又被 LIKE、正则或别的语言怎样解释。只做第一步检查,会漏掉搜索规则的差异;只做符号转义,又可能忘掉输入仍应绑定。

本实验在模式两侧加百分号,专注于包含搜索的正确性,没有比较索引计划或查询耗时。生产搜索需要多少条、怎样排序、能否接受全表扫描,都要用相应数据库与数据规模另测。一个十行正确性矩阵不能当作性能报告。

最后让维护者能从一条失败查询还原原始搜索词、构造后的模式和实际命中项。不要在真实日志中无条件收集私人搜索内容;这里能完整公开,是因为六条标签和五个输入全部为教学虚构数据。

记住这三点

  • 参数绑定固定值与 SQL 结构的边界,LIKE 仍会解释参数里的模式符号。
  • 字面包含搜索需要同时处理转义符、百分号和下划线,再添加程序自己的通配符。
  • 按完整命中编号核对结果,并把大小写、空输入与性能要求分别定义。

别把例子用过头

  • 把 LIKE 命中变多直接当成 SQL 结构注入。
  • 先处理百分号,再把自己加的转义符一并改坏。
  • 认为转义后所有语言的大小写行为都一致。

自己试一小步

为一组新的文件标签设计字面搜索,怎样证明输入中的符号没有扩大匹配范围?

  1. 自拟至少八个标签,包含连续感叹号、多个百分号、下划线和混合大小写,不沿用本文标签。
  2. 先写出四个搜索词在两种模式下的完整预计编号列表。
  3. 修改脚本副本运行,保留原输入、绑定值和实际结果,逐项比较预测。
  4. 单独说明空输入的产品政策,并新增一个会触发它的检查。

做到这些,就算完成

  • 没有把预期答案直接写进搜索函数。
  • 每一条命中都有对应标签,不能只交条数。
  • 能区分 SQL 绑定、LIKE 模式和产品输入政策三种规则。
  • 没有把这次语义验证扩写为索引或吞吐量已经验证。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • SQLite:表达式中的 LIKE 与 ESCAPE (新标签页)↗SQLite 官方文档 · 英文

    核读第 4、5 节:参数位置、百分号和下划线含义、单字符 ESCAPE,以及默认 ASCII 与非 ASCII 大小写差异。

    2026-10-15 实际核读所列范围;六条标签、十组查询和输入政策是原创实验;未加载 ICU。

  • Python 3.14:sqlite3 的参数占位符 (新标签页)↗Python 官方文档 · 英文

    核读占位符指南、execute 与连接上下文管理器说明,确认值通过参数交接,以及连接需要明确关闭。

    2026-10-15 实际核读所列范围;本次实跑 3.14.0,未执行文档中的不安全 SQL 拼接分支。

  • SQLite:向预备语句绑定值 (新标签页)↗SQLite 官方文档 · 英文

    核读 host parameters 与 bind 接口说明,支持参数为预备语句的值位置;不把该接口说成 LIKE 转义器。

    2026-10-15 实际核读所列范围;仅核读接口语义,未直接调用 C API。