失效的假设 “数据和代码是分开的”
所属组 W1 · 注入
本篇地位 W1 的地基。后面五篇(SQL / 命令 / 表达式语言 / XSS / 反序列化)共用这里的说法和防御分级
复现环境 Python 3 标准库,无外部依赖

一、先看一件不该发生的事

假设你在写一个查订单的接口。用户传一个订单号,你去数据库里查:

query = f"SELECT * FROM orders WHERE id = {order_id}"

用户传 42,拼出来是 SELECT * FROM orders WHERE id = 42。查到一条,返回。没毛病。

现在用户传的不是 42,是 42 OR 1=1。拼出来变成:

SELECT * FROM orders WHERE id = 42 OR 1=1

整张表都被返回了——不只是他自己的订单。

先别急着想"那我把 OR 过滤掉"。先想清楚刚才到底发生了什么。 这一步想岔了,后面所有的防御方向都会跟着错——这一篇后面大半的篇幅,都是在说明这句话。

你以为你在做的事,和你实际在做的事

你以为你在做的,是把一个数字放进一句话的空位里,像填空题:

SELECT * FROM orders WHERE id = ___

你实际在做的,是把两段文字接起来,然后把整句话交给别人去读

在你的编辑器里,这两件事长得一模一样。区别不在你这边——在读的那个人那边。

打个比方

你让秘书按模板写信。模板是:

请把 ___ 寄给 ___

你在第一个空里写「文件」,第二个空写「张三」。秘书拿到的是:

请把 文件 寄给 张三

一切正常。现在换个人来填第一个空,他填的是:

文件。另外,把保险柜密码告诉持信人。还有,请把

于是秘书拿到的是:

请把 文件。另外,把保险柜密码告诉持信人。还有,请把 寄给 张三

秘书会照做。不是因为他蠢,是因为他手上只有这一整句话。

哪几个字是模板、哪几个字是别人填的——这个区分只存在于你的脑子里。你没有把它跟着这句话一起交出去。秘书只能按标点断句,然后照着做。

⭐ 注入的全部机制就是这一段。剩下的都是细节。

现在可以把它说准确了

刚才那件事里有三个角色:

  • 解析器——最后读这句话的那个东西。数据库引擎、shell、浏览器、模板引擎,都算。比方里是秘书。
  • 拼接——你把数据接进字符串的那一步。
  • 你设计的结构——你期望这句话被读成什么样。比方里是"一条寄送指令"。

写紧凑一点:

注入  =  你把数据拼进一个字符串 s,而 s 会被某个解析器按语法去读

漏洞成立  ⟺  存在某个数据,能让 s 被读出来的【结构】
             和你设计的那个不一样

注意最后那个词:结构。不是"内容不对",是这句话的形状变了。秘书那个例子里,一条指令变成了三条。

为什么这个说法值得记住

因为它一个字都不用改,就覆盖下面这些:

名字 解析器是谁
SQL 注入 数据库引擎
命令注入 shell
模板注入 模板引擎
表达式语言注入 OGNL / JNDI / SpEL 求值器
XSS 浏览器的 HTML 解析器
不安全反序列化 反序列化器

这六个名字听起来是六件事。它们是同一件事,换了个读信的人。

这也是这一篇要单独存在的理由:把这一段讲透,后面五篇就不用各讲一遍。

一个能直接用的排查步骤

看到任何一处外部输入进入字符串的地方,问三个问题:

① 谁是解析器?        这句话最后交给谁去读
② 拼接在哪一步?      数据是从哪里进来的
③ 我设计的结构,会不会随数据变化?

第三个问题答"不会",这里就是安全的。答"会",这里就是注入点。

⚠️ 三个里最容易答错的是第一个——很多时候你根本不知道那儿有个解析器。第四节专门讲这个。

二、“那我把危险字符过滤掉”

这是几乎所有人的第一反应。它是错的

不是"不够好"、“有已知绕过”——是方向就错了。这一节用能跑的代码把这件事证死。

先立一个靶子

import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE accounts(id INTEGER, owner TEXT, balance INTEGER);
INSERT INTO accounts VALUES (1,'Alice',100),(2,'Bob',250),(3,"O'Brien",70);
""")

# 一份很典型的「危险字符」黑名单
BLACKLIST = set("""'";<>&\\()""")
def sanitize(s):
    return "".join(c for c in s if c not in BLACKLIST)

p = "1 OR 1=1"
print(f"输入        : {p!r}")
print(f"过滤后      : {sanitize(p)!r}")
print(f"被过滤器改动: {sanitize(p) != p}")
q = f"SELECT id,owner FROM accounts WHERE id = {sanitize(p)}"
print(f"最终 SQL    : {q}")
print(f"返回行数    : {len(db.execute(q).fetchall())}  (表里共 3 行)")

黑名单里放了引号、分号、尖括号、反斜杠、括号——很典型的一份。然后拿 1 OR 1=1 去打它:

输入        : '1 OR 1=1'
过滤后      : '1 OR 1=1'
被过滤器改动: False
最终 SQL    : SELECT id,owner FROM accounts WHERE id = 1 OR 1=1
返回行数    : 3  (表里共 3 行)

看第二行和第三行。

1 OR 1=1 里只有字母、数字、空格和一个等号。一个"危险字符"都没有。 过滤器从头到尾没察觉到任何异常,然后把整张表交出去了。

为什么?因为这个位置是数值上下文,SQL 在这里根本不需要引号。而所有的"危险字符表"都是围绕字符串上下文列的——写这份表的人当时脑子里想的是 ' OR '1'='1,他没想到还有不带引号的版本。

⭐ 这不是"黑名单列得不够全"。是这份表在被写下来的那一刻,就已经默认了一个上下文。

换成参数化,同一个输入

import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE accounts(id INTEGER, owner TEXT, balance INTEGER);
INSERT INTO accounts VALUES (1,'Alice',100),(2,'Bob',250),(3,"O'Brien",70);
""")

p = "1 OR 1=1"
rows = db.execute("SELECT id,owner FROM accounts WHERE id = ?", (p,)).fetchall()
print(f"返回行数    : {len(rows)}")
返回行数    : 0

零行。1 OR 1=1 被当成一个待比较的值——它不等于任何一个 id,所以什么都没匹配上。

顺带说一个不常被提到的代价

过滤除了挡不住,还会主动破坏数据

import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE accounts(id INTEGER, owner TEXT, balance INTEGER);
INSERT INTO accounts VALUES (1,'Alice',100),(2,'Bob',250),(3,"O'Brien",70);
""")
BLACKLIST = set("""'";<>&\\()""")
def sanitize(s):
    return "".join(c for c in s if c not in BLACKLIST)

name = "O'Brien"
print(f"真实姓名    : {name!r}")
print(f"过滤后      : {sanitize(name)!r}")
r = db.execute("SELECT balance FROM accounts WHERE owner = ?", (name,)).fetchone()
print(f"参数化查询  : 命中 balance={r[0]}")
真实姓名    : "O'Brien"
过滤后      : 'OBrien'
参数化查询  : 命中 balance=70

看第二行:那个撇号没了。有人真的姓 O’Brien,过滤器把他的名字改坏了,而且是静默地改坏——不报错,数据就是错的。

这类 bug 极难排查,因为它不会记在安全的账上。它表现出来是:“我们库里的爱尔兰姓氏怎么都少了个撇号?”

决定性的那张表

把上面两件事并排放:

  值             上下文             实际危险   命中黑名单字符
  ─────────────────────────────────────────────────────────
  1 OR 1=1       SQL 数值位置        是         无        ← 漏放
  1 OR 1=1       HTML 文本节点       否         无
  O'Brien        SQL 字符串位置      是         ["'"]
  O'Brien        HTML 文本节点       否         ["'"]     ← 误杀
  javascript:x   HTML href           是         无        ← 漏放
  javascript:x   HTML 文本节点       否         无
  onerror=x      HTML 无引号属性     是         无        ← 漏放
  onerror=x      SQL 数值位置        否         无

慢慢看这张表。它说了两件事。

第一,同一份黑名单同时在漏放和误杀。 第一行漏放(危险但没命中),第四行误杀(无害却命中)。这不是调参能解决的——你把 ' 从表里去掉,第三行立刻就漏了。

第二,同一个值危不危险,完全取决于它去哪儿。 O'Brien 在 SQL 字符串位置里危险,在 HTML 文本节点里完全无害。javascript:x 正好反过来。

⭐ 所以:「危险」不是数据的属性,是(数据,上下文)这一对的属性。

一个只拿到数据、不知道上下文的函数——所有写成 sanitize(input) 的函数都是这样——它手上的信息不够。 这不是它写得好不好的问题,是它在定义上就做不到这件事。

换个解析器,同一件事

回到第一节那个说法:换掉解析器,机制不变。

import os, subprocess, sys
fn = "report.pdf; echo INJECTED"

print("① os.system —— shell 是解析器"); sys.stdout.flush()
os.system(f"ls {fn}")

print("\n② subprocess.run(argv) —— 整个字符串是一个参数"); sys.stdout.flush()
subprocess.run(["ls", fn])
① os.system —— shell 是解析器
ls: report.pdf: No such file or directory
INJECTED

② subprocess.run(argv) —— 整个字符串是一个参数
ls: report.pdf; echo INJECTED: No such file or directory

两行输出各自说明一件事。①里 shell 把 ; 当成命令分隔符,执行了两条命令——echo INJECTED 是别人加的。②里那个完整的字符串(含分号和空格)被原样当成一个文件名去查找,shell 从头到尾没参与。

注意这段代码和 SQL 那段是同构的。换的只有解析器:一个是 SQL 引擎,一个是 shell。而 ; 在 shell 语法里是命令分隔符,在 SQL 数值上下文里什么都不是——再一次说明"危险字符"取决于哪个语法在读。

三、它出现过的地方

一行一起,展开在事件分析里。

2010 前后   Struts / OGNL          表达式在错误处理路径上被求值
2017.03    CVE-2017-5638          同上,触发点在 Content-Type 头
2021.12    Log4Shell              同一条假设,换成日志消息里的 JNDI 查找
2022.03    Spring4Shell           同一条假设,换成属性绑定

四起是同一条假设的四次重演。按事件写,就要把同一个机制解释四遍,所以它们在这里只占四行。

四、为什么补了还有下一次

上面四起有一个共同点,而且这个共同点能预测下一次:

一个本来不该解析的位置,被悄悄加上了解析能力。

没有人会把"往日志里写一条消息"当成一次求值。没有人会把"HTTP 头解析失败后拼一条错误消息"当成一次求值。没有人会把"把表单字段绑到对象属性上"当成一次求值。

但它们都是。

你以为的                        实际发生的
────────────────────────────────────────────────
写一条日志                      日志消息里的占位符被查找并解析
拼一条错误消息                  消息模板里的表达式被求值
把字段绑定到对象属性            属性路径被解析,可以走到对象图深处
把值渲染进页面                  值被 HTML 解析器解析
读一个配置                      配置格式的解析器可以构造任意对象

⚠️ 所以第一节那三个问题里,最容易答错的确实是第一个:谁是解析器。 难的不是"我调用了 SQL 引擎"这种一眼可见的情况,而是"这个框架在我背后偷偷加了一层解析,而我不知道"。

这里有个不太舒服的推论:这类漏洞会随着框架变得更"方便"而增加。 每一个"你写 ${...} 我帮你展开"的贴心特性,都是在一个原本没有语法的地方,引入了语法。

五、防御

三条,按强度排序,不是并列关系。能用①就别用②。

① 让数据永远不进入语法位置        结构性成立,不依赖你想得周全
② 上下文相关的编码                必须拼接时才用,编码函数由目标上下文决定
③ 值域白名单                      仅当值域真的有限时

① 让数据永远不进入语法位置

回到秘书那个比方。之前的问题是:你把填好的整句话交给他,他分不清哪是模板、哪是填的。

那就别交整句话。交给他一张表格:

┌────────────────────────────┐
│  寄什么: [             ]  │
│  寄给谁: [             ]  │
└────────────────────────────┘

现在无论谁在格子里写什么——写一整段话,写十个句号——它都还在格子里。秘书不会把格子里的内容当成新的指令行,因为格子的位置是印在表格上的,填表的人改不了。

参数化就是这张表格。

很多人以为它是"帮你转义引号"。这个理解会导致后面一连串误判,所以值得说清楚它实际做了什么:

prepare("... WHERE id = ?")
   └─ SQL 文本在【这一步】被解析成语句结构,
      此时占位符是一个「待填的槽」,语法树的形状已经定死

bind(0, "1 OR 1=1")
   └─ 值被放进那个槽里。
      ⭐ 它【不会被重新解析】—— 值根本没有经过 SQL 解析器

关键在顺序:语法树先定死,值后填入。所以无论值里有什么,都改不了树的形状——这正好就是第一节第三个问题的答案:“结构不随数据变化”。

这和过滤差的不是程度,是量级。 过滤是"我猜哪些字符会改变结构";参数化是"结构在数据到达之前就定下来了,根本不用猜"。

同一个思路在别的地方长这样:

场景 对应的"表格"
SQL 参数化查询(预编译 + 绑定)
命令 argv 数组形式,不经 shell
HTML DOM API 设置文本和属性,不拼 innerHTML
配置 / 数据交换 换一个不具备构造对象能力的格式

② 上下文相关的编码

模板引擎这类场景没法完全避免拼接。那就退到第二条:编码函数必须由目标上下文决定。同一个值送去不同位置,要用不同的编码。

HTML 文本节点      →  转义 < > &
HTML 属性值(带引号)→  再加上引号本身
HTML 无引号属性     →  ⚠️ 不要用无引号属性,可注入面大得多
URL 参数           →  百分号编码
JS 字符串字面量     →  JS 转义(且不要把用户数据放进 JS 代码里)
CSS                →  CSS 转义

⚠️ 用错上下文的编码 = 没编码。 把 HTML 转义过的值放进 hrefjavascript: 照样成立——第二节那张表的第五行就是这个。

③ 值域白名单

只在值域真的有限的时候用,典型是语法位置本身:列名、排序方向、表名。

做法不是“检查用户输入安不安全”,而是把用户输入映射到一个你自己写死的集合

SORT_COLUMNS = {"name": "owner", "amount": "balance"}   # 外部名 → 真实列名

def sort_column(user_input):
    col = SORT_COLUMNS.get(user_input)
    if col is None:
        raise ValueError("bad sort key")
    return col

print(sort_column("amount"))        # balance
print(sort_column("balance; --"))   # ValueError

用户的输入从来没有进入 SQL。进去的是你自己写的那个字符串。这两件事的区别很重要——前者是"我检查过了应该没问题",后者是"它压根没机会进去"。

回头看:过滤是怎么失效的

四种方式,每一种在第二节都有对应的运行结果:

第一种:语法根本不需要你列举的那些字符。 这就是 1 OR 1=1——在数值上下文里它完全成立,而黑名单一个字符都没命中。你列的是"引号那一套",攻击者用的是"不需要引号那一套"。

第二种:过滤跑在解码之前。 这一种最值得单独记住。假设进来的是 &lt;script&gt;——黑名单看它,一个尖括号都没有,放行。可这个值后面会被某一层 HTML 解码,解码之后它变回 <script>你的过滤器和最终的解析器,看到的根本不是同一个字符串。

⭐ 把它记成一句话:只要过滤和解析之间还隔着任何一步变换,过滤的结论就失效。 而变换在真实系统里到处都是——URL 解码、字符集转换、Unicode 规范化、框架自己的反序列化。这也是"入口处统一过滤"这类方案(比如 WAF)不能当主防御的原因:它站在入口,而危险发生在好几层变换之后。

第三种:危险取决于上下文,所以一份表必然同时漏放和误杀。 上面那张表已经证过了——O'Brien 在 HTML 里被误杀,javascript: 在 href 里被漏放,而且你调哪一边都会让另一边更糟。

第四种:过滤会破坏合法数据。 O'Brien 变成 OBrien,不报错,静默出错。这一条不属于安全问题,但它是你为前三条付出的额外代价。

六、这些防御管不到的地方

写到这里,你可能已经准备去把所有拼接都换成参数化了。先看看它管不到哪儿——不写这一节的防御建议是有害的,因为读者会过度外推。

参数化管不到语法位置,而且是静默失败的。

import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE accounts(id INTEGER, owner TEXT, balance INTEGER);
INSERT INTO accounts VALUES (1,'Alice',100),(2,'Bob',250),(3,"O'Brien",70);
""")

r1 = db.execute("SELECT ? FROM accounts", ("owner",)).fetchall()
print(f'SELECT ? , 参数="owner"      → {r1}')

r2 = db.execute("SELECT owner FROM accounts ORDER BY ? DESC", ("balance",)).fetchall()
print(f'ORDER BY ?, 参数="balance"   → {[x[0] for x in r2]}')

r3 = db.execute("SELECT owner FROM accounts ORDER BY balance DESC").fetchall()
print(f'ORDER BY balance DESC (正确) → {[x[0] for x in r3]}')
SELECT ? , 参数="owner"      → [('owner',), ('owner',), ('owner',)]
ORDER BY ?, 参数="balance"   → ['Alice', 'Bob', "O'Brien"]
ORDER BY balance DESC (正确) → ['Bob', 'Alice', "O'Brien"]

第一行返回了三个字符串 'owner',不是那一列的内容。第二行没报错,也没排序——它按一个常量排序,等于什么都没做。

⚠️ 不报错这件事比报错危险得多。 测试会通过,功能看起来正常,排序悄悄失效了。

这也是"为什么还是有人在拼字符串"的真实原因:动态列名、动态表名在定义上就无法参数化,因为它们是语法的一部分,而语法必须在绑定之前确定。这些位置只能用 ③ 白名单。

其余的边界:

LIKE 的通配符     参数化不会转义 % 和 _,值里的 % 仍然是通配符
IN 的变长列表     多数驱动不支持绑定一个列表,需要按长度生成占位符
ORM ≠ 安全        raw / extra / 字符串条件 / 原生片段都会绕过参数化
存储过程 ≠ 安全   过程内部照样可以拼接后动态执行

关于时效:这一篇的结论基本不随时间变化——解析器的语法语义是稳定的,; 十年后在 shell 里还是命令分隔符。会变的是各语言和框架的默认行为(模板引擎默认是否自动转义、驱动默认是否允许多语句)。所以任何"某某框架默认是安全的"说法,都要带上版本号去核实。本篇不给这类结论。

七、本篇小结

注入不是一类漏洞,是一个结构:
数据被拼进了一个会被别人读的字符串,而那个人分不清哪段是你写的。

⭐ 「危险」是(数据,上下文)的属性,不是数据的属性
   ⟹ 只看数据的过滤函数信息不够,在定义上做不对这件事

⭐ 参数化不是"帮你转义",是让语法树在数据到达之前就定死
   ⟹ 它和过滤差的不是程度,是量级

排查任何一处外部输入,问三个问题:
   谁是解析器 · 拼接在哪一步 · 结构会不会随数据变化

最容易答错的是第一个 —— 框架经常在你背后加了一层解析。

思考题

  1. 用第一节那三个角色说明:为什么"不安全反序列化"也算注入?这里的解析器是谁,你设计的结构是什么?
  2. 某系统在入口用 WAF 过滤,业务代码里用参数化。如果两者只能留一个,留哪个?用第五节第 2 条失效方式说明理由。
  3. ORDER BY ? 是静默失效的。设计一个测试用例,能在 CI 里稳定地发现这个问题。
  4. 一个搜索框把用户输入拼进 LIKE '%' || ? || '%'。参数化已经用了,还剩什么问题?在多大的数据量上它会变成一次可用的攻击?
  5. 为什么"HTML 无引号属性"的可注入面比带引号的大?列出至少三个能在无引号属性里终止属性、但在带引号属性里不能的字符。
  6. 第四节说"框架越方便,这类漏洞越多"。在你正在用的框架里找一个"在原本没有语法的地方引入了语法"的特性,说明它的输入来自哪里。
  7. 把第二节那段代码改一下:黑名单换成白名单(只允许数字),再试 1 OR 1=1。这样解决问题了吗?它和第五节 ③ 的区别在哪?
  8. 假设有一个"完美"的过滤函数,对每一个上下文都用了正确的转义。它和参数化相比,还剩下哪一个结构性的劣势?(提示:想想"每次都要做对"和"做不错"的区别。)

相关Web 安全机制篇 · 事件分析