Abstract

Fraggap (CVE-2026-53362) は、Linux UDPv6 corking パスに存在するバグで、skb_shared_info に対して 15 バイトの OOB 書き込みを可能にします。コードは こちら です。

fraggap exploit flow

Vulnerability Summary

CONFIG_IPV6=y が必要です。

  1. 2 回の splice() 呼び出しが、新規 skb の背後にある skb_shared_info の先頭 15 バイトを上書きします。
  2. この OOB 書き込みにより、ヒープ上の残存パイプアドレスが frags[0] として扱われ、put_page() が呼び出されてダングリング・パイプページが生成されます。
  3. そのページは PTE ページとして再利用され、物理メモリの読み書きが可能になります。
  4. core_pattern を書き換えて root ヘルパーを実行します。

欠陥のある会計処理はコミット 773ba4fe9104 で導入されました。現在の MSG_SPLICE_PAGES トリガーはコミット ce650a166335 の後に到達可能になりました。IPv6 パスはコミット 736b380e28d0 で修正されました。

Vulnerability Analysis

Overview

UDP corking は複数の送信を 1 つのデータグラムにまとめます。データグラムがフラグメント境界をまたぐ場合、__ip6_append_data() は境界を超えたバイト数を前回の skb からの fraggap としてカウントし、次の skb のリニア領域に移動します。基本的な skb の構造は次のようになります。

pasted image 1784187985518

fraggap はリニア領域にコピーされるデータです。したがって、新しい skb のリニア割り当ての一部である必要があり、パイプページに残る長さから除外されなければなりません。脆弱なコードはその逆を行います。fraggap をリニアデータ領域から除去し、pagedlen に加えます。

カーネルはデータ領域にスペースを確保せずに fraggap バイトをリニア末尾にコピーします。これにより、末尾の直後にある skb_shared_info を任意の 15 バイトに設定できます。nr_frags バイトのみを 1 に設定すると、範囲外の frags[0] がヒープ上の残存パイプページアドレスを割り当て済みアドレスとして扱います。

Root cause

このバグは、同じバイトがパスによって異なるカウント方法で扱われるために発生します。datalen の構築では fraggap を次の skb が扱うデータとして含めます。一方、ページングブランチでは同じ fraggap を外部データの一部として扱います。

datalen = length + fraggap;
fraglen = datalen + fragheaderlen;
pagedlen = 0;

/* ... */

else {
    alloclen = fragheaderlen + transhdrlen;
    pagedlen = datalen - transhdrlen;
}

/* ... */

copy = datalen - transhdrlen - fraggap - pagedlen;
if (copy < 0 && !(flags & MSG_SPLICE_PAGES)) {
    err = -EINVAL;
    goto error;
}

datalenfraggap を加えますが、alloclen は加えません。pagedlendatalen から導出されるため、ギャップも含まれます。これにより copy-fraggap に縮小します。負のコピーチェックは MSG_SPLICE_PAGES が設定されている場合はスキップされます。

その後、コードは fraggap が非リニアデータであることを無視して、実際のリニアコピーを実行します。

data = skb_put(skb, fraglen - pagedlen);
data += fragheaderlen;

if (fraggap) {
    skb->csum = skb_copy_and_csum_bits(
        skb_prev, maxfraglen,
        data + transhdrlen, fraggap);
    /* ... */
}

最終的に、ページングブランチはギャップの内容を収まるスペースを確保せずにコピーします。

Triggering the 15-byte OOB

目標は、最初の skb がフラグメント境界よりちょうど 15 バイト大きくなるようにすることです。次の skb のリニア末尾が skb_shared_info の先頭に位置するようになります。

  1. 320 バイトのルーティングヘッダーと MTU 1287 を持つ UDPv6 ループバックソケット
  2. 最初の skb をフラグメント境界より 15 バイト大きくする 919 バイトのパイプペイロード
  3. 同じ cork キューに追加される 2 番目の 20 バイトのパイプペイロード

パイプからソケットへの splice() により splice_to_socket() が bvec イテレータを構築し、MSG_SPLICE_PAGES を設定します。SPLICE_F_MOREMSG_MORE になります。UDP_CORK と組み合わせることで、最初の skb を送信せずに write キューに保持します。

msg.msg_flags = MSG_SPLICE_PAGES;
if (flags & SPLICE_F_MORE)
    msg.msg_flags |= MSG_MORE;

/* ... */

iov_iter_bvec(&msg.msg_iter, ITER_SOURCE, bvec, bc,
               len - remain);
ret = sock_sendmsg(sock, &msg);

呼び出しチェーンは udpv6_sendmsg()ip6_append_data()__ip6_append_data()skb_splice_from_iter() です。プレーン UDP が使用されるため udpv6_sendmsg()ip_generic_getfrag を選択します。__ip6_append_data() はページスプライスパスを維持し、paged = true を設定します。これにより、脆弱なパスに到達し、ペイロードを skb フラグメントとしてアタッチします。

実際のシーケンスは以下の通りです。

  1. 最初の splice(919, SPLICE_F_MORE) は 8 バイトの UDP ヘッダーを加えて length = 927 を追加します。320 バイトのルーティングヘッダーにより fragheaderlen は 360 になります。最初の skb の長さはちょうど 1287 になります。
  2. UDP_CORK により skb はキューに保持されます。計算されたフラグメント境界は 1272 であるため、skb 末尾の最後の 15 バイトは次のフラグメントに移動する必要があります。
  3. 2 番目の splice(20, SPLICE_F_MORE) は同じキュー内の末尾 skb を見つけます。最初の copy は 0 で、新しい長さ 20 より小さいため、境界に対して copy = -15 として再計算されます。この値が alloc_new_skb に入り、fraggap = 15 で新しい skb が作成されます。

pasted image 1784190488457

必要な計算は以下の通りです。

fragheaderlen = 40 + 320 = 360
maxfraglen    = ((1287 - 360) & ~7) + 360 - 8 = 1272
first skb len = 360 + 8 + 919 = 1287
fraggap       = 1287 - 1272 = 15

second append: length=20, transhdrlen=0
datalen=35, alloclen=360, pagedlen=35, copy=-15

16 バイトのループバックヘッドルームと 8 バイトのフラグメントヘッダーリザーブを加えると、alloc_skb() リクエストは 384 になります。lts-6.12.85 では、このリクエストは 704 バイトの skbuff_small_head オブジェクトを使用します。最初の 384 バイトが skb データ領域で、最後の 320 バイトが skb_shared_info です。

skb_put(360) は末尾を skb->end に正確に移動し、続く data += fragheaderlen は同じアドレスを指します。したがって、前の skb の最後の 15 バイトは skb_shared_info + 0x00 から +0x0e にコピーされます。これにより、skb_shared_info に任意の 15 バイトを書き込むことができます。

pasted image 1784190717346

Primitive to Exploit

これで skb_shared_info の先頭に 15 バイトを書き込めるようになりました。最初の skb は境界の前に 1272 - 360 - 8 = 904 バイトのペイロードを保持しています。したがって、ソースペイロードの [904..918] が宛先の [+0x00..+0x0e] にマッピングされます。

nr_fragsskb_shared_info + 0x02 にあります。したがって、エクスプロイトはソースバイト 906 のみを 1 に設定し、残りを 0 のままにします。

pasted image 1784190867845

nr_frags はオフセット 0x02 にあり、frags[0]0x30 にあります。全体の skb_shared_info は 320 バイトです。完全な pahole 出力は Appendix にあります。

15 バイトしか制御できないため、フラグ以上のフィールドはほとんど制御できません。したがって、有用なフィールドは nr_frags のみです。実際のページ記述子を保持する frags[0] は OOB 範囲外にあります。どうすればこれを解決できるでしょうか?

以下の問題を解決することで root を取得できます。

Exploit Details

Exploit Summary

  • 未初期化変数の再利用 → 有効な skb_frag_t を保持していたオブジェクトを解放し、脆弱な skb として再割り当てして未初期化の frags[0] を再利用する
  • Fraggap OOBnr_frags を 0 から 1 に反転させ、frags[0] を割り当て済みとして扱わせる
  • パイプの追加 → 2 番目のパイプページをスロット 1 にアタッチし、nr_frags == 2 とする
  • 一致しないページ解放 → デストラクタが frags[0] に対して put_page() を実行する
  • Dirty Pagetable → ダングリングページを PTE ページとして再割り当てする
  • 物理書き込み → PTE を変更して core_pattern を見つけ、上書きして root ヘルパーを実行する

Background of the exploit

なぜ古い frags[] が生き残るのか?

新しい skb ヘッドを割り当てても、skb_shared_info のすべてがクリアされるわけではありません。__finalize_skb_around()dataref までしかゼロクリアしません。

shinfo = skb_shinfo(skb);
memset(shinfo, 0, offsetof(struct skb_shared_info, dataref));
atomic_set(&shinfo->dataref, 1);

frags[]dataref の後にあるため、前の割り当てからの記述子バイトが生き残る可能性があります。しかし nr_frags は 0 にリセットされるため、古い記述子は通常到達不能です。Fraggap OOB で nr_frags を 1 に設定すると、カーネルはそのページを割り当て済みとして扱います。

Dirty Pagetable

ページテーブルはページアロケータからの 4 KiB ページです。ダングリングパイプページを PTE ページとオーバーラップさせると、パイプ書き込みで PTE を破損できます。PTE の PFN のみを変更することで、希望する物理ページをユーザースペースにマッピングできます。

Planting a stale frags[0]

現時点では制御可能なフィールドは nr_frags のみで、必要な frags[0] は OOB 範囲外にあります。したがって、バグをトリガーする前に、エクスプロイトは同じキャッシュ内に有効な記述子を構築し、それらのバイトが残っているオブジェクトを再利用します。

まず、ソースパイプ wp に 256 バイトのマーカーを書き込みます。次に tee() を 8 つの保持パイプに対して実行し、ページ参照カウントを 9 に引き上げます。これらの追加参照により、グルーミングおよびトリガー中にソースページが生き続けます。

記述子は単一の corked データグラム内で構築されます。すべてのページングペイロードは tee() を介して同じソースページから来ます。したがって、各ターゲットヘッドは有効な記述子と frags[0] 内の有効なページ参照を取得します。

pasted image 1784192239418

トリガーソケットと 2 つのパイプペイロードは、グルームソケットが閉じられる前に準備されます。次に、2 つの splice が他の割り当てを挟まずに実行されます。最初のトリガー skb は 392 リクエストを持つため kmalloc-1k を使用し、small-head フリーリストには触れません。2 番目の 384 リクエストのみが、解放されたばかりのターゲットの 1 つを再利用します。

再利用されたオブジェクトは frags[0] にソースページ記述子バイトを保持しますが、nr_frags == 0 の間は無害です。次のステージでは OOB を使用してこの古いスロットを活性化します。

From nr_frags to a dangling page

Fraggap OOB が nr_frags = 1 に設定すると、デストラクタは frags[0] を実際のフラグメントとして扱います。2 番目の splice が新しい skb をキューに入れます。次のループ反復で、残りの 20 有効バイトが同じ skb にアタッチされます。

skb_append_pagefrags() は新しい記述子のインデックスとして現在の nr_frags を使用します。

int i = skb_shinfo(skb)->nr_frags;

if (skb_can_coalesce(skb, i, page, offset)) {
    skb_frag_size_add(&skb_shinfo(skb)->frags[i - 1], size);
} else if (i < max_frags) {
    get_page(page);
    skb_fill_page_desc_noacc(skb, i, page, offset, size);
}

インデックスはすでに 1 です。2 番目のトリガーパイプページはソース wp ページとは異なるため、frags[0] と結合せず、frags[1] に入ります。このパスは通常の get_page() を実行し、nr_frags を 2 に引き上げます。

ソケットを閉じると cork キューがフラッシュされ、skb デストラクタが両方のスロットを解放します。

for (i = 0; i < shinfo->nr_frags; i++)
    __skb_frag_unref(&shinfo->frags[i], skb->pp_recycle);

したがって frags[0] に対する put_page() が実行され、refcount が減少します。最後にパイプを閉じると、ダングリングページが残ります。

pasted image 1784191876526

Reclaiming as a PTE

アロケータに戻ったが wp->pipe_buffer からまだ指されている 4 KiB ページが存在します。mmap() を呼び出し、PTE ページを作成するためにタッチすると、解放されたページが再利用されます。

これで、ユーザースペースから任意の物理ページを読み書きできるようになりました。

Finding and overwriting core_pattern

PTE 制御による物理的な読み書きにより、core_pattern を検索します。

見つけたら、値を以下のように変更します。子プロセスが SIGSEGV を発生させると、カーネルは以下のコマンドを root として実行します。

|/proc/%P/fd/666 %P

Flag

Fraggap kernelCTF result

Appendix

Verified skb_shared_info layout

struct skb_shared_info {
	__u8                       flags;                /*     0   0x1 */
	__u8                       meta_len;             /*   0x1   0x1 */
	__u8                       nr_frags;             /*   0x2   0x1 */
	__u8                       tx_flags;             /*   0x3   0x1 */
	short unsigned int         gso_size;             /*   0x4   0x2 */
	short unsigned int         gso_segs;             /*   0x6   0x2 */
	struct sk_buff *           frag_list;            /*   0x8   0x8 */
	union {
		struct skb_shared_hwtstamps hwtstamps;   /*  0x10   0x8 */
		struct xsk_tx_metadata_compl xsk_meta;   /*  0x10   0x8 */
	};                                               /*  0x10   0x8 */
	unsigned int               gso_type;             /*  0x18   0x4 */
	u32                        tskey;                /*  0x1c   0x4 */
	atomic_t                   dataref;              /*  0x20   0x4 */
	unsigned int               xdp_frags_size;       /*  0x24   0x4 */
	void *                     destructor_arg;       /*  0x28   0x8 */
	skb_frag_t                 frags[17];            /*  0x30 0x110 */

	/* size: 320, cachelines: 5, members: 14 */
};

Mitigation

The patch

パッチは上記の不変条件を復元します。リニア領域に実際にコピーされる fraggap を追加し、非リニア側で同じ値を減算します。

- alloclen = fragheaderlen + transhdrlen;
- pagedlen = datalen - transhdrlen;
+ alloclen = fragheaderlen + transhdrlen + fraggap;
+ pagedlen = datalen - transhdrlen - fraggap;

IPv6 修正では、負の copy チェックから MSG_SPLICE_PAGES 例外も削除されます。IPv4 パスにも同じ会計バグがあり、コミット eca856950f7c で修正されました。

Partial mitigations

splice() を制限することは機能しますが、良い選択肢ではありません。

Timeline

DateEvent
2022-07-12バグはコミット 773ba4fe9104 で導入されました。
2023-08-02現在の MSG_SPLICE_PAGES 破損はコミット ce650a166335 の後に可能になりました。
2026-05-15脆弱性を [email protected] に非公開で報告しました。
2026-06-21IPv6 修正 736b380e28d0 が netdev net ツリーに取り込まれました。
2026-06-25修正が Linux mainline にマージされました。
2026-07-04Linux kernel CNA が CVE-2026-53362 を公開しました。
2026-07-14Openwall linux-distros リストに通知しました。
2026-07-16Linux kernel CNA が CVE-2026-53366 を公開しました。
2026-07-20writeup とエクスプロイトを公開しました。

Acknowledgements

Fraggap は IPv4/IPv6 で @physicube@qwerty によって発見され、kernelCTF で使用されました。IPv4 バグが悪用可能であることを確認してくれた Sultan Alsawaf (team CIQ) に感謝します。

References