🔑 HTTP/2 的头部压缩:为什么 HPACK 能把几十个 Header 塞进几个字节

打开浏览器的开发者工具,随便点一个页面,你会发现每个 HTTP 请求的头部动辄几百字节——Host、User-Agent、Accept、Cookie,这些字符串翻来覆去几乎一模一样,却在每个请求里原封不动地再传一遍。HTTP/1.1 时代,大家就这么忍着,一个页面几十个请求,光头部就吃掉好几 KB。SPDY 协议最早试过给头部套 gzip,结果被安全团队拦下来了——CRIME 攻击利用压缩上下文的确定性,能从密文长度的微小差异反推出 Cookie。于是 HTTP/2 规格里出现了 HPACK,一套专门为头部设计的压缩方案。

HPACK 的核心思路是"不重复传"。它维护两张表:静态表和动态表。静态表是协议写死的 61 个常见头部字段组合,比如 :method: GET 编码成索引 2,content-type: text/html 编码成索引 24,一个字节搞定。动态表是连接级别的 FIFO 缓冲区,每个端点各自维护一份,发请求时把新出现的头部键值对追加进去,后续请求如果出现相同的字段,直接传索引号。两端同步更新,只要索引一致,解码端查表还原即可。

这里有个容易忽略的细节:动态表的大小是受限的,协议里叫 SETTINGS_HEADER_TABLE_SIZE,默认 4096 字节。新条目塞进来时如果超出上限,就从最老的开始逐个淘汰,直到总大小回到限制以内。这意味着同一个字段在不同时间点可能对应不同的索引——你以为的索引 62,经过几轮淘汰之后可能已经指向另一个字段了。编码端和解码端必须严格保持表的同步状态,任何一方的处理顺序出错,整个压缩上下文就会崩溃。

那 HPACK 怎么对付 CRIME 攻击?关键在于它不是通用压缩。gzip 或 deflate 看到重复子串就会用 back-reference 指向它,攻击者可以精心构造请求,让秘密字段(比如 Cookie)和已知明文在同一个压缩窗口里相遇,然后通过观察密文长度变化来逐字节猜出秘密。HPACK 完全不搞这种跨条目的字符串匹配——它只做整条头部字段的索引替换,秘密字段要么匹配到已有条目(长度不变),要么作为字面量原样传输(长度完全暴露),不会和邻近的已知明文产生联合压缩效应,从根本上堵死了这种侧信道。

🧪 一个 HPACK 动态表容量为 100 字节的连接,依次发送 user-agent: Chrome(40B)、accept: text/html(20B)、cookie: sid=abc(16B)、accept-language: ja(19B),哪条头部最先被淘汰?

cookie: sid=abcuser-agent: Chrome

💡 容易搞混的一点:HPACK 的索引空间里,静态表占 1–61,动态表从 62 开始递增。但动态表是头插的——最新加入的条目拿到索引 62,之前的条目索引全部 +1。所以"索引 62"指向的是最近一次加入动态表的字段,不是最老的。

#CS