一条QUIC连接拉垮HTTP/3服务器——QPACK手风琴内存放大攻击实战复现与检测
FoxIO于2026年10月20日披露Header Accordions攻击,QPACK解码器成为HTTP/3服务端的新型内存放大器。本文复现三条利用路径,实测Bun 1.4.3以588.7 KB流量放大4045倍触发OOM,并验证两个新版补丁的有效性。
一条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