| 失效的假设 | “这段字节只有一种解析结果,所有组件的理解都一样” |
| 所属组 | W5 · 内存与解析 |
| 代表问题 | HTTP 请求走私、解析器差异(parser differential) |
| 复现环境 | Python 3 标准库,无外部依赖 |
一、危险不在解析错,在两个人解析得不一样
有个用户投诉说,他点开自己的订单页,看到的是别人的订单。
你去查日志。他那个请求好好的,参数没问题,返回也没问题。你查了会话、查了缓存、查了鉴权,全都正常。你甚至没法复现——刷新一下就好了。
问题不在他的请求里。问题在上一个人的请求里:那个请求同时写了 Content-Length: 6 和 Transfer-Encoding: chunked,而你的前端代理按前者读、后端服务器按后者读。
没有哪一方解析错了。 可它们对「这个请求到哪结束」给出了不同答案,而差额那几个字节,成了下一个人请求的开头。
上一篇是"信任了对方的长度"。这一篇更微妙:没有哪一方解析错,但两方解析得不一样。
打个比方
一份合同签了中英两个版本,都说"以本文本为准"。
中文本写"三十日内交付",英文本写 “within thirty business days”。翻译的人当时觉得这俩差不多。
于是甲方按工作日算,乙方按自然日算。没有人读错自己手上那份,两边各自都能拿出白纸黑字。可"交付日"这一天,两边差了两周。
⭐ 麻烦的地方在于:这个问题不在任何一份文本里。 你把中文本从头到尾审一遍,挑不出毛病;英文本也一样。bug 只存在于"两份都算数"这件事本身。
⚠️ 这一节是整个系列里最反直觉的一篇。前面十六篇训练你的是"找出哪里写错了",而这里哪里都没写错——要找的是两个正确实现之间的落差。第一次读会觉得抓不住东西,这是正常的。
现代 Web 请求往往要经过好几道手:CDN、负载均衡、反向代理、WAF,最后才到你的后端。每一道都要解析这个请求——它到哪结束、body 有多长、路径是什么。只要任意两道对同一段字节的理解出现分歧,分歧处就是一个攻击面。
最经典的是 HTTP 请求走私,它的分歧来自一个历史包袱:一个请求可以用两种方式声明 body 长度——Content-Length(明确字节数)和 Transfer-Encoding: chunked(分块,读到 0 块结束)。当两者同时出现、而前后端各信一个,灾难就发生了。
二、复现那个分歧
要证明"两个解析器理解不一样",得真的拿两个解析器。下面这两个都是现实里存在的东西:
- 解析器 A:Python 标准库的 HTTP 头解析(
http.client用的是email那一套,遵循 RFC 关于"折行续行"的规定)。真实的后端服务器就是这么解析的。 - 解析器 B:很多代理、中间件、日志分析器手写的那种"按行切、按冒号分"的十行解析。
喂给它们同一段字节。这段字节有一处不起眼的构造:Transfer-Encoding: 后面是空的,值被放到了下一行、以一个空格开头——这在 RFC 里是合法的"折行续行"写法。
import http.client, io
raw = (b"POST / HTTP/1.1\r\n"
b"Host: x\r\n"
b"Content-Length: 6\r\n"
b"Transfer-Encoding:\r\n" # 值是空的……
b" chunked\r\n" # ……真正的值在下一行,以一个空格开头
b"\r\n")
# 解析器A:标准库(遵循 RFC 的折行续行规则)—— 真实后端就是这么解析的
h = http.client.parse_headers(io.BufferedReader(io.BytesIO(raw.split(b"\r\n", 1)[1])))
print("解析器A(标准库) Transfer-Encoding =", repr(h.get("Transfer-Encoding")))
# 解析器B:代理/中间件里很常见的手写解析 —— 按行切、按冒号分
def naive(block):
d = {}
for line in block.decode().split("\r\n"):
if ":" in line:
k, v = line.split(":", 1); d[k.strip().lower()] = v.strip()
return d
b = naive(raw.split(b"\r\n\r\n")[0])
print("解析器B(手写切行) Transfer-Encoding =", repr(b.get("transfer-encoding")))
print()
print("A 认为这是个 chunked 请求吗:", bool(h.get("Transfer-Encoding", "").strip()))
print("B 认为这是个 chunked 请求吗:", bool(b.get("transfer-encoding")))
解析器A(标准库) Transfer-Encoding = 'chunked'
解析器B(手写切行) Transfer-Encoding = ''
A 认为这是个 chunked 请求吗: True
B 认为这是个 chunked 请求吗: False
同一段字节,两个都真实存在的解析器给出了相反的答案。
而且请注意:没有哪一个解析错了。A 按 RFC 处理了折行,完全正确;B 的十行代码在 99.99% 的正常请求上也完全正确——它只是不知道有"折行"这回事。两边各自跑单元测试都是绿的。
现在把这个分歧接到真实架构上。前端代理是 B,后端服务器是 A:
前端代理(B) 没看见 Transfer-Encoding,于是按 Content-Length: 6 读 body
=> 认为请求在第 6 个字节处结束,后面的字节属于【下一个请求】
后端服务器(A) 看见了 Transfer-Encoding: chunked,按分块读,读到 "0\r\n\r\n" 就结束
=> 它认为请求在别的地方结束
两者对"这个请求到哪结束"的判断错开了 —— 错开的那几个字节,
就是攻击者能塞进去的东西,它会被拼到【下一个用户的请求】前面。
⭐ 后果很严重:攻击者能用这段走私前缀篡改下一个受害者的请求——把受害者的请求重定向到攻击者控制的路径、往受害者的请求头里注入内容、或让受害者收到被投毒的响应。而这一切两端各自看都"正常",因为没有哪一方觉得自己解析错了。
⚠️ 顺带说一句为什么这个坑填不完:HTTP 里能制造这种分歧的构造有很多——Content-Length 出现两次、Transfer-Encoding: chunked 后面跟个别的值、头名字里混进空格或制表符、大小写变体。每一种都是在赌"某两个组件对这个畸形写法的处理不一样",而组合数量随着链路上组件的增多而爆炸。这就是下一节说的"缝"。
三、为什么这类问题这么难发现
普通漏洞你能在一个组件里审出来。解析器差异不行——每个组件单独看都没问题,问题只在它们的组合里显现。
单看前端代理:Content-Length 解析正确 ✓
单看后端服务器:chunked 解析正确 ✓
合起来:两者对"请求边界"理解不一致 ✗ ← bug 只存在于这里
⚠️ 这就是"解析器差异"作为一类问题的核心难点:它不属于任何一个组件,它属于组件之间的缝。 代码审计、单元测试、扫描器都盯着单个组件,正好看不见缝里的东西。这和第 5 篇 XSS"输出点散落在整个前端"、第 2 篇"拼接点很多"是同一种困境的变体——风险在边界上,而工具盯着的是内部。
四、通用教训:同一份输入,别让多个解析器各自理解
请求走私只是一个著名实例。这个模式到处都是:
两个组件解析同一个 URL => 权限检查按解析A放行,实际访问按解析B => 绕过鉴权
前端和后端解析同一个文件名 => 校验看到 a.jpg,存储看到 a.php => 绕过类型限制
WAF 和应用解析同一个参数 => WAF 看到无害,应用看到 payload => 绕过 WAF
Unicode 规范化前后不一致 => 校验和使用看到的不是同一个字符串
它们的根是同一句:只要一份输入会被多个组件分别解析,你就必须保证它们的理解一致,否则攻击者会构造一个"在 A 眼里是 X、在 B 眼里是 Y"的输入。
防的方向也统一:
① 消除歧义输入 请求走私:出现 CL+TE 冲突就直接拒绝整个请求(别猜哪个对)
② 只解析一次 规范化/解析在最前面做一次,后续组件用同一个结果,别各解析各的
③ 组件间理解要对齐 前后端用同一套解析规则;WAF 和应用对输入的规范化必须一致
⭐ 第 ① 条是请求走私的标准解法,也最能体现思路:遇到会产生分歧的输入,不要试图"正确地解析"它,而要直接拒绝。 因为"正确"本身有歧义——这正是问题的来源。这又是"默认拒绝"(第 12 篇)在解析层的化身。
这一类没有单一的标志性公开事件,它以大量分散的个案存在;有技术细节的会收进事件分析。
五、防御
⭐ 回到那份双语合同:正确的做法不是去裁定"到底哪个版本算数"——一旦需要裁定,你就已经输了。正确的做法是在签字之前就发现两个版本不一致,然后拒绝签。下面第 ① 条就是这句话。
① 歧义即拒绝 CL 与 TE 同时出现 => 拒绝;任何"两种合法解读"的输入都拒绝
② 一次解析,全程复用 在入口规范化/解析一次,下游共享结果,杜绝"各解析各的"
③ 前后端解析规则对齐 同一套 HTTP/URL/编码解析实现或严格一致的规范
④ WAF 与应用规范化一致 否则 WAF 看到的和应用看到的不是同一份输入
⑤ 减少中间跳数 每多一道解析就多一处潜在分歧(架构层的最小化)
判断标准:这份输入,会不会被两个及以上的组件分别解析? 会——它们对"边界/编码/路径"的理解一定要能对齐,对不齐就得靠 ①"歧义即拒绝"。
六、这些防御的边界
① “只解析一次"在分布式架构里很难彻底。 CDN、云 WAF、网关往往不是你能改的,它们各有各的解析。你能做的是选择行为可控、且遵循严格规范的组件,并在自己的边界上"歧义即拒绝”。
② 规范化本身可能引入新差异。 你为了对齐而加的规范化步骤,如果和某个下游的规范化不同,反而制造了新的分歧。对齐要端到端验证,不是各加各的规范化。
③ 这类问题需要专门的差异测试。 常规测试喂"正常输入",看不出分歧。要专门喂"歧义输入"(CL+TE、畸形编码、多重解码),对比各组件的解析结果是否一致——这是一种和功能测试思路相反的测试(呼应第 11 篇的越权测试)。
关于时效:解析器差异是结构性问题,只要架构里有多个解析器就存在。具体的走私技法随 HTTP 版本、代理实现演进(HTTP/2 降级走私等是较新的变体),涉及具体技法要按当年的研究核实;但"对齐多个解析器"这条原则不变。
七、本篇小结
危险不在"解析错了",在"两个解析器把同一段字节解析成不同的东西"。
⭐ HTTP 请求走私:请求同时带 Content-Length 和 Transfer-Encoding,
前端信 CL、后端信 TE => 对"请求在哪结束"分歧 => 分歧处塞进走私请求
=> 篡改下一个受害者的请求/响应,而两端各自看都"正常"
⭐ 难发现:bug 不属于任何单个组件,只在组件【之间的缝】里
—— 代码审计/扫描器盯着组件内部,正好看不见缝
通用教训:一份输入被多个组件分别解析,就必须保证理解一致。
① 歧义即拒绝(别试图"正确解析"歧义输入——歧义本身就是问题)
② 一次解析、全程复用 ③ 前后端/WAF 与应用的解析规则对齐
思考题
- §一 说"没有哪一方解析错,但两方解析得不一样"。为什么这比"某一方解析错了"更难修?该去修哪一方?
- §二 的例子里,如果前后端都信 Content-Length(或都信 TE),还会有走私吗?由此说明走私的根是"不一致"而非"某个头危险"。
- §四 列了 URL、文件名、WAF 三个解析差异的例子。任选一个,说明攻击者要构造一个"在 A 眼里是 X、在 B 眼里是 Y"的输入,需要知道什么。
- 为什么"歧义即拒绝"比"按 RFC 规定的优先级正确解析"更安全?(提示:你的后端遵循 RFC,但你不能保证上游的代理也遵循同一版 RFC。)
- §六② 的"一次解析、全程复用"和 §七① 的"分布式里很难彻底"矛盾吗?在你无法控制 CDN/WAF 解析的现实里,你的边界上还能做什么?
- 解析器差异的测试"要喂歧义输入、对比各组件解析结果"。这和第 11 篇的越权测试、常规功能测试在思路上有什么共同点?为什么这类测试很少有人写?
- 把这一篇和第 16 篇(越界读)放一起:它们都在 W5"内存与解析"下,但一个是"信任了对方的数字"、一个是"两个组件理解不一致"。它们共同的上层主题是什么?和第 1 篇"谁是解析器"有没有关系?
相关:第 16 篇:越界读 · 第 1 篇:注入的一般形式 · 第 12 篇:纵向越权 · Web 安全机制篇 · 事件分析