HTTP/2動態圧縮の深淵:HPACK「動的テーブル」が隠し持つパケットの最適化と脅威
ネットワークの低レイヤを愛する我々にとって、プロトコルがどのように「無駄」を削ぎ落とし、物理の限界(光速の壁)に挑んでいるかを知ることは、一種の知的興奮をもたらす体験だ。HTTP/1.1が抱えていた、毎度数キロバイトにも膨れ上がる冗長なASCIIテキストのヘッダー群。User-Agent、Cookie、Accept-Encoding……。これらの繰り返し送信が、往復遅延時間(RTT)の支配するインターネットにおいてどれほどのボトルネックであったかは、実務でパケットキャプチャを眺めるエンジニアなら痛いほど承知していることだろう。
HTTP/2はこの冗長性を打ち破るべく、RFC 7540およびRFC 7541にてHPACKという専用のヘッダー圧縮スキームを導入した。静的テーブル(Static Table)が事前定義されたよくあるヘッダーフィールドの集合であるのに対し、「動的テーブル(Dynamic Table)」は、コネクションのライフサイクルを通じてその場で学習し、進化し続ける。
今回は、この動的テーブルの内部メカニズム、パケット上のバイナリ表現、メモリ管理の裏側、そして一歩間違えば致命的な脆弱性(HTTP/2 Continuation Floodなど)へと直結するセキュリティの急所まで、徹底的に解剖していこう。
—
1. 静的テーブルから動的テーブルへ:パケット削減のメカニズム
HPACKの基本思想はシンプルだ。「すでに送った文字列は、2回目以降は小さな整数インデックスに置き換えて送る」。
静的テーブルには、あらかじめ61個の標準的なヘッダーフィールド(例:`:method: GET` はインデックス2、`content-type: application/json` はインデックス31など)が定義されている。しかし、`cookie: session_id=abc123xyz…` や独自のカスタムヘッダーなどは静的テーブルには存在しない。ここで動的テーブルが真価を発揮する。
動的テーブルのライフサイクル
1. エンコード(送信側): 送信側は、リクエストまたはレスポンスに含まれるヘッダーペア(名前と値)を評価する。もしそれが動的テーブル(または静的テーブル)に存在しない場合、そのペアを新しいエントリとして動的テーブルの先頭(Index 62以降)に追加する。
2. 送信: ペアそのものを文字列として送る代わりに、動的テーブル上のインデックス番号をバイナリフレームとしてワイヤーに流す。
3. デコード(受信側): 受信側(リバースプロキシやブラウザ)は、同じコネクション上で同じアルゴリズム・順序で動的テーブルを維持しているため、受信したインデックスを元のヘッダーペアに正確に復元し、同時に自身の動的テーブルを更新する。
この仕組みにより、数バイトのインデックス表現だけで、数百バイトのCookieや認可トークンを完全に置き換えることが可能になる。
—
2. 内部アルゴリズム:インデックスの割り当てとFIFOの現実
動的テーブルは、構造的には「FIFO(First-In, First-Out)のキュー」でありながら、ルックアップのためには「インデックスアクセス」が可能な双方向のデータ構造として実装される。
エントリの追加とインデックスの動的シフト
RFC 7541に基づき、動的テーブルのエントリは追加されるたびにインデックスがインクリメントされ、常に最も新しいエントリが最小の動的インデックス(62〜)を占有する。
[ 新しいエントリ (Index: 62) ] <-- 先頭に追加される [ 古いエントリ (Index: 63) ] [ 最も古いエントリ (Index: N) ] <-- キャパシティを超えるとここからパージされる 動的テーブルのエントリサイズ($Size$)は、RFC 7541の規定により以下の計算式で厳密に算出される。 $$\text{Entry Size} = \text{Octets in Header Name} + \text{Octets in Header Value} + 32$$ 末尾の `32` は、エントリ管理のためのオーバーヘッド(メモリ上の構造体の維持コスト等)を表現したものである。実装者は、このサイズ計算を正確に行わなければ、クライアントとサーバー間でテーブルの状態(State)が乖離し、HPACKコンテキストの破損(Connection Error: COMPRESSION_ERROR)を引き起こすことになる。
—
3. メモリ管理とテーブルサイズ制限(SETTINGS_HEADER_TABLE_SIZE)
動的テーブルは無限に肥大化させるわけにはいかない。メモリリソースの枯渇を防ぐため、HTTP/2の接続確立時のSETTINGSフレームにおいて、最大許容サイズをネゴシエーションする。
- `SETTINGS_HEADER_TABLE_SIZE` (0x01): デフォルトは4,096オクテット。
サーバーまたはクライアントは、この上限値(Maximum Size)を動的に変更する指示を、データストリームの外側(制御フレーム)から流すことができる。
サイズ超過時のエントリパージ(Eviction)
動的テーブルの現在合計サイズが、許容された最大サイズを超過した場合、テーブル管理システムは最も古いエントリ(FIFOの末尾)を、サイズを下回るまで次々と破棄(Evict)していく。
// [擬似コード] 動的テーブルへのエントリ追加とサイズ制限の強制
void hpack_dynamic_table_insert(hpack_table_t table, hstring_t name, hstring_t value) {
size_t entry_size = name.len + value.len + 32;
// 最大サイズを超える場合、古いエントリを削除する
while (table->current_size + entry_size > table->max_size && table->count > 0) {
hpack_entry_t oldest = &table->entries[table->tail];
table->current_size -= (oldest->name.len + oldest->value.len + 32);
free_entry(oldest);
table->tail = (table->tail + 1) % table->capacity;
table->count–;
}
// 新しいエントリを先頭に挿入
// …
}
このメモリ管理の厳密さが、NGINX、Envoy、Goの `net/http` などのプロダクション実装において、メモリリークを防ぐ防波堤となっている。
—
4. パケットレベルの解剖:バイナリ表現とプレフィックスコード
HPACKのデータは、HTTP/2の `HEADERS` フレームのペイロードとして流れる。ここでは、文字やインデックスが可変長のプレフィックス整数(Variable-Length Integer)としてエンコードされる。
例えば、インデックスを表現する場合、上位ビット(プレフィックス)のパターンによってそのエントリが何を意味するかが一意に決まる。
| プレフィックスパターン (二進数) | 意味・解釈 |
| :— | :— |
| `1xxxxxxx` | 静的または動的テーブルのインデックス参照(`xxxxxxx` が整数値) |
| `01xxxxxx` | インデックスとしての更新を伴う、リテラルヘッダー(名前はインデックス参照) |
| `0000xxxx` | 新規リテラルヘッダー(名前も値も生データ、動的テーブルに追加しない) |
| `0101xxxx` | 新規リテラルヘッダー(名前も値も生データ、動的テーブルに追加する) |
パケットアナライザー(Wiresharkなど)で覗くと、これらがビット単位でパッキングされ、ネットワーク帯域を1バイトたりとも無駄にしない執念が垣間見える。
—
5. 悪夢の脆弱性:HPACKボムとHTTP/2 Continuation Flood
美しく最適化された動的テーブルだが、設計上のトレードオフとしてセキュリティ上の重大な懸念を抱えている。それが「HPACK Bomb(Compression Bomb)」および「CVE-2023-44487(HTTP/2 Rapid Reset / Continuation Flood)」の文脈における動/静的テーブルの悪用だ。
脆弱性のメカニズム
攻撃者は、非常に小さなバイナリサイズのヘッダー(例えば、動的テーブルのサイズを最大値まで拡張し、数バイトのインデックス参照を大量に繰り返す、あるいは不正に巨大なヘッダーブロックを分割して送りつける)を送信する。
受信側のサーバーは、これをデコードするために指数関数的または線形的に膨大なメモリとCPUサイクルを消費させられ、結果としてOOM(Out of Memory) Killerが発動してサービスがダウンする。
現場のインフラエンジニアが取るべき防御策
モダンなLinuxカーネルやリバースプロキシ(Envoy, NGINX, Apache Traffic Serverなど)を使用している場合、以下のチューニングパラメータを適用して、悪意ある圧縮攻撃から身を守る必要がある。
1. テーブルサイズの厳格な制限:
不必要に大きな `SETTINGS_HEADER_TABLE_SIZE` を許可しない。多くのセキュアな環境では、これをデフォルトの4096、あるいはさらに小さく制限する。
2. リクエストあたりのヘッダー制限の導入:
NGINXであれば `large_client_header_buffers` や `http2_max_field_size`、Envoyであれば `max_request_headers_kb` を適切に設定し、過大なヘッダーを受け付けないようにする。
Envoy Proxyにおけるヘッダーサイズと制限の堅牢な設定例
static_resources:
listeners:
- name: ingress_http2
address:
socket_address: { address: 0.0.0.0, port_value: 443 }
filter_chains:
- transport_socket:
# TLS設定(省略)
filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
“@type”: type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
codec_type: HTTP2
http2_protocol_options:
max_concurrent_streams: 100
# 動的テーブルの初期サイズを制限し、HPACKボムの被害を最小化する
initial_stream_window_size: 65535
# リクエストヘッダー全体の最大サイズを制限 (例: 64KB)
max_request_headers_kb: 64
—
6. チューニングと実務的考察:RTT、TCPバッファ、そしてHTTP/3へ
動的テーブルが機能するためには、「同一のHTTP/2コネクション(TCPストリーム)」が維持されていることが絶対条件だ。
- TCPスロースタートとウィンドウチューニング:
HTTP/2は単一のTCPコネクション上で多重化(Multiplexing)を行うため、TCPの初期混雑ウィンドウ(InitWin: 通常10セグメント、約14.6KB)の最適化が、ヘッダー圧縮の恩恵を最大限に受けるための鍵となる。カーネルパラメータ(`net.ipv4.tcp_congestion_control = bbr` など)を組み合わせることで、最初のパケット群にヘッダーとリクエストボディを相乗りさせ、往復遅延を削り取る。
- HTTP/3 (QUIC) へのパラダイムシフト:
HTTP/2の動態圧縮が抱える「TCPのHead-of-Lineブロッキング問題に伴う、HPACKコンテキストの同期遅延」というジレンマは、次世代のHTTP/3(QUICプロトコル上のQPACK)によって解決された。QPACKでは、パケットロスによる順序逆転の影響を受けないよう、動的テーブルの更新を非同期化するアプローチ(Acknowledgment機構)が採用されている。しかし、HTTP/2とHPACKがモダンWebインフラの基盤として今なお現役である事実は変わらない。
—
結びにかえて
HPACKの動動的テーブルは、単なる「データ圧縮のアルゴリズム」ではない。限られたネットワーク帯域、ミリ秒を争うレイテンシ、そしてメモリ枯渇を狙う攻撃者との攻防が交差する、プロトコル設計の芸術品である。
パケットアナライザーを開き、バイナリの海に沈むプレフィックスコードを読み解くとき、私たちは単なる設定作業者から、ネットワークという巨大な生態系をコントロールするアーキテクトへと昇華する。この仕組みの深層を理解したあなたなら、明日からのインフラ設計やトラブルシューティングにおいて、より鋭利な視点でパケットと向き合えるはずだ。
コメント