图 0 封面:一条下载箭头被 chunked 与 gzip 两层壳包着,剥壳顺序错了,最里层的明文就永远拿不到
网关加 gzip,同时开启 chunked,下载回来的包解不开:把两层编码的解压顺序与长度核对做成只读页
同事上午发来一句话:「同一个静态包,走网关下载回来 gzip -t 报错,换回内网直连就好的。」他把包发我,我拿过来先看了一眼响应头:
Content-Type: text/plain
Content-Encoding: gzip
Transfer-Encoding: chunked
问题不在压缩本身。Content-Encoding 和 Transfer-Encoding 是两条不同的轴,一个说「这份字节被谁压过」,一个说「这些字节怎么切成块运过来」。客户端把它们当成一件事处理,先解压就会撞在第一个十六进制长度行上。
这篇不讲网关怎么配,也不讲 gzip 能省多少带宽。只做一件事:在本地起一个靶站,把四种编码组合的响应真跑一遍,把「线缆字节 / 剥完分帧的实体字节 / 解压后的明文字节」三个数并排打出来,让你下次拿到坏包时,能指出它坏在哪一层。
图 1 chunked 是逐跳、只管怎么运,gzip 是端到端、只管内容长什么样,先运到再拆内容
1 先把两个头的职责拆开
Content-Encoding 是端到端语义。它描述实体字节被压过几次、用什么压的,值可以是逗号分隔的列表,比如 gzip, gzip 就代表压了两层。接收方解压时按列表倒序解。
Transfer-Encoding 是逐跳语义。chunked 只说明这段字节在传输层被切成了「长度行 + 数据 + CRLF」的帧,中间每一跳都可以自己决定怎么切。接收方拿到的是帧,不是实体。
这两条轴正交,所以顺序是死的:传输层还原在前,内容层解码在后。
为什么是按「逐跳 vs 端到端」决定?因为逐跳的东西只对当前这条 TCP 连接负责,代理可以在转发时重新分块甚至把 chunked 拆成 Content-Length;端到端的东西一路传到最终接收方都不会变。既然分帧只活在一跳之内,它就必须在进入下一跳之前被剥干净。浏览器和客户端拿到响应时,chunked 这一层已经在它手上,而 gzip 那一层必须由它自己解。
一句话记住:先剥分帧,再解压,顺序反了必错。
2 环境准备:一个靶站,四个 case
/tmp/encoding-lab 下面一个文件就够。裸 socket 手拼响应,不经过任何框架的封装,这样线缆上是什么字节,我们就能看到什么字节。
bash
mkdir -p /tmp/encoding-lab
cd /tmp/encoding-lab
python3 -V # 本文实测环境:Python 3.14.3
靶站主体:
python
# /tmp/encoding-lab/mock_encoding_site.py
import socket, threading, gzip, sys
PLAIN = b"Hello encoding stack\n"
def build(case):
if case == "gzip-only":
body = gzip.compress(PLAIN)
head = (b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n"
b"Content-Encoding: gzip\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"Connection: close\r\n\r\n")
return head + body
if case == "double-gzip":
body = gzip.compress(gzip.compress(PLAIN))
head = (b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n"
b"Content-Encoding: gzip, gzip\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"Connection: close\r\n\r\n")
return head + body
if case in ("chunked-then-gzip", "gzip-then-chunked"):
body = gzip.compress(PLAIN * 40) # 840 字节明文,压完 50 字节
chunks = [body[i:i+256] for i in range(0, len(body), 256)]
framed = b"".join(b"%x\r\n" % len(c) + c + b"\r\n" for c in chunks) + b"0\r\n\r\n"
head = (b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n"
b"Content-Encoding: gzip\r\nTransfer-Encoding: chunked\r\n"
b"Connection: close\r\n\r\n")
return head + framed
if case == "claim-gzip-but-plain":
head = (b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n"
b"Content-Encoding: gzip\r\n"
b"Content-Length: " + str(len(PLAIN)).encode() + b"\r\n"
b"Connection: close\r\n\r\n")
return head + PLAIN
if case == "claim-plain-but-gzip":
body = gzip.compress(PLAIN)
head = (b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"Connection: close\r\n\r\n")
return head + body
raise KeyError(case)
def handle(conn, path):
try:
data = build(path.strip("/"))
except KeyError:
data = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
conn.sendall(data)
conn.close()
def main(port):
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", port))
s.listen(16)
print("mock listening", port, flush=True)
while True:
c, _ = s.accept()
req = c.recv(4096).split(b"\r\n")[0].decode("latin-1")
threading.Thread(target=handle, args=(c, req.split(" ")[1]), daemon=True).start()
if __name__ == "__main__":
main(int(sys.argv[1]))
端口 8140 是本篇预留的核对页端口,这里先拿来做靶站,前后命令保持一致,少一个变量。起起来:
bash
cd /tmp/encoding-lab
nohup python3 mock_encoding_site.py 8140 > mock.log 2>&1 &
sleep 1
curl -sS -D- -o /dev/null http://127.0.0.1:8140/gzip-only
看到 HTTP/1.1 200 OK 和 Content-Encoding: gzip 就算靶站活着。如果连不上,先看 mock.log 有没有打印 mock listening 8140,没打印就是脚本报错退出了,跟端口无关。
这里有个坑要提前说:http.client 会自动替你解 chunked。 后面的现场 A 如果用 http.client 读,读到的 50 字节是已经剥完分帧的实体,看不到线缆上的 32\r\n 长度行。想看线缆原始字节,必须用裸 socket 一次 recv 到底。这也是很多人本地反复试都复现不出来的原因之一,第 8 节展开。
3 现场 A:同时开 chunked 和 gzip,一次解压必然失败
探测脚本用裸 socket,把响应头和正文字节原样拿回来:
python
# /tmp/encoding-lab/exp.py(片段)
import socket, gzip, binascii
def wire(path):
s = socket.create_connection(("127.0.0.1", 8140), timeout=5)
s.sendall(("GET %s HTTP/1.1\r\nHost: 127.0.0.1:8140\r\n"
"Connection: close\r\n\r\n" % path).encode())
buf = b""
while True:
d = s.recv(65536)
if not d:
break
buf += d
s.close()
head, _, body = buf.partition(b"\r\n\r\n")
hdrs = {}
for line in head.split(b"\r\n")[1:]:
k, _, v = line.partition(b":")
hdrs[k.decode().lower()] = v.strip().decode()
return hdrs, body
对 /chunked-then-gzip 跑出来的结果,本机实测(Python 3.14.3):
| 处理方式 | 拿到的字节数 | 开头字节(hex) | 结果 |
|---|---|---|---|
| 不做任何处理(线缆原样) | 61 | 33320d0a1f8b08000000 |
第一个字节是 0x33,也就是字符 3 |
直接 gzip.decompress(wire) |
--- | --- | 抛 BadGzipFile("Not a gzipped file (b'32')") |
| 先剥 chunked 再 gunzip | 实体 50 → 明文 840 | 1f8b08000000 |
明文首行 Hello encoding stack |
三个数连起来看就清楚了。线缆上 61 字节 = 2 字节长度行 32 + \r\n + 50 字节实体 + 2 字节 \r\n + 末尾的 0\r\n\r\n。剥完分帧是 50 字节,gzip 魔数 1f 8b 在这里才露出来。解完是 840 字节明文。
BadGzipFile 这句报错别急着当成「包坏了」的证据。它说的是 Not a gzipped file (b'32')------b'32' 正是那个十六进制长度行。也就是说,喂给解压器的根本不是压缩数据,是分帧头。服务端压得好好的。
4 现场 B:把顺序反过来,两种错法
规范讲一百遍不如跑一次。两种反向顺序都实跑,结论是「不是偏好问题,是必错」。
在带 chunked 的响应上先 gunzip(就是现场 A 第二行):
BadGzipFile("Not a gzipped file (b'32')")
出错位置在 gzip.decompress 的头部校验,连解压都还没开始。
在不带 chunked 的响应上先剥分帧 (对 /gzip-only 执行 strip_chunked):
ValueError('subsection not found')
/gzip-only 线缆是 41 字节,开头 1f8b08000000000002fff348。按分帧解析时,程序把开头的 1f8b 当成十六进制长度(= 8075),然后去找紧随其后的 \r\n,找不到,bytes.index 抛 ValueError。
两头都堵死。这也解释了为什么「先剥分帧还是先解压」不是可以商量的实现细节:两个头的存在本身就是答案。
剥分帧的函数长这样,只负责把帧拼回实体,不关心内容:
python
def strip_chunked(b):
out, i = b"", 0
while True:
j = b.index(b"\r\n", i)
size = int(b[i:j], 16)
if size == 0:
return out
out += b[j+2:j+2+size]
i = j + 2 + size + 2
图 2 现场 B:在线缆上直接 gunzip 撞在 b'32' 上,在纯 gzip 上先剥分帧撞在 subsection not found 上
5 现场 C:双层 gzip,必须倒序解两次
Content-Encoding 的值是列表,gzip, gzip 就是压了两层。靶站 /double-gzip 的响应,本机实测(Python 3.14.3):
CE: gzip, gzip wire: 59 head_hex: 1f8b08000000000002ff93ef
once len: 41 head_hex: 1f8b08000000000002fff348
once as utf8: UnicodeDecodeError: 'utf-8' codec can't decode byte 0x8b in position 1
twice len: 21 first line: Hello encoding stack
解一次得到 41 字节,开头还是 1f8b------那是里层 gzip 的头。拿它当明文去 decode,报错落在 0x8b 上:这个字节在 gzip 魔数第二位上,几乎等于告诉你「我只解了一层」。
解两次得到 21 字节,正好是 Hello encoding stack\n 的长度。
长度差就是判据:59 → 41 → 21 。如果只解一次就当成终态,你手里的「明文」长度会比真实明文长,而且解出来的东西不可读。反着数层数也行:头里写了几次 gzip,就解几次,顺序倒着来。
层数要设上限。解压层数上限 2 层、解压后总字节上限 256KB,超过就判 suspicious-nesting 停下,不要继续往里解。双层 gzip 是现成的解压炸弹壳子。
6 现场 D:把三个长度一次打出来
Content-Length 在不同层里根本不是同一个东西,混着用就永远对不上账。
- 出现
Transfer-Encoding: chunked时,Content-Length必须被忽略,规范里它被 chunked 取代。本机实测里/chunked-then-gzip的 CL 就是None。 - 剥完分帧后的实体字节数,才是「压缩后那份数据」的长度。
- 解压后的明文长度,才是业务真正要的长度。
四个 case 一次跑完,本机实测(Python 3.14.3):
| case | Content-Length | Transfer-Encoding | Content-Encoding | 线缆字节 | 实体字节 | 明文字节 |
|---|---|---|---|---|---|---|
| /gzip-only | 41 | 无 | gzip | 41 | 41 | 21 |
| /double-gzip | 59 | 无 | gzip, gzip | 59 | 59 | 21 |
| /chunked-then-gzip | 无 | chunked | gzip | 61 | 50 | 840 |
| /gzip-then-chunked | 无 | chunked | gzip | 61 | 50 | 840 |
/gzip-then-chunked 和 /chunked-then-gzip 数字完全一样,这是对的:先压缩再分块,和先分块再压缩,在接收方看来是同一个东西------都是「线缆上分帧包着一份 gzip 字节」。两条路走到客户端手上,剥法一样。
自查函数把三个数都打出来,哪层不对一眼可见:
python
def audit(path, host="127.0.0.1", port=8140, max_bytes=262144, max_layers=2):
hdrs, body = wire_headers_and_body(path, host, port) # 同上 wire(),拆分头和体
ce = hdrs.get("content-encoding", "")
te = hdrs.get("transfer-encoding", "")
layers = [x.strip() for x in ce.split(",") if x.strip()]
if len(layers) > max_layers:
return {"verdict": "suspicious-nesting", "ce_layers": len(layers)}
entity = strip_chunked(body) if te == "chunked" else body
plain = entity
for name in reversed(layers): # 关键:倒序
if name != "gzip":
return {"verdict": "unsupported-encoding:%s" % name}
plain = gzip.decompress(plain)
if len(plain) > max_bytes:
return {"verdict": "suspicious-nesting", "decoded_bytes": len(plain)}
return {
"declared_content_length": hdrs.get("content-length"),
"wire_bytes": len(body),
"entity_bytes": len(entity),
"decoded_bytes": len(plain),
"chunk_stripped_first": te == "chunked",
"verdict": "ok",
}
declared_content_length 这一列在 chunked 下是 None,这本身就是结论,不要拿它去和 decoded_bytes 比大小。
图 3 现场 C:59 → 41 → 21,解一次得到 41 仍是 gzip 字节,解两次才是 21 字节明文
7 现场 E:头撒谎的两种畸形,线上最难查
网关声称压缩了,后端其实原样返回明文。客户端按头强行 gunzip:
/claim-gzip-but-plain CL 21 wire 21 hex 48656c6c6f20656e
BadGzipFile("Not a gzipped file (b'He')")
48 65 是 He,明文首字母。报错里那个 b'He' 就是证据:头说 gzip,字节是文章。
反过来,声称没压但实际压了:
/claim-plain-but-gzip CL 41 wire 41 hex 1f8b080000000000
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x8b in position 1
没有 Content-Encoding,客户端不会解压,直接把 41 字节当文本 decode,撞死在 0x8b 上。这两种在线上都是「头是对的,字节是错的」,而包的字节又是完好的------所以你无论重下几次都一样。
判据只看一个字节:正文首字节是 0x1f 且第二个是 0x8b,就是 gzip 字节;否则就是明文。拿这个和 Content-Encoding 对照,撒谎的那一方立刻现形。
8 为什么本地几乎复现不出来,过网关才炸
三个理由,都在这台机器上能验证。
第一,本机直连只有一层。 后端回 text/plain 明文,客户端 code 一切正常。一过网关,出口加了 gzip,变成两层或一层压缩,同一份客户端代码表现不同。
第二,客户端库会替你解一层。 用 requests 2.34.2 打同样四个靶站,本机实测:
gzip-only CE gzip n 21 b'Hello encoding stack'
chunked-then-gzip CE gzip TE chunked n 840 b'Hello encoding stack'
double-gzip CE gzip, gzip n 21 b'Hello encoding stack'
requests 把 chunked 剥了,也把 gzip 解了,双层 gzip 它一次解到底,你什么错都看不到。这不是 requests 有问题,是它把「传输层 + 内容层」都替你做完了。标准库的 gzip.decompress 一层都不碰。所以拿 requests 测通的代码,换成标准库上线就炸,反过来也一样。
顺带说清楚:http.client 剥 chunked,不 解 gzip;gzip.decompress 解一层,不剥 chunked。两个工具各管一半,很多人以为其中一个管全部。
第三,网关出口会二次压缩。 上游已经 Content-Encoding: gzip,网关再压一次,头变成 gzip, gzip,客户端每解一层就少一层可解的,解到只剩里层 gzip 头时就会报错。看 Via、Age、X-Cache 这类头能判断中间有没有人动过字节。
要确认是「链路那位动过字节」,最省事的办法是把这张核对页发出去,让对方从他自己的网络打开。用 cpolar 一行就够:
bash
cpolar http 8140
免费随机地址 24 小时内不保证还是同一个域名,隧道重启会变。要让对方明天、下周回来对着同一张对照表看,需要基础套餐及以上的固定二级子域名。要让对方的构建脚本按固定 TCP 端口直连取这份核对结果,需要专业套餐及以上。注意这里的 8140 是核对页端口,本机 mock 靶站也用 8140,核对页不要和模拟靶站同时起,否则端口会撞。
图 4 六条判据清单,按顺序过,命中第一条就停
9 六条判据,五分钟定位包坏在哪一层
按顺序过,遇上第一条命中的就停:
- 响应头里同时有
Transfer-Encoding和Content-Encoding时,客户端是否先剥分帧?没剥就去 gunzip,现场 A 的BadGzipFile(b'32')会直接出现。 Content-Encoding是列表时,是否按头里声明的顺序倒序解压?gzip, gzip解一次的长度是 41 而非 21。- 解压后的明文长度与前端或业务侧拿到的字节数是否一致?不一致就说明中间某一层多解或少解了。
- 网关出口是否二次压缩?看
Via、Age、X-Cache一类头有没有出现,再对一下Content-Encoding的层数。 - 客户端库是否已经替你解了一层?
requests默认解 gzip 也剥 chunked,标准库默认一层都不碰。 - 是否存在
br或deflate混用?标准库不内置 br 解码,本篇不展开;混用时按头的倒序逐个处理,认不出来就直接判unsupported-encoding,不要猜着解。
第 5 条是排查时最容易漏的。换库测试前先确认手里那份字节到底是哪一层,不然你以为换了库,其实换了层。
10 把核对页做成只读,别做成抓包代理
核对页只暴露四条白名单路由:
GET /HEAD /GET /healthzGET /replay/{case}
{case} 只接受 gzip-only、double-gzip、chunked-then-gzip、gzip-then-chunked 四个固定枚举值,其余一律 404。POST、PUT、PATCH、DELETE 一律 405,响应头带 Allow: GET, HEAD。白名单外的路径,比如 /cases、/probe.json,返回 404。/replay 不接受任何 query 参数,也不接受 host、port、url。目标清单只来自启动参数 --cases 指向的本地 JSON。
原因很直接:只要它肯按别人给的地址去抓一次,它就是一个挂在公网上的「抓包 + 解压」代理,也就是 SSRF 入口。给同事开的便利,等于给陌生人开了一扇门。
11 不做的三件事
- 不接受任意 host、port、url。
- 不代理、不转发第三方请求。
- 不提供「输入 URL 帮你抓包解压」的功能。
这三条写死了,核对页才有资格只干一件事:把它自己缓存的那几个 case 在内存里重放一遍,把三层长度摊开给你看。
12 可执行自检
改完代码跑一遍,每一行都要有输出:
bash
# 1 healthz
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8140/healthz # 期望 200
# 2 首页结论
curl -sS http://127.0.0.1:8140/ | grep -c '先剥分帧,再解压,顺序反了必错' # 期望 1
# 3 gzip-only 头
curl -sS -D- -o /dev/null http://127.0.0.1:8140/replay/gzip-only | grep -i 'content-encoding' # gzip
# 4 chunked-then-gzip 两个头
curl -sS -D- -o /dev/null http://127.0.0.1:8140/replay/chunked-then-gzip | grep -Ei 'transfer-encoding|content-encoding'
# 5 非法 case
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8140/replay/badcase # 期望 404
# 6 写方法
curl -sS -D- -X DELETE -o /dev/null http://127.0.0.1:8140/ | grep -Ei '^HTTP|^allow' # 405 + Allow: GET, HEAD
# 7 顺序反了必错
python3 -c "import gzip,urllib.request,socket;print('见现场 B')" # 实跑见 exp.py 输出
# 8 双层长度差
python3 exp.py | grep 'once len' # 41
# 9 二次解压
python3 exp.py | grep 'twice len' # 21
第 7 条不要靠眼睛记,直接跑 exp.py,看到 BadGzipFile("Not a gzipped file (b'32')") 才算过关。第 8、9 条的数字差 41 与 21,就是「解一次远远不够」的长度证据。
13 总结
跑完这四个 case,手里应该有两样东西:一张表,和一句顺序规则。
- 线缆字节、实体字节、明文字节是三个不同的数,
Content-Length在 chunked 下根本不存在。 Transfer-Encoding是逐跳的,Content-Encoding是端到端的,所以先剥分帧、再解压,反过来两种走法都实测会报错。gzip, gzip要倒序解两次,59 → 41 → 21 的长度差是最快的判据。
下次再遇到「你下是好的、我下是坏的」,别急着重下,也别急着怀疑后端。把这页发出去,让对方从他自己的出口打开,两边把三层长度贴在同一张表上------是哪一层被改的,一行就能看出来。
图 5 只读围栏:只认四个 GET 路由,写方法 405 带 Allow,任意 host 与 url 一律拒绝
隔离环境中的可执行实验提纲
目录结构:
/tmp/encoding-lab/
mock_encoding_site.py # 裸 socket 靶站,四个正常 case + 两个畸形 case
exp.py # 现场 A/B/C/D/E 探测脚本
mock.log # 靶站日志
执行顺序:
python3 mock_encoding_site.py 8140,看到mock listening 8140。python3 exp.py,按本文第 3~7 节的顺序输出 A/B/C/D/E。- 核对:A 的线缆 61 / 实体 50 / 明文 840;C 的 59 / 41 / 21;E 的
BadGzipFile(b'He')。 - 跑完
kill %1关掉靶站。
数字性质说明:文中所有字节数、hex 前缀、报错文本,都是本机实测(Python 3.14.3),靶站只监听 127.0.0.1:8140。requests 那组对比也是本机实测(requests 2.34.2)。本轮没有起 cpolar 隧道,没有抓任何外部站点,全文没有第三方服务参与。