网关加 gzip,同时开启 chunked,下载回来的包解不开:把两层编码的解压顺序与长度核对做成只读页

图 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 六条判据,五分钟定位包坏在哪一层

按顺序过,遇上第一条命中的就停:

  1. 响应头里同时有 Transfer-Encoding 和 Content-Encoding 时,客户端是否先剥分帧?没剥就去 gunzip,现场 A 的 BadGzipFile(b'32') 会直接出现。
  2. Content-Encoding 是列表时,是否按头里声明的顺序倒序解压?gzip, gzip 解一次的长度是 41 而非 21。
  3. 解压后的明文长度与前端或业务侧拿到的字节数是否一致?不一致就说明中间某一层多解或少解了。
  4. 网关出口是否二次压缩?看 Via、Age、X-Cache 一类头有没有出现,再对一下 Content-Encoding 的层数。
  5. 客户端库是否已经替你解了一层?requests 默认解 gzip 也剥 chunked,标准库默认一层都不碰。
  6. 是否存在 br 或 deflate 混用?标准库不内置 br 解码,本篇不展开;混用时按头的倒序逐个处理,认不出来就直接判 unsupported-encoding,不要猜着解。

第 5 条是排查时最容易漏的。换库测试前先确认手里那份字节到底是哪一层,不然你以为换了库,其实换了层。

10 把核对页做成只读,别做成抓包代理

核对页只暴露四条白名单路由:

  • GET /
  • HEAD /
  • GET /healthz
  • GET /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                # 靶站日志

执行顺序:

  1. python3 mock_encoding_site.py 8140,看到 mock listening 8140。
  2. python3 exp.py,按本文第 3~7 节的顺序输出 A/B/C/D/E。
  3. 核对:A 的线缆 61 / 实体 50 / 明文 840;C 的 59 / 41 / 21;E 的 BadGzipFile(b'He')。
  4. 跑完 kill %1 关掉靶站。

数字性质说明:文中所有字节数、hex 前缀、报错文本,都是本机实测(Python 3.14.3),靶站只监听 127.0.0.1:8140。requests 那组对比也是本机实测(requests 2.34.2)。本轮没有起 cpolar 隧道,没有抓任何外部站点,全文没有第三方服务参与。

相关推荐
小嘉丶学长1 小时前
Day14 · 字典与集合
java·开发语言·网络·python·学习·1024程序员节
迪菲赫尔曼1 小时前
【基础篇 11】把官方源码放到服务器:版本、解压层级和安装位置,一次弄清楚
运维·服务器·开发语言·人工智能·python·yolo·目标检测
用户8356290780511 小时前
Python 调整 Excel 工作表显示设置
后端·python
小咕聊编程2 小时前
教育大数据可视化系统
python·django·flask
lusklusklusk2 小时前
Python 巡检脚本:进程运行状态、数据库写入时效检查及邮件通知与告警
数据库·python·oracle
Angeldream1232 小时前
第 10 章 医学影像伦理与安全
图像处理·人工智能·python·安全·机器学习·医学影像
勤奋的大熊猫2 小时前
Python 双层 enumerate 循环选取最后一层循环的优雅写法
python
JYCJ_2 小时前
星愿池·子项清单
python
Wang's Blog2 小时前
Java 项目实战: 交友项目 - 动态查询开工:昨日回顾与今日任务
java·python·交友