一条QUIC连接拉垮HTTP/3服务器——QPACK手风琴内存放大攻击实战复现与检测

声明:本文全部实验在本地隔离环境内闭环完成,目标进程均为自行部署的服务端与官方发布的漏洞版本,不涉及任何未授权的第三方系统。文中复现步骤仅用于安全研究与防御建设,不得在未获授权环境中执行。

1 研究背景

2026年10月20日,FoxIO研究员Sébastien Féry发布了针对HTTP/3实现的内存放大攻击研究Header Accordions(图1为原文页面)。这项工作把Gal Bar Nahum在HTTP/2上的MadeYouReset思路推进到了QPACK压缩层:攻击者在线上发送极小的编码块,服务端解码时被迫分配几十到几千倍的内存,单条QUIC连接即可击穿按默认配置部署的服务。

图1 FoxIO原始研究页面,官方演示以OOMKilled=true收场

研究的披露时间线值得留意:quic-go早在2025年11月就收到报告并于11月21日发布修复(CVE-2025-64702),Wireshark与Cloudflare quiche也先后修补(CVE-2025-13945、CVE-2026-12523),而Bun、h3、aioquic、mitmproxy、lsquic在博客发布时仍未修复。也就是说,这份研究同时描绘了一条时间轴——同一攻击在不同实现上的修复进度相差近一年。本文除复现攻击本身外,还完成了三件原文之外的事:核查发布五天后Bun与aioquic两条线的修复状态(Bun已随1.4.4修复,aioquic仍无修复版);区分“库层瞬时放大”与“应用层驻留放大”两种危害形态;实测三个实现的SETTINGS指纹并整理成对照。

2 QPACK手风琴的放大原理

2.1 QPACK与动态表

HTTP/3的头部压缩由QPACK(RFC 9204)承担,机制与HTTP/2的HPACK同源:99项静态表存放常见头部,运行期动态表存放新插入的字段。差异在于QUIC的流可能乱序到达,QPACK因此把动态表的插入指令剥离到一条独立的单向编码器流上,请求流的头部块只携带索引引用,并声明所需插入计数,解码器在动态表内容齐备前挂起等待。

这个设计对压缩非常友好:以FoxIO原文举的例子,一条71字节的strict-transport-security头部编码后缩成1字节的静态表索引。但它同时意味着,线上1字节的引用在解码端要展开成完整的字段对象。压缩比有多高,放大比就有多高——这正是“手风琴”这一名称的由来:压得多紧,拉开就有多长。

2.2 两种攻击变体

PoC提供了两种把预算换成内存的构造。

第一种是unique-name变体:在1 MB字节预算内枚举1至4字符的头部名(值为单字符,跳过te),塞满一个HEADERS帧。实测每帧可容纳160075条字段,服务端必须为它们各自分配字符串对象。第二种是动态表引用变体:先经编码器流插入一条4064字节的表项(22字节名+4042字节值),再在请求流里重复发送指向它的1字节引用。

我用与PoC相同的构造写了一个编码层转储,把这两种变体的账算开(图2):编码器流的插入指令经Huffman压缩后只有2534字节,而字段段里每条引用恰好1字节(0x80);解码端为每条引用都要复制4064字节的字段内容(22字节名+4042字节值,按RFC 9114计账口径折合4096字节)——单引用的放大达四千倍。

图2 编码器流指令与字段段的字节级构造,单引用1字节对应4096字节计账尺寸

整条攻击链的机制与四类根因汇总在图3。

图3 QPACK手风琴内存放大机制与四类根因(根因归纳自FoxIO原文,数值为本文实测)

四类根因值得逐条展开,因为它们解释了为什么这个问题会同时出现在互不相干的实现里:

(1)预算守在压缩层。quic-go 0.56只检查HEADERS帧的压缩后长度(上限1 MiB),帧内16万个头部在解码层畅通无阻。字节数守在错误的层,等于没守。

(2)全量解码后才校验。部分实现先解码完整个字段段再交给应用判断,峰值内存取决于最大帧而非剩余配额。攻击者把帧拆到多条流上并发发送,峰值还能叠加。

(3)计账口径漏了32字节。RFC 9114要求按“字段名+字段值+32”计账,quiche只算了前两项之和。1 MiB预算本应放行约3.2万个单字节头部,错误口径下放行了约100万个,实际解码量达到设计上限的约31倍。

(4)公告与执行脱节。SETTINGS_MAX_FIELD_SECTION_SIZE是端点向对端公告的解码预算,但公告只是声明。Bun 1.4.3公告16384却不执行,quic-go 0.56与aioquic 1.7.0干脆公告0(不限),解码侧默认无预算。

3 实战复现

3.1 环境与测量方法

实验对象为五个官方渠道获取的服务端版本:Bun 1.4.3(PoC指定的未修复版本)与Bun 1.4.4(发文后新发布的版本)、quic-go 0.56.0(漏洞版)与0.57.0(修复版)、aioquic 1.7.0(PyPI当前最新版,也是FoxIO指纹覆盖的版本)。攻击端使用FoxIO开源的PoC客户端,其中quic-go客户端由我补了一个keylog参数用于流量解密。

内存测量不依赖服务端自报:以0.25秒周期读取目标进程状态中的RSS字段记录全程轨迹,服务端置于cgroup内存上限之下(与PoC的容器编排一致,Bun 2G、quic-go 512M),并关闭交换分区,以复现“默认配置的容器化部署”这一最常见场景。aioquic的PoC服务端自带peak_rss日志,可以与外部采样互证。

3.2 aioquic:瞬时放大与应用层驻留是两回事

aioquic 1.7.0的实测结果修正了我对这份研究的初步理解。用unique-name变体单连接单流打1 MB帧,服务端日志显示每帧解码出160075个头部(与FoxIO原文的160075完全一致,交叉验证了构造的正确性),进程RSS从基线40 MB峰值到76.3 MB——单帧瞬时放大约36倍,但请求处理完内存立即归还,进程安然无恙。

PoC服务端在响应后即丢弃头部列表,而真实应用几乎总会把请求对象持有片刻(日志、路由、异步任务队列)。我给服务端加了一行“保留已解码头部”的模拟——这正是mitmproxy保留请求对象的行为——放大立即从瞬时转为驻留:38.1 MB线上流量把RSS从40 MB一路推到788 MB,每请求稳定增长约17 MB,驻留放大约19.6倍。换用700M cgroup上限后,第34个请求处进程被内核直接击杀。

图4 aioquic 1.7.0:单帧解码160075个头部,驻留变体40个请求爬至788 MB,700M上限下单连接击杀

这里有一个容易误读的点:FoxIO给aioquic标注的20至30倍是库层瞬时值,不代表“打不挂”。只要应用层保留请求对象,瞬时放大就会转化为驻留增长,最终同样由OOM兜底。评估HTTP/3服务端风险时,必须把业务代码的持有行为算进去。

3.3 Bun 1.4.3:588.7 KB换一个OOM

动态表引用变体在Bun上发挥到了极限。攻击前先用PoC的探测模式读取Bun的SETTINGS:QPACK_MAX_TABLE_CAPACITY=4096、MAX_HEADER_LIST_SIZE=16384、QPACK_BLOCKED_STREAMS=100。公告了16384的头部列表上限,随后10条流各发60000条引用——每条流解码出的头部总量约232 MB,是公告上限的约1.4万倍,上限完全没有被执行。

攻击结果(图5第1段):588.7 KB线上流量,客户端侧计算的目标分配2325.4 MB,放大4045倍;服务端RSS从21 MB在约15秒内爬到2054 MB,撞上2G cgroup上限后被内核OOM击杀。这个数字与FoxIO给出的“约4000倍、5.8 MiB流量击杀2 GiB容器”在同一量级,且我的输入只是其约十分之一——单连接10流已经足够。

3.4 quic-go 0.56.0:驻留23倍与第13个请求

quic-go 0.56.0的PoC服务端把每个请求的头部保留在应用层切片里,等价于最坏的业务写法。无上限实测:30个请求、28.6 MB线上流量,堆分配爬至661 MB,驻留约23倍。置于512M cgroup上限(PoC编排的默认值)后,第13个请求(2.08M个头部)处进程被OOM击杀——13 MB流量换掉一个服务。

值得注意的是服务端日志里的头部计数是160071,与客户端打印的160075相差4个:Go的请求头对象不包含4个伪头部(:method、:scheme、:path、:authority),客户端计数则包含。两边的账都对,口径差4。

3.5 补丁有效性验证

同一攻击原样重放到两个修复版本,结果如图5第2、3段。

Bun 1.4.4(研究发布两天后,即10月22日发布):探针显示SETTINGS与1.4.3完全相同,但同一588.7 KB攻击全程RSS纹丝不动(基线23 MB、峰值23 MB),公告的16384开始真实生效——与FoxIO原文建议的修复方式(解码循环内对解码后尺寸做累计检查,超过16 KiB即拒绝)行为一致。

quic-go 0.57.0:同一95.4 MB攻击下进程存活,RSS稳定在约137 MB,且应用层处理器一条请求日志都没有——超限字段段在h3层即被拦截,业务代码根本没有机会见到那160071个头部;按FoxIO披露,0.57.0的拦截由增量解码、逐条目尺寸检查与431响应共同完成。

图5 同一攻击在四个版本上的实测对照(上三段)与三实现SETTINGS指纹(下段,探测模式实测)

图5底部是三个实现SETTINGS指纹的实测对照:quic-go 0.56.0三项全0(无任何预算),aioquic 1.7.0公告4096表容量但头部列表不限,Bun 1.4.3三项齐全却唯独不执行中间那项。这份指纹既可以用于资产测绘中识别实现类型,也可以直接读出每个实现的失守点——FoxIO发布的JA4Scan-QUIC指纹(如aioquic 1.7.0对应0310000ss000_2a673bc4abf8_db4d1fe4882a_sr1s0seb0750)是同一思路的工程化形态。

3.6 内存轨迹汇总

图6汇总了三条典型的RSS轨迹,形态差异一目了然:Bun 1.4.3是陡峭的爬升并在顶格处被击杀;aioquic驻留变体是匀速线性爬升(每请求增量恒定);Bun 1.4.4是贴地的水平线。监控体系若对HTTP/3进程的RSS变化率告警,这类形态在进程死亡前十几秒即可被发现。

图6 同一攻击流量下三类目标的进程RSS轨迹(0.25秒采样,数据为进程状态原始读数)

4 检测与缓解

4.1 网络侧为什么帮不上忙

QUIC对载荷全程加密,中间设备既看不到头部块也看不到QPACK引用,内容级的预算检查只能发生在端点上。图7是持有TLS密钥的端上对Bun目标抓包的解密结果:服务端的SETTINGS帧与QPACK解码器单流清晰可辨——这类解密能力属于运维取证而非防御手段,攻击面本身在加密边界之内。

图7 持有密钥端上解密还原的HTTP/3控制面(Bun目标抓包),服务端SETTINGS与QPACK解码器单流可见

网络侧仍然可以做弱信号的观测:攻击流量的形态是单条UDP流内持续满尺寸的上行报文(本例588.7 KB集中在十余秒内),配合目的端口收敛可以给出低置信度告警,但确认只能依赖端侧。

4.2 端侧修复的正确姿势

端侧修复可以概括为三层:预算在SETTINGS里向对端公告,使上限成为双方可见的契约;校验在解码循环内逐字段累计进行、超限即断,而非解码完成后补查;计账按RFC 9114的“名+值+32”口径,堵住漏算32字节留下的约31倍空间。quic-go 0.57.0与Bun 1.4.4的修复分别印证了前两层。

部署侧的兜底同样必要:为HTTP/3进程设置cgroup内存上限并关闭交换分区(本文所有OOM证据均依赖该配置,它同时也是容器化部署的事实默认);短期内无法升级的组件直接关闭HTTP/3能力,mitmproxy对应的开关是--set http3=false,Bun在1.4.4之前的版本不应将HTTP/3服务暴露在不可信网络。

4.3 一份自查清单

结合复现结果,给运营HTTP/3服务端的人一份可执行的核查项:其一,用PoC的探测模式读自己的SETTINGS,MAX_HEADER_LIST_SIZE为0即无预算,公告值不为0也不代表被执行;其二,检查解码路径是否存在“先解码后校验”,尤其留意中间件是否长期持有请求对象;其三,对进程RSS的变化率做告警,图6的两种危险形态都能在死亡前被发现;其四,跟踪FoxIO发布的JA4Scan-QUIC指纹,识别资产中仍在运行未修复实现的服务。

5 结语与复盘

三条利用路径走完,这份研究最有价值的地方不是某个具体CVE,而是它揭示了协议栈里一类结构性风险:压缩层的天性是“以小的引用指代大的对象”,而服务端的天性是“先收下再检查”,两者相遇,中间没有任何一层天然负责对账。HPACK时代这一问题被HTTP/2的并发上限压制着,QPACK把动态表拆到独立流上之后,引用的成本进一步降到1字节,天平彻底倾向了攻击者。修复的共性也由此而来——把“对账”从压缩层挪到解码循环里,逐字段、含开销、超限即断。

复盘过程有几处踩坑值得记录。构建PoC时go.mod声明的工具链版本在默认模块代理下无法下载,降版本指令并改用可达的模块代理后解决;Bun官方发布资产的直链不可达,改从npm的平台二进制包获取同版本二进制。流量解密方面,tshark的QUIC密钥日志挂在tls.keylog_file偏好之下而非quic前缀;quic-go目标的大流量抓包用密钥日志只能还原握手与控制流,请求流报文始终解不开,换用动态表引用变体的小流量抓包(Bun目标)后同一版本Wireshark全程可解,控制面证据即取自后者。内存击穿的复现则要区分两种限制手段:虚拟内存上限下Python进程会收到MemoryError并存活,cgroup物理上限配合关闭交换分区才是贴近容器默认配置的击杀路径——同一攻击在这两种约束下呈现完全不同的死法。

最后是数字口径的提醒。本文所有倍率均为“目标侧内存增量或分配量除以线上字节数”,aioquic的36倍是单帧瞬时值、19.6倍是应用层驻留值,Bun的4045倍是客户端侧的目标分配计算值,quic-go的23倍是Go堆分配的驻留值——同一攻击在不同实现、不同层的放大口径不同,引用时必须带上限定词,否则就会得出“修复版还有23倍放大”这类错误结论。

引用来源

[1] Féry S. Header Accordions: QPACK amplification attacks against HTTP/3 servers[EB/OL]. FoxIO, 2026-10-20. https://foxio.io/blog/header-accordions-qpack-amplification-attacks-against-http-3-servers

[2] FoxIO-LLC. blog_poc: headeraccordions[EB/OL]. https://github.com/FoxIO-LLC/blog_poc/tree/main/headeraccordions

[3] Bishop M. Hypertext Transfer Protocol Version 3 (HTTP/3)[S]. RFC 9114, 2022.

[4] Oku K, Pardue L. QPACK: Field Compression for HTTP/3[S]. RFC 9204, 2022.

[5] Oven. bun v1.4.4 release notes[EB/OL]. https://github.com/oven-sh/bun/releases

[6] quic-go. quic-go v0.57.0 release notes[EB/OL]. https://github.com/quic-go/quic-go/releases