| 失效的假设 | “数据和代码是分开的” |
| 所属组 | 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 转义过的值放进 href,javascript: 照样成立——第二节那张表的第五行就是这个。
③ 值域白名单
只在值域真的有限的时候用,典型是语法位置本身:列名、排序方向、表名。
做法不是“检查用户输入安不安全”,而是把用户输入映射到一个你自己写死的集合:
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——在数值上下文里它完全成立,而黑名单一个字符都没命中。你列的是"引号那一套",攻击者用的是"不需要引号那一套"。
第二种:过滤跑在解码之前。 这一种最值得单独记住。假设进来的是 <script>——黑名单看它,一个尖括号都没有,放行。可这个值后面会被某一层 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 里还是命令分隔符。会变的是各语言和框架的默认行为(模板引擎默认是否自动转义、驱动默认是否允许多语句)。所以任何"某某框架默认是安全的"说法,都要带上版本号去核实。本篇不给这类结论。
七、本篇小结
注入不是一类漏洞,是一个结构:
数据被拼进了一个会被别人读的字符串,而那个人分不清哪段是你写的。
⭐ 「危险」是(数据,上下文)的属性,不是数据的属性
⟹ 只看数据的过滤函数信息不够,在定义上做不对这件事
⭐ 参数化不是"帮你转义",是让语法树在数据到达之前就定死
⟹ 它和过滤差的不是程度,是量级
排查任何一处外部输入,问三个问题:
谁是解析器 · 拼接在哪一步 · 结构会不会随数据变化
最容易答错的是第一个 —— 框架经常在你背后加了一层解析。
思考题
- 用第一节那三个角色说明:为什么"不安全反序列化"也算注入?这里的解析器是谁,你设计的结构是什么?
- 某系统在入口用 WAF 过滤,业务代码里用参数化。如果两者只能留一个,留哪个?用第五节第 2 条失效方式说明理由。
ORDER BY ?是静默失效的。设计一个测试用例,能在 CI 里稳定地发现这个问题。- 一个搜索框把用户输入拼进
LIKE '%' || ? || '%'。参数化已经用了,还剩什么问题?在多大的数据量上它会变成一次可用的攻击? - 为什么"HTML 无引号属性"的可注入面比带引号的大?列出至少三个能在无引号属性里终止属性、但在带引号属性里不能的字符。
- 第四节说"框架越方便,这类漏洞越多"。在你正在用的框架里找一个"在原本没有语法的地方引入了语法"的特性,说明它的输入来自哪里。
- 把第二节那段代码改一下:黑名单换成白名单(只允许数字),再试
1 OR 1=1。这样解决问题了吗?它和第五节 ③ 的区别在哪? - 假设有一个"完美"的过滤函数,对每一个上下文都用了正确的转义。它和参数化相比,还剩下哪一个结构性的劣势?(提示:想想"每次都要做对"和"做不错"的区别。)