← 一覧へ戻る

Claude が書いた記事

HTTP/2 のフレームは TCP にどう乗ってるか ── バイト列に「長さ」で切れ目を入れる

HTTP/2 Bomb の脆弱性を調べてて、攻め口の手前で詰まった。そもそも HTTP/2 って TCP の上をどう流れてるんや? フレームと TCP セグメントが別単位という話を整理した学習ログ。

俺の最初の疑問

きっかけは HTTP/2 Bomb (CVE-2026-49975) や。あの脆弱性を調べてて「で、これは HTTP/2 をどうやって攻めるんや?」と疑問に思った。HPACK・フレーム・フロー制御 ── 攻め口の説明にそういう言葉が出てくるけど、その手前で引っかかった。

そもそも HTTP/2 って、TCP の上をどう流れてるんや?「フレーム」ってイーサネットフレームのことか?

攻撃の中身に入る前に、HTTP/2 ってなんやねん?を整理した記録。

まず一言でいうと

HTTP/2 フレームと TCP セグメントは別の単位。1 つのフレームが複数の TCP セグメントにまたがることがあるし、逆に 1 つのセグメントに複数のフレームが入ることもある。(TCP が一回に送り出すデータの単位は「セグメント」── 「パケット」は IP の単位や。詳しくは後述。)

カギは TCP が「切れ目のないバイト列」だということ。TCP はメッセージの境界を一切持たへん。ただ順番どおりにバイトを届けるだけ。だから上に乗る HTTP/2 は、各フレームの頭に「ここから何バイトがこのフレームか」を書いた固定長のヘッダを置く。受信側はその長さを頼りに、バイト列のどこで切れててもフレームの境界を復元できる。

これが フレーミング (バイト列にメッセージの切れ目を入れる仕組み) や。

何と比べるとわかるか

HTTP/1.1 と並べると、HTTP/2 が何をしてるかが見える。どっちも下は同じ TCP のバイト列やのに、境界の付け方が違う。

HTTP/1.1HTTP/2
境界の付け方テキストの区切り (\r\nContent-Length)バイナリの長さ前置き (9 バイトのフレームヘッダ)
1 単位メッセージ (リクエスト / レスポンス)メッセージをフレームに分割 (HEADERS / DATA / …)
区切りの読み方区切り文字が来るまで読む頭の Length を見て、その分だけ読む

HTTP/1.1 は「\r\n が来たらヘッダの行が終わり」みたいに区切り文字を探しながら読む。HTTP/2 は逆に最初に長さを宣言してしまう。長さが分かってるから、バイト列のどこでセグメントが切れても「あと何バイト足りない」が計算できる。

「メッセージ」(リクエスト / レスポンス 1 個) はどっちにもある考え方やけど、「フレーム」は HTTP/2 で新しく入った下位の単位。HTTP/1.1 にこれに当たる名前は無く、メッセージをまるごと 1 塊で送るだけや。

同じメッセージを 1 本の TCP でどう運ぶか。HTTP/1.1 (上) はメッセージをまるごと、区切り文字 (\r\n・Content-Length) で境界を取る。HTTP/2 (下) は同じメッセージを複数のフレームに刻み、各フレームの頭に Length を前置きする ── 境界の付け方が「区切り文字」から「長さ前置き」に変わった。これがフレーミング。

何がややこしいのか ── TCP には境界が無い

TCP を「パケットの集まり」と思うと混乱する。アプリから見た TCP は、1 本のバイト列にしか見えない。パケットの切れ目はアプリに見えへん。送る側の TCP は、都合 (MSS・輻輳・タイミング) で勝手にバイト列を区切って運ぶ。受け取る側の TCP は、それを順番どおりに繋いでアプリに渡す。どこで区切られたかはアプリに伝わらない

ここで「TCP コネクション 1 本」がイメージしにくい人へ ── 「パケット = だいたい 1500 バイト」みたいなサイズ感がコネクションには無いからや。サイズがどこに付くのかを、いちばんやさしい言葉で。

やさしいことばで 小学生にわかるように説明すると

「TCP コネクション 1 本」は、荷物が積める「入れ物」やなくて、自分の家と相手の家をつなぐ 1 本の道路 (1 車線)。道路そのものに「何キロ積める」っていう大きさは無いやろ? つながってるか・つながってないか、だけや。

トラックと、その荷物

その道路を走るのがトラック。送りたいデータは、トラックに荷物として積んで運ぶ。

  • トラック = パケット … 宛先ラベル (IP アドレス) を貼った車体。1 台 ~1500 バイトまで。「1500 バイト」と思い浮かぶのはこれ。
  • 荷物 = セグメント … トラックに載ってる中身 (TCP が送りたいデータ)。

つまりセグメント (荷物) が、パケット (トラック) に載って運ばれる。同じ物の呼び名違いやなくて、入れ子や

だから

  • 「コネクション 1 本」(道路) にサイズは無い。サイズはトラック (パケット) の方にある
  • 大きいデータは、何台ものトラックに分けて運ぶ。同じ道路 1 本で 1 台でも 100 台でも走れる。

HTTP/2 フレームの頭 9 バイトの内訳はこうなってる。

  • Length (3 バイト) … この後に続く payload が何バイトか
  • Type (1 バイト) … HEADERS / DATA / SETTINGS など
  • Flags (1 バイト)
  • Stream Identifier (4 バイト) … どのストリームのフレームか

この Type が「何のフレームか」を決める。1 つの HTTP メッセージは、ヘッダーとボディが種類ごとに別のフレームに分かれて運ばれる

  • HEADERS フレーム (Type=HEADERS) … HTTP ヘッダー (Host・Content-Type・:method など) を運ぶ。中身は HTTP/2 Bomb で出てきた HPACK で圧縮されたバイナリ
  • DATA フレーム (Type=DATA) … ボディを運ぶ。

HTTP/1.1 が「ヘッダーもボディも 1 つのテキストの塊」で送ってたのを、HTTP/2 は種類で分けてフレームに刻む

1 つのメッセージは HEADERS フレーム 1 個 → DATA フレーム複数を、同じストリームに順番に流すだけ (ヘッダーとデータをバルクで分離するんやない)。ヘッダーを 1 種類に固めるから HPACK が重複を番号で参照して圧縮でき、本体だけ flow control もかけられる ── 分けるのは無駄やなく、効率を上げる設計。

その「番号で参照」がどれくらいデータを削るか、具体で見るとこう。

同じ接続の 2 回目以降がどれだけ縮むか (例)。1 回目はヘッダーを送って表に登録 (~269B)、2 回目からは繰り返す分を番号 1 個で参照するだけ (~16B、約 94% 減)。リクエストを重ねるほどヘッダーのデータ量はほぼゼロに近づく ── これが HPACK の効きどころ。

受信側がやることは単純。「9 バイトのヘッダを読む → Length を見る → その分だけ payload として読む → 読み切ったら、次の 9 バイトはまた新しいヘッダ」をループするだけ。長さを前置きしてあるから、バイト列のどこで切れてても境界が復元できる。

ヘッダの Stream Identifier も効いてくる。これは「このフレームがどのやり取り (リクエスト) のものか」を示す名札で、これがあるから複数のリクエストのフレームを 1 本の TCP に混ぜて流せる (= 多重化)。受信側は名札で仕分けて元のやり取りに戻す。HTTP/1.1 が 1 本で 1 つずつしか流せなかったのと、ここが大きく違う ── ただ、この多重化は本記事の主役 (フレーム = 長さ前置き) の応用なので、深掘りは「次におすすめ」に譲る。

図で見る

まず、1 フレームが 3 つの TCP セグメントに分割されたとき、何がどこに入るか。

1 つの HTTP/2 フレームを TCP が 3 つのセグメントに切る。フレームヘッダーは先頭のセグメント① に 1 回だけ。② ③ は payload の続きだけで、ヘッダーは入っていない。セグメントの数は 3 のまま。

次に、その境界をどう見つけるか。TCP は切れ目のないバイト列で、HTTP/2 は「長さ」で切れ目を作る。

TCP が渡すのは切れ目のないバイト列 (上)。HTTP/2 は各フレームの頭に Length 付きの 9 バイトヘッダを置く (下)。受信側は「9 バイト読む → Length 分飛ばす → 次が新しい頭」を繰り返すだけ。境界は TCP セグメントの境界と一切揃わない。

混乱しやすいポイント

① 「フレーム」「セグメント」「パケット」── 層ごとに単位の名前が違う

network をやってると「フレーム」でまず浮かぶのはイーサネットフレームやし、「TCP パケット」と言われると違和感が出る ── その勘は正しい。各層で運ぶ単位の名前は別々やから。

単位の名前
アプリ (HTTP/2)フレーム
トランスポート (TCP)セグメント
ネットワーク (IP)パケット
データリンク (Ethernet)フレーム

だから「TCP パケット」は厳密には変で、TCP の単位はセグメントパケットは IP の単位や (「IP パケット」がすっと入るのはこれ)。「フレーム」も L2 と HTTP/2 で名前を使い回してるだけで中身は別物。そして上の単位は下の単位に包まれて運ばれる (入れ子)。HTTP/2 フレームは TCP セグメントの中身になり、そのセグメントは IP パケットに包まれ、さらにイーサネットフレームに包まれて線を流れる。この記事で「フレーム」と言ったら全部 HTTP/2 の方や。

(キャプチャツールが行を一律「Packet」と表示するから「TCP パケット」と錯覚しやすいけど、割れてる中身は TCP セグメントや。)

② 「組み立て直す」は表示の話やない

キャプチャしても、行は 3 分割なら 3 行のまま (ツールは各行を「Packet」と表示する)。3 つが 1 行に合体表示されるわけやない。フレームを「組み立て直す (reassembly)」のは、上位プロトコル (HTTP/2) を解釈するための内部のバイト連結であって、画面表示でも箱の数でもない。

ファイル読み込みと同じや。中身が HELLO のファイルを、OS は HELLO に分けて読むかもしれん。でもアプリは HELLO として扱える。OS が HE + LLO を内部で繋いだからといって、ディスク上のブロックが 1 個になったわけやない。TCP も同じで、Packet100 / 101 / 102 はそのまま。ただ HTTP/2 がそれを順に読んで「Length=4000 のフレームが揃った」と判断できるようになるだけ。

③ フレームヘッダーは先頭に 1 回だけ

分割されたとき、フレームヘッダー (9 バイト) が入ってるのは先頭のセグメントだけ。後続のセグメントは payload の続きしか入ってへん。「3 つに割れてるからヘッダーも 3 つ」やない。

④ 逆に 1 セグメントに複数フレームも入る

分割の逆もある。小さいフレームが続くなら、1 つの TCP セグメントに複数の HTTP/2 フレームが詰まる。フレームの境界とセグメントの境界は揃わへん ── これがフレーミングの本質や。

たとえ話

長さラベル付きの宅配。TCP は「箱を順番に届けるだけの運送屋」で、中身がどこで切れてるかは知らん。送り主 (HTTP/2) は中身を運送屋に渡す前に、各荷物の頭に「この荷物は 4000 グラム」と長さラベルを貼る。受け取り手は箱がどう分かれて届こうが、ラベルの数字どおりに測り取れば、荷物の切れ目を正しく復元できる。箱 (セグメント) の分かれ方と、荷物 (フレーム) の分かれ方は、最初から別物。

出てきた言葉の変換表

言葉つまり何の話?
フレームHTTP/2 のやり取りの単位 (HEADERS / DATA など)。TCP セグメントとは別単位
セグメントTCP が一回に送り出すデータの単位。IP の「パケット」・L2 の「フレーム」と層が違う
フレーミング切れ目のないバイト列に、メッセージの境界を入れる仕組み
Length (フレームヘッダの先頭 3 バイト)この後に続く payload のバイト数。境界を復元する頼り
Stream Identifierそのフレームがどのストリームのものか (多重化で効く)
reassembly (再構成)分割された payload を上位プロトコル解釈のため繋ぐ内部処理。表示の統合やない
バイトストリームTCP がアプリに見せる、切れ目のない 1 本のバイト列
長さ前置き (length-prefix)中身の前に「何バイトか」を書く境界の付け方。HTTP/2 のやり方
深掘りメニュー 次におすすめのトピック

この記事の続きとして、未来の自分が次に掘るといいトピック。

  • HPACK の動的テーブル ── HEADERS フレームの中で何が起きてるか。HTTP/2 Bomb の増幅の本丸で、フレームの中身に一段降りる話。
  • HTTP/2 の多重化とストリーム ── Stream Identifier で複数のやり取りを 1 本の TCP に混ぜる仕組み。フレーミングが効いてくる出口。
  • TLS レコード ── 同じ「長さ前置きで TCP に境界を作る」を暗号層でやってる。物差しを延ばす最初の一歩。
  • MSS / MTU と TCP セグメント化 ── そもそも TCP がどんな都合でバイト列を区切るか。「なぜまたがるのか」の下地。