技術 約17分で読めます

HTTP/2 Bomb PoC公開でnginxとApacheとEnvoyのメモリ枯渇DoSを確認する

いけさん目次

TL;DR

影響 HTTP/2を終端するnginx、Apache httpd、Envoy、IIS、Pingora。単一の100Mbps回線から32GB級メモリ枯渇(数十秒)

対応 nginx 1.29.8以降(max_headers追加)、mod_h2 v2.0.41(CVE-2026-49975)、Envoy 1.35.11 / 1.36.7 / 1.37.3 / 1.38.1(CVE-2026-47774)

暫定 HTTP/2無効化(Protocols http/1.1等)、前段プロキシでのヘッダフィールド数制限、ワーカー・コンテナのメモリ上限設定


HTTP/2 BombのPoCと検証用リポジトリが公開された。
Califの説明では、単一クライアントがHTTP/2のHPACK圧縮とフロー制御の停止を組み合わせ、Apache httpdやEnvoyで32GB級のメモリを数十秒ほど保持できる。

対象として挙がっているのは、nginx、Apache httpd、Microsoft IIS、Envoy、Cloudflare Pingora。
ただし2026年6月4日時点では修正状況に差がある。nginxは1.29.8で max_headers を入れ、Apache側はmod_h2 v2.0.41で LimitRequestFields のcookie計上を直した。Envoyも6月3日にGHSA-22m2-hvr2-xqc8を公開し、1.35.11 / 1.36.7 / 1.37.3 / 1.38.1を修正版として出している。

nginxは5月にもrewriteまわりのRCE級CVEが続いた。
あちらはrewrite設定の特定パターンでヒープ破壊に落ちる話だったが、今回はHTTP/2終端そのもののメモリ保持だ。
同じnginxでも、確認する場所が設定ファイルのrewrite行からHTTP/2ヘッダ数・cookie処理・終端位置へ変わる。

1バイトが巨大ヘッダ値になる話ではない

HTTP/2のHPACKは、ヘッダを圧縮するために動的テーブルを持つ。
古典的なHPACK Bombは、大きな値を動的テーブルへ入れて、それを小さなインデックス参照で何度も展開させる。
この形に対して、多くの実装は「デコード後ヘッダサイズ」を上限で縛るようになった。

HTTP/2 Bombのひねりは逆で、ヘッダ値自体は小さい。
Califは、増えるのはデコード後の文字列サイズではなく、サーバーが各ヘッダ項目の周辺に確保する管理用メモリだと説明している。
値がほぼ空なので、デコード後サイズの上限に引っかからない。

Apache httpdとEnvoyでは、cookieヘッダの扱いが抜け道になる。
HTTP/2ではcookieを複数の小さなフィールドに分けて送れる。
サーバーが通常ヘッダ数を数えていても、cookie crumbを同じ数え方に入れていないと、ヘッダ数制限をすり抜ける。

flowchart TD
    A[攻撃者のHTTP/2接続] --> B[HPACKの短い参照を大量に送る]
    B --> C[cookie crumbや小さいヘッダが大量に復元される]
    C --> D[サーバー側でヘッダ項目ごとの管理メモリを確保]
    D --> E[フロー制御ウィンドウを0にして応答を詰まらせる]
    E --> F[ストリームが終わらずメモリが解放されない]

後半で働くのがフロー制御だ。
クライアントが0バイトの受信ウィンドウを示すと、サーバーは応答を書き出せない。
ストリームが完了しないので、リクエスト処理中に確保したメモリが長く残る。
プロセスを即座に落とすより、OOM寸前やスワップ投入状態で粘られるほうが、同じホスト上の通常リクエストに響く。

修正状況は製品ごとにずれている

Califの一次記事は6月3日更新で、Envoyの修正が出たことにも触れている。
SecurityOnlineの記事ではIIS、Envoy、Pingoraを未修正としているが、Envoyについてはその後にGitHub advisoryが公開された。
古い二次情報だけを見ていると、ここを取り違える。

製品2026年6月4日時点の状態
nginx1.29.8で max_headers を追加。デフォルト1000
Apache httpdmod_h2 v2.0.41でcookieヘッダの計上処理を修正。CVE-2026-49975
EnvoyCVE-2026-47774 / GHSA-22m2-hvr2-xqc8(CVSS 7.5)。1.35.11 / 1.36.7 / 1.37.3 / 1.38.1が修正版
Microsoft IISCalif記事では修正未確認。HTTP/2無効化や前段プロキシでの制限が暫定策
Cloudflare PingoraCalif記事では修正未確認。HTTP/2終端の前段制限か無効化が暫定策

nginxの max_headers は、リクエストヘッダの数そのものを制限する。
既存の client_header_buffer_sizelarge_client_header_buffers は合計サイズ寄りの制限なので、ほぼ空のヘッダを大量に並べる形とは別の軸になる。

Apache側のv2.0.41は、cookieをマージするときにも LimitRequestFields から逃げないようにする修正だ。
ただしCalifは、LimitRequestFieldSize を下げるだけでは攻撃全体を消せず、ストリームやコネクションを増やせば効果を重ねられると書いている。
未更新のApacheでHTTP/2を外せるなら、Protocols http/1.1 で攻撃経路を切るほうが単純だ。

Envoy advisoryは、HTTP/2のダウンストリーム側リクエスト処理で、認証不要のリモートクライアントが過剰なメモリ消費を起こせると説明している。
原因として、cookie断片のサイズ計上、HPACKヘッダブロックのサイズ制限、フロー制御によるストリーム寿命の延長が並ぶ。
回避策は「修正適用以外の完全なものは知られていない」という扱いなので、該当系統は修正版へ上げる。

CDNやリバースプロキシの内側でも終端位置を洗う

公開サイトがCDN配下なら、攻撃者が直接到達するHTTP/2終端はCDNやフロントプロキシになる。
この場合、オリジンのnginxやApacheがHTTP/2を話していても、外部からそのポートへ届かなければ直撃しにくい。

ただし、社内向けロードバランサー、Kubernetes Ingress、サービスメッシュ、APIゲートウェイの内側でHTTP/2を終端している場合は別だ。
「インターネットに公開しているWebサーバー」だけを見ても、EnvoyやPingora、IISの終端を落とすことがある。
外側の443だけでなく、CDNからオリジン、LBからPod、ゲートウェイからアップストリームまで、どこでHTTP/2を受けているかを確認する。

nginxなら1.29.8以降か、HTTP/2を使うserverブロックに max_headers が入っているか確認する。
Apacheは同梱mod_http2のバージョンか、スタンドアロンmod_h2 v2.0.41相当の取り込み状況。
Envoyは1.35.11 / 1.36.7 / 1.37.3 / 1.38.1以降かどうか。

更新まで時間がかかる環境では、HTTP/2を一時的に外す、前段でヘッダフィールド数を強制的に切る、ワーカーやコンテナのメモリ上限を小さめに置く。
メモリ上限は根本修正ではないが、ホスト全体がスワップで詰まる前にワーカーだけ落として復帰させるための緩和策になる。

増幅率は実装のメモリ管理で桁が変わる

Califの計測によると、100Mbpsの家庭回線1本からサーバーのメモリを32GB以上消費できる。
増幅率は実装内部のデータ構造に依存するため、製品ごとに大きく開く。

実装増幅率消費メモリ所要時間
Envoy 1.37.2約5,700倍約32GB約10秒
Apache httpd 2.4.67約4,000倍約32GB約18秒
nginx 1.29.7約70倍約32GB約45秒
Microsoft IIS (Windows Server 2025)約68倍約64GB約45秒
Cloudflare Pingora 0.8.0約62倍--

EnvoyとApacheの増幅率が2桁大きい理由はcookieの再結合処理にある。

cookie分割がApacheとEnvoyで4,000倍の増幅を生む

RFC 9113のSection 8.2.3は、HTTP/2でcookieヘッダを複数の小さなフィールドに分けて送ることを明示的に許している。
受信側はそれらを結合してHTTP/1.1互換の単一cookieヘッダに戻す。

Apacheのmod_h2では、この結合がイテレーティブに走る。
cookie crumbが届くたびに新しいプール文字列を確保して前の結果と結合し、結合前の文字列もストリーム終了まで残る。
cookie crumbがN個の場合、メモリ消費は i=1N(2i+1)\sum_{i=1}^{N}(2i+1) で二次成長する。
4,091個のcookie crumbから約16MBが生まれ、1接続100ストリームで1.5GBを超える。

Envoyでは、4,058バイトのcookieを動的テーブルに入れ、1バイトのインデックス参照(0xBE)で32,768回展開する。
ワイヤ上は36,844バイトだが、サーバー側の結合済みcookieは126.9MiBに膨らむ。
アロケータのオーバーヘッドを含めた実測RSSの増分比が5,700:1になる。

両方に共通するのは、ヘッダの「バイト数」制限がcookieを正しく計上していなかった点だ。
Envoyのmax_request_headers_kbはcookieバイトを含まず、ApacheのLimitRequestFieldsはcookie crumbを個別カウントしていなかった。

1バイトずつ流してメモリを何時間も保持する

フロー制御の攻撃部分はHPACK増幅と独立しており、単体でも成立する。

攻撃者はHTTP/2のSETTINGSフレームでINITIAL_WINDOW_SIZE=0を送る。
サーバーはレスポンスのHEADERSフレームは送れるが、DATAフレームは送り出せない。
レスポンスボディがバッファに残り、ストリームが完了しない。

nginxにはsend_timeout(デフォルト60秒)がある。
何もしなければ60秒でストリームが切れてメモリが解放される。
攻撃者が50秒ごとにWINDOW_UPDATE(increment=1)を送ると、サーバーは1バイトだけ書き出してタイムアウトをリセットする。
接続の維持に必要な帯域は約34バイト/秒だ。

flowchart TD
    A["SETTINGS:<br/>INITIAL_WINDOW_SIZE=0"] --> B["サーバーはDATAを<br/>送れない"]
    B --> C["レスポンスが<br/>バッファに残る"]
    C --> D{"send_timeout<br/>残り60秒"}
    D -->|"50秒後"| E["WINDOW_UPDATE<br/>increment=1"]
    E --> F["1バイト送出<br/>タイマーリセット"]
    F --> D

nginxのデフォルト404ページ(約150バイト)なら、1接続あたり約2.3時間メモリを保持できる。
カスタムエラーページで1KBなら約15時間、4KBの静的ファイルなら約62時間だ。

nginxはワーカー再起動までメモリが返らない

nginxでは攻撃終了後にストリームを閉じてメモリを解放しても、プロセスのRSSが下がらない。

glibcのptmallocはbrk()でヒープの上端を伸ばしてメモリを確保する。
解放されたブロックはアリーナのフリーリストに入るが、ヒープの上端より下にあるブロックはOSに返せない。
上端に1つでもライブなアロケーションが残っていると、その下のフリーブロック全体がピン止めされる。

Califの計測では、5接続で1.41GBに達したRSSが、全接続を閉じた後も1.13GBのまま残った。
nginx -s reloadは同じワーカーPIDを使い回すので回収されない。
ワーカープロセスを再起動するまでこの状態が続く。

nginxの組み込みフラッド検知は total_bytes / 8 > payload_bytes + 1,048,576 で判定するが、HPACK Bombのオーバーヘッド比は1.001:1なので8:1の閾値を超えない。
フラッド検知はこの攻撃を検出しない。

IISはカーネルメモリなので再起動が要る

IISはhttp.sysがカーネルモードでHTTP/2を処理しており、問題の性質が異なる。

Windows Server 2025の環境で、64GBサーバーが95秒後にピーク65,319MBに到達した。
攻撃停止後もカーネル空間に12.2GBが回収されずに残る。
96GBサーバーでは45秒で92,920MBに達し、残留は16GBだった。

IISのTimer_MinBytesPerSecond(ベースライン約15秒)は、5秒間隔のWINDOW_UPDATE(increment=1)で回避される。
フレームレートDoSリミッターはレジストリ値がデフォルト0で無効になっている。
完全なメモリ回収にはシステム再起動が必要だ。

2019年のHTTP/2 DoSやRapid Resetとの違い

HTTP/2のDoS脆弱性は2019年のCVE-2019-9511〜9518で一括報告されている。

CVE-2019-9517(Internal Data Buffering)がHTTP/2 Bombに最も近い。
HTTP/2のウィンドウを開いたままTCPウィンドウを閉じ、サーバー側にレスポンスをバッファさせる手口だった。
HTTP/2 Bombは「HPACKの増幅でリクエスト処理中のメモリコストを上げる」をここに加えた形になる。

CVE-2023-44487(HTTP/2 Rapid Reset)は狙いが違う。
ストリームを開いてすぐRST_STREAMでキャンセルし、リクエスト処理のCPUコストだけを食わせるスループット攻撃で、ピーク時は3.98億リクエスト/秒を記録した。
大帯域が要るRapid Resetに対し、HTTP/2 Bombは100Mbps回線1本で成立する。

CVE-2016-6581はCory Benfieldが提唱した初代HPACK Bombで、大きな値を動的テーブルに入れてインデックス参照で展開する形だった。
多くの実装がデコード後サイズの上限を入れて対処したが、HTTP/2 Bombはヘッダ値を小さくすることでこの防御をすり抜ける。

攻撃狙い帯域要件防御を抜ける理由
HTTP/2 Bomb(今回)メモリ枯渇+保持100Mbps 1本ヘッダ値が小さくデコード後サイズ制限を通過
Rapid Reset(CVE-2023-44487)CPU飽和大帯域RST_STREAMの処理コストが想定外
Internal Data Buffering(CVE-2019-9517)メモリバッファ中程度TCP/HTTP/2のウィンドウ不整合
初代HPACK Bomb(CVE-2016-6581)メモリ膨張低帯域デコード後サイズ制限で対処済み

HPACKの仕様がヘッダ個数のコストを過小評価している

RFC 7541(HPACK)のSection 4.1は、動的テーブルのエントリサイズを name_length + value_length + 32 バイトと定義している。
32バイトはポインタや参照カウンタの推定オーバーヘッドだ。

ただし、この32バイトはHPACK動的テーブル自体のサイズ計算に閉じている。
サーバーがリクエスト処理のために各ヘッダに割り当てる管理メモリは含まれない。
nginxでは1ヘッダあたりngx_table_elt_t構造体56バイト、名前コピー2バイト、値コピー1バイトで計約59バイトを確保する。
ワイヤ上の1バイトのインデックス参照がサーバー側で59バイトのアロケーションに化ける。

RFC 7541のSection 7.3はメモリ消費について「動的テーブルサイズの上限で制限できる」としか書いていない。
デコード後のヘッダ項目ごとの管理コストには触れていない。
Califは「欠陥は仕様にある(the defect is in the spec)」と書いている。

nginxのmax_headersディレクティブは、この仕様上の穴に対する実装側の追加防御だ。
コミットログを見ると、HTTP/1.x、HTTP/2、HTTP/3(QUIC)の3経路すべてにヘッダ数チェックが入っている。

Codexが2つの公知手口を合成して発見した

この脆弱性はCalifがOpenAIのCodexエージェントを使って発見した。
HPACK動的テーブルの増幅とフロー制御の停止は、どちらも10年以上前から公知の手口だ。
Codexはnginx、Apache、Envoyのコードベースを読み、2つの手口が組み合わさることを認識して攻撃を組み立てた。

さらに、nginxとApacheの修正コミットを解析して、IIS、Envoy、Pingoraにも同種の問題があることを逆算している。
PoCはすべてPythonの標準ライブラリだけで書かれており、外部依存はない。

Califの中心人物であるThai Duongは、2012年にCRIME攻撃(TLS圧縮を使ったセッションハイジャック)をJuliano Rizzoと共同で発見した人物で、当時GoogleでHPACK RFCのレビューに参加していた。
「欠陥は仕様にある」と指摘する根拠は、仕様策定の現場にいた経験から来ている。
Stanford大学で2026年6月に開催されるReal World AI Securityカンファレンスで発表が予定されている。

PoCが送る0xBEの1バイトインデックス参照

nginx・Pingora向けPoCのワイヤ上のバイト列を分解するとこうなる。

0x82  :method GET(HPACKの静的インデックス2)
0x84  :path /(静的インデックス4)
0x86  :scheme https(静的インデックス6)
0x41  :authority リテラル挿入(名前インデックス1)
  0x01  値の長さ1バイト
  0x78  "x"
0x40  リテラル挿入(新しい名前)
  0x01  名前の長さ1バイト
  0x61  "a"
  0x00  値の長さ0(空)
0xBE × N  動的テーブルのインデックス62を参照

最初に:method:path:scheme:authorityの疑似ヘッダを静的テーブルから引く。
次に名前a・値なしのヘッダをリテラル挿入で動的テーブルのインデックス62に入れる。
あとは0xBEを並べるだけで、1バイトごとに名前a・値なしのヘッダがデコードされる。

nginxのlarge_client_header_buffersはデフォルト4×8192=32,768バイトで、名前1バイト+値0バイトのヘッダだとサイズ上限まで約32,000ヘッダが入る。
1バイトの参照が59バイトのサーバー側アロケーションに化けるので、32,000×59=約1.9MBがストリームごとに確保される。
128ストリーム×1.9MB=約243MBが1接続あたりの消費だ。

Envoy向けPoCはcookieを使う。
HPACKの静的テーブルインデックス32はcookieで、4,058バイトのcookie値をリテラル挿入で動的テーブルに入れる。
HPACKのエントリサイズ計算は名前6バイト+値4,058バイト+オーバーヘッド32バイト=4,096バイトで、Envoyのデフォルト動的テーブルサイズ(4,096バイト)にちょうど1エントリ分収まる。
あとは同じく0xBEを32,768回並べて、1バイトずつ4,058バイトのcookie crumbとして展開させる。

IIS向けPoCはa:ヘッダ方式だが、1ストリームあたり900ヘッダに制限する。
http.sysのDecompressionOverflow検出の閾値が約921ヘッダにあるため、900で止めて検出を回避する。

Envoyはデコード後のcookieバイトをmax_request_headers_kbに含めていなかった

Envoyの増幅率が5,700:1と突出する原因は2つある。

1つ目はcookieヘッダ断片のサイズ計上漏れ。
Envoyはリクエストヘッダのサイズ検証を完了した後にcookie断片をマージする。
マージ後の合計cookieバイトがmax_request_headers_kbのチェックに含まれない。
32,768個のcookie crumbが結合されて126.9MiBのcookieになっても、サイズ制限をすり抜ける。

2つ目はHPACKブロックサイズの制限がエンコード済みバイトのみに適用される点。
Envoyの内部で使うoghttp2/quicheは、HPACKヘッダブロックのサイズ制限をエンコード済みのワイヤバイト数にだけ適用し、デコード後のヘッダ合計サイズを制限していなかった。
ワイヤ上36,844バイトのヘッダブロックがデコード後に133MBに膨らんでも、制限にかからない。

Envoy advisoryのCWEはCWE-405(非対称リソース消費/増幅)とCWE-770(上限なしのリソース割り当て)の2つが付いている。
CVSSベクターはAV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:Hで、認証不要・ネットワーク経由・可用性への影響が高い。
修正版は1.35.11、1.36.7、1.37.3、1.38.1の4系統で、修正適用以外の完全な回避策はないとされている。

Pingoraはh2クレートのデフォルトでストリーム受信数が実質無制限

増幅率テーブルでPingoraの消費メモリと所要時間が空欄なのは、ストリーム数次第で消費量が大きく変わるからだ。

PingoraはRustのh2クレートを使っている。
受信側の最大同時ストリーム数のデフォルトがusize::MAXで、実質的に制限がない。
デコード済みヘッダリストの上限はデフォルト16MiB/ストリームなので、1ストリームあたりのRSSは約1.9MiBに留まる。
だが1接続から2,048ストリームを開くと1.9MiB×2,048=約3.8GiBに到達する。
ワイヤ上は62.5MiB程度だ。

Pingoraの増幅率62:1はEnvoyの5,700:1に比べて2桁低いが、これは1ストリームあたりの増幅が小さいだけで、ストリーム数に制限がないぶん接続数を絞っても合計消費量を積み上げられる。

mod_h2の修正は*pwas_added = 1の1行

Apache httpd側の修正は、h2_util.cの2か所だけだ。

1つ目は空のcookie重複を無視する処理。

if (!nv->valuelen)
    return APR_SUCCESS;

2つ目がcookieのマージをLimitRequestFieldsのカウントに含める修正で、こちらが核心になる。

*pwas_added = 1;

修正前は、cookie結合パスが*pwas_added = 1を設定する前にreturn APR_SUCCESSしていた。
*pwas_addedが0のまま返るので、cookie crumbのマージがいくら発生してもLimitRequestFieldsのカウンタに反映されなかった。

Apacheのcookie結合はh2_req_add_header()の中でapr_psprintf(pool, "%s; %.*s", existing, ...)を繰り返す。
crumbが1つ届くたびに新しいAPRプール文字列を割り当てて前の結果と結合し、前の文字列はストリームクリーンアップまで解放されない。
空のcookie crumbが4,091個なら、LimitRequestFieldSize(デフォルト8190バイト)の範囲内で最終cookie値は4,091×2=8,182バイトに収まるが、途中のプール文字列がすべて残るのでメモリ消費が二次成長する。

IISのhttp.sysカーネル処理の経路

IISでHTTP/2リクエストが処理される経路は、http.sysのカーネルモードドライバの中を通る。

HPACKペイロードはHkDecodeで解凍され、デコード済みの各ヘッダペアにカーネルプールバッファが割り当てられる。
処理チェーンはUxDuoProcessCompleteCatalogUxDuoRunStreamReceivePumpUlHttpReceiveHeadersEventの順に流れる。

WINDOW_UPDATE(increment=1)を受け取るとUxDuoUpdateStreamSendWindowが最小値チェックなしで受理する。
1バイト送信でTimer_MinBytesPerSecondUxDuoDispatchWindowParcel経由でリセットされる。
カーネル空間の処理なのでIISのアプリケーションプール設定やWeb.configからは制御できない。

フレームレートDoSリミッター(Http2MaxWindowUpdatesPerSend)に加え、他のHTTP/2 DoS関連のリミッターもレジストリ値がデフォルト0で出荷されている。
有効化にはレジストリの手動設定が必要だが、そもそもDecompressionOverflowの閾値が約921ヘッダで、PoCは900に抑えて回避するので、閾値の調整だけでは防げない。

Shodanで880,000台以上のHTTP/2終端が露出

SecurityOnlineによると、Shodan上でHTTP/2を終端しているアクティブなシステムが880,000台以上確認されている。
PoC公開時点でIISとPingoraが未修正のため、かなりの台数が脆弱な状態にある。

CDN配下のオリジンサーバーは直接到達しにくいが、サービスメッシュ内のEnvoyサイドカー、Kubernetes IngressのPingora、社内向けのIIS HTTP/2終端はShodanに出てこない。
Shodanに出てこない内部ネットワークのHTTP/2終端を含めると、実際の脆弱台数は880,000台よりかなり多い。

開示と修正のタイムライン

時期出来事
2026年4月nginx開発チームへ開示。翌日に1.29.8リリース(max_headers追加)
2026年5月27日Apache httpdのmod_h2メンテナStefan Eissingへ開示。同日にv2.0.41リリース。CVE-2026-49975付与
2026年6月3日Envoyが4系統の修正版リリース。GHSA-22m2-hvr2-xqc8 / CVE-2026-47774公開
2026年6月3日CalifがPoCと解析記事を公開
2026年6月4日時点IIS、Pingoraは修正未確認

nginxの対応が速かったのは、コミットの出自にも表れている。
max_headersのコミットはfreenginxからの取り込み(https://freenginx.org/hg/nginx/rev/199dc0d6b05be814b5c811876c20af58cd361fea)で、著者はMaxim Dounin。
HTTP/1.x、HTTP/2、HTTP/3の3経路すべてに同じheaders_in.countカウンタでのチェックが入っている。

cscf = ngx_http_get_module_srv_conf(r, ngx_http_core_module);
if (r->headers_in.count++ >= cscf->max_headers) {
    ngx_log_error(NGX_LOG_INFO, r->connection->log, 0,
                  "client sent too many header lines");
    /* HTTP/1.x: NGX_HTTP_REQUEST_HEADER_TOO_LARGE + break
       HTTP/2:   ngx_http_finalize_request() + goto error
       HTTP/3:   return NGX_ERROR */
}

HTTP/1.xではlingering_closeを立ててbreak、HTTP/2ではngx_http_finalize_requestからgoto error、HTTP/3ではreturn NGX_ERRORと、エラーハンドリングの経路が3つに分かれるが、カウンタとログメッセージは共通だ。

参考