HTTP/3のQPACK増幅攻撃、FoxIOが試した10実装のうち7つでメモリ不足やCPU 100%
目次
TL;DR
影響 HTTP/3を受け付けるサーバーで、ログインなどの認証なしのクライアント1接続からメモリを使い切らせたり、CPU使用率を100%から下がらなくさせたりできる。テストされた10実装のうち7つが該当
修正済み quic-go 0.57.0、Cloudflare quiche 0.29.3、lsquic 4.9.0、Wireshark 4.6.2 / 4.4.12
未修正 2026年10月20日時点でBun 1.4.3、hyperium/h3、aioquic / mitmproxy。BunはHTTP/3を外に出さない、mitmproxyは --set http3=false、h3は max_field_section_size を設定する
2026年10月20日、FoxIOがHTTP/3のヘッダー圧縮QPACKを使った増幅攻撃をブログで公開した(Header Accordions: QPACK Amplification Attacks Against HTTP/3 Servers)。
筆者のSébastien Féry氏はHTTP/3の実装10個を試し、そのうち7個で、通信量に見合わない量のメモリを確保させられた。
小さなリクエストでサーバーのメモリやCPUを使い切らせて応答できなくする、サービス不能(DoS)攻撃の一種だ。
原因はどの実装も、ヘッダーを展開したあとのサイズに上限がない、上限の計算が仕様と違う、上限のチェックが展開し終わったあとにしか走らない、のどれかだった。
1年近くかけて各プロジェクトへ報告され、修正版が出たものと、まだ出ていないものが混ざっている。
QPACKとヘッダーサイズの上限
HTTP/3はUDP上のQUICで動くHTTPで、リクエストやレスポンスのヘッダーはQPACK(RFC 9204)で圧縮して送る。
QPACKには、よく使うヘッダーをあらかじめ番号で登録した静的テーブルと、接続ごとに送り手が中身を追加していく動的テーブルがあって、受け手は番号を受け取ったら対応するヘッダーの名前と値を復元する。
たとえば71バイトある strict-transport-security ヘッダーは、静的テーブルの58番を指す1バイト(0xFA)で送れる。
動的テーブルは、受け手が SETTINGS_QPACK_MAX_TABLE_CAPACITY で容量を許可したときだけ使える(既定値は0)。
使える場合、送り手はエンコーダーストリーム(テーブルを更新するための専用の通信路)でエントリーを登録しておき、HEADERSフレーム(ヘッダーを運ぶフレーム)ではそのエントリーを1バイトの参照で指す。
受け手側では、通信路に流れたバイト数よりずっと大きなデータが展開される。
HTTP/3の仕様(RFC 9114 4.2.2節)は、ヘッダー全体のサイズを、展開後の名前と値のバイト数に、1フィールドあたり32バイトのオーバーヘッドを足して数えると定めている。
名前1バイト・値1バイトのヘッダーなら、1+1+32で34バイトと数える。
上限を設けるかどうかは実装の任意で、設けた値は SETTINGS_MAX_FIELD_SECTION_SIZE で相手に知らせられる。
超えたリクエストには、サーバーはHTTP 431(Request Header Fields Too Large)を返せる。
2種類の攻撃
ブログのタイトルにもなっている「Header Accordion」は、1つのHEADERSフレームに、a から z、aa から zz、さらに3文字・4文字と、名前の違うヘッダーをできるだけ多く入れる攻撃だ(te だけは特別扱いされるので飛ばす)。
1,000,000バイトのHEADERSフレームに約16万個のヘッダーが入る。
展開後のサイズに上限がない実装では、受け手がこの16万個をすべて展開して保持する。
もう1つは動的テーブルを使う方法で、こちらは1バイトの参照を大量に並べる。
flowchart TD
A["エンコーダーストリームで<br/>約4KiBのエントリーを1つ登録"] --> B["HEADERSフレームで<br/>そのエントリーを1バイトで参照"]
B --> C["参照を何千回も並べる"]
C --> D["受け手は参照のたびに<br/>4KiBを新しく確保してコピー"]
D --> E["通信量1に対して<br/>約4000倍のメモリ確保"]
参照のたびにエントリー全体を確保し直してコピーする実装では、1バイトの参照が約4KiBのメモリになる。
FoxIOがlsquicとBunで測った比率は4064:1だった。
実装側の5つの間違い
| 間違い | 何が起きるか |
|---|---|
| 圧縮後のフレームサイズだけを確認している | 小さいフレームが巨大なヘッダーに展開されても止まらない |
| 全部展開してからサイズを確認している | チェックが走る前にメモリを確保し終わっている |
| 32バイトのオーバーヘッドを足していない | 1バイトのヘッダーを大量に並べると上限の何十倍も入る |
| 設定していないと上限なしになる | 既定の設定のままでは制限がかからない |
| 設定しても実際にはチェックしていない | 上限を相手に通知するだけで、受信時に何もしない |
ブログが示す直し方は、展開しながら32バイトのオーバーヘッド込みで累計を数え、上限を超えた時点で止めることだ。
実装ごとの結果
| 実装 | CVE | ブログが示した影響 | 原因 |
|---|---|---|---|
| quic-go | CVE-2025-64702 | 保持されるヒープが約15倍 | 圧縮後のサイズしか確認していない |
| Cloudflare quiche | CVE-2026-12523 | 約20倍 | 32バイトのオーバーヘッドがない、宣言されたフレーム長で事前確保 |
| lsquic | なし(修正済み) | 約600〜1800倍 | 展開後の累計に上限がない、HEADERSフレームを無制限に受け付ける |
| Bun | なし | 約4000倍 | 内部はlsquicだが、lsquicの累計上限を外している |
| aioquic / mitmproxy | なし | 約20〜30倍 | 上限の設定を読むだけで適用していない |
| hyperium/h3 | なし(報告済み、未修正) | スレッドのパニック | HeaderMap::append が32,768件でパニックする |
| Wireshark | CVE-2025-13945 | クラッシュ | HTTP/3の解析処理が展開数を制限していない |
| Google quiche | ― | 影響なし | 展開中に上限を適用している |
| IIS(http.sys) | ― | 影響なし | 展開しながら少しずつ上限を確認、QPACKのテーブルも4K |
| ngtcp2 / nghttp3 | ― | 影響なし | 上限の設定は通知だけ、という仕様をドキュメントに明記 |
quic-go
Go製のQUIC/HTTP/3ライブラリで、上限の1MiB(DefaultMaxHeaderBytes)は圧縮後のHEADERSフレームにしかかかっていなかった。
展開処理は全フィールドをまとめて返すだけで、サイズを確かめていない。
1,000,000バイトのフレームを送ると160,075個のフィールドに展開され、GC(使わなくなったメモリの回収)のあとも約15MiBのヒープ(プログラムが実行中に確保するメモリ)が残った。
GitHubのアドバイザリ(GHSA-g754-hx8w-x2g6)では、静的テーブルの長い名前と値を使えば約50倍に膨らむとしている。
影響はサーバーだけでなくクライアントにもあり、CVSS(脆弱性の深刻度を0〜10で表す指標)は5.3で4段階の下から2番目のModerate、対象はv0.56.0以前だ。
0.57.0では SETTINGS_MAX_FIELD_SECTION_SIZE を送るようになり、1エントリー展開するごとにサイズを確かめる。
超えたらサーバーは431を返し、クライアントはストリームを切る。
Cloudflare quiche
CloudflareのRust製の実装で、Googleの同名のQUIC実装(後述の影響を受けなかったGoogle quiche)とは別物だ。
サイズを name.len() + value.len() で数えていて、32バイトのオーバーヘッドを足していなかった。
上限1MiBのとき、仕様どおりなら1バイトのヘッダーは約32,000個しか入らないが、quicheでは約100万個入った。
QPACKとは別に、フレーム処理にも問題があった。
フレームのヘッダーに書かれた長さのとおりに、中身が届く前からメモリを確保していた。
攻撃者は16MiBのSETTINGSフレームやGOAWAYフレーム(接続を閉じる予告)のヘッダーだけを送れば、データ本体がなくても16MiBを確保させられた。
アドバイザリ(GHSA-4fgf-9xrr-88gf)のCVSSは7.5(High)で、0.1.0以降が対象、2026年7月14日公開の0.29.3で直った。
lsquicとOpenLiteSpeed
lsquicはLiteSpeedが作っているC製のQUIC/HTTP/3ライブラリで、OpenLiteSpeedやLiteSpeed Web Serverが使っている。
参照ごとに展開されるサイズの累計に上限がなく、es_max_header_list_size も既定値が0(上限なし)のうえ、QPACKの展開処理にそもそも渡されていなかった。
さらに64KiBの上限が、ストリーム単位ではなくHEADERSブロック単位でリセットされていた。
攻撃では約4KiBのエントリーを1つ登録し、そのあと1バイトの参照を15個ずつ入れたHEADERSフレームを続けて送った。
1ブロックの中身は小さいので64KiBの上限に引っかからないし、ブロックが変わるたびにカウントも0に戻る。
PoC(実証コード)では、50接続・10MiB未満の通信で、コンテナのメモリ使用量が1秒以内に上限の5GiBに達した。
cgroup(Linuxでプロセスのメモリ上限などを管理する仕組み)の memory.events には、上限に達した回数が900回以上記録された。
2026年7月15日の4.9.0で、バッファーに溜めておけるヘッダーの組の数を制限する es_max_header_sets が追加され、サーバーの既定値は1になった(CHANGELOG)。
ブログ末尾の、サーバーの種類とバージョンを見分けるフィンガープリント一覧では、OpenLiteSpeed 1.9.1はlsquic 4.8.2で動いていた。
OpenLiteSpeedのリリースノートでは、8月6日の1.9.2でlsquic 4.9.3、9月30日の1.9.3でlsquic 4.10.0に上がっていて、1.9.2以降には4.9.0の修正が入っている。
Bun
JavaScriptランタイムのBunはHTTP/3にlsquicを使っているが、lsquicにあった64KiBの累計チェックを、ヘッダー1個ごとのチェックに置き換えていた。
es_max_header_list_size=16384 を相手に通知しているのに、実際には適用していない。
1接続・100ストリームで、1ストリームあたり6万回の参照(通信量の合計5.8MiB)を送るだけで、メモリ2GiBのコンテナがOOM(メモリ不足)で強制終了された。
もっと少ない通信量で止まる不具合もある。
us_quic_hsi_prepare(Bun側のヘッダー処理の関数)はヘッダー用バッファーの容量を unsigned int で管理し、足りなくなると倍にしていく。
容量が2GiBを超えると値がオーバーフローして0になり、倍にしても0のままなので処理がいつまでも終わらない。
1ストリーム・約516KiB(528,500回の参照)を送っただけで、サーバーのCPU使用率が100%のまま下がらなくなった。
Bunは2026年5月24日に報告を受けているが、10月20日時点の1.4.3は未修正だ。
ブログは回避策としてHTTP/3のサーバーを外部に公開しないことを挙げ、修正案として、バッファーへ追加する前に合計が16KiBを超えないか確かめるコード(if (h->len + space > 16 * 1024) return NULL;)を載せている。
aioquic / mitmproxy
Python製のaioquicは、MAX_FIELD_SECTION_SIZE の設定を読み込んで保存するだけで、受信時に比較していなかった。
フロー制御(相手に送ってよい量を伝える仕組み)の送信枠も上限なしで倍々に広がる。
受け取ったヘッダーを捨てる最小構成のaioquicサーバーなら落ちないが、aioquicを使うデバッグ用プロキシのmitmproxyはストリームごとに http.Request を保持するので、メモリ消費が増え続ける。
ブログが挙げるmitmproxyの回避策は、--set http3=false でHTTP/3を無効にすることだ。
hyperium/h3
Rust製のh3クレート(Rustのライブラリの単位)は、展開したフィールドを http::HeaderMap に入れる。
HeaderMap::append は件数が32,768に達すると size overflows MAX_SIZE でパニック(処理を打ち切る異常終了)を起こし、既定の設定ではヘッダーサイズの上限も設定されていなかった。
メンテナーは2025年12月5日に、まだアルファ版のライブラリで、既定値を変える予定があるという理由で報告を閉じている。
ブログは回避策として、max_field_section_size に妥当な値を設定することを挙げている。
Wireshark
パケット解析ツールのWiresharkは、quic-go向けの攻撃データを解析にかけたときにクラッシュした。
展開されるヘッダー数に上限がなく、データの連結処理(tvb_composite)も遅いため、HTTP/3の解析処理が応答しなくなっていた。
アドバイザリ(wnpa-sec-2025-07)によると、キーログファイル(TLSの暗号鍵を書き出したファイル)で復号しているときや、復号用の鍵を含むキャプチャを開いたときに起きる。
対象は4.6.0〜4.6.1と4.4.0〜4.4.11で、2025年12月3日の4.6.2と4.4.12で直った。
影響を受けなかった実装
Google quicheは60Kの上限を展開中に適用していた。
IIS(http.sys)も展開しながら少しずつ上限を確認していて、QPACKの動的テーブルも4Kに抑えている。
ngtcp2 / nghttp3はFoxIOの判定では影響なしだが、max_field_section_size は相手に通知するだけで、受信時に制限をかけるのはアプリケーション側としている。
報告を受けて、2025年12月17日にその旨をドキュメントへ書き足した(nghttp3のPR #445)。
nghttp3を組み込んで自分でサーバーを書いている場合、展開したヘッダーのサイズはnghttp3側では数えてくれない。
報告から公開まで
| 日付 | 出来事 |
|---|---|
| 2025年11月9日 | quic-goへ報告 |
| 2025年11月14日 | Wiresharkへ報告 |
| 2025年11月15日 | Cloudflare quicheへ報告 |
| 2025年11月21日 | quic-go 0.57.0リリース |
| 2025年12月3日 | Wireshark 4.6.2リリース |
| 2025年12月4日 | hyperium/h3へ報告 |
| 2025年12月5日 | h3のメンテナーが報告を閉じる |
| 2025年12月17日 | nghttp3が上限は通知だけとドキュメント化 |
| 2026年5月24日 | Bunへ報告 |
| 2026年7月13日 | aioquicとlsquicへ報告 |
| 2026年7月14日 | Cloudflare quiche 0.29.3リリース |
| 2026年7月15日 | lsquic 4.9.0リリース |
| 2026年10月20日 | ブログ公開 |
PoCはFoxIO-LLC/blog_pocの headeraccordions に置かれている。
ブログには、FoxIOのJA4Scan-QUIC(QUICの応答からサーバーの種類を見分けるツール)で各実装のバージョンを判別するフィンガープリントも載っている。