摘要

Fraggap (CVE-2026-53362) 是 Linux UDPv6 corking 路径中的一个漏洞,可向 skb_shared_info 执行 15 字节的越界写入。代码位于 此处

fraggap exploit flow

漏洞摘要

需要 CONFIG_IPV6=y

  1. 两次 splice() 调用覆盖新 skb 后面的 skb_shared_info 的前 15 字节。
  2. 越界写入使堆上的残留管道地址充当 frags[0],随后触发 put_page() 以创建悬垂管道页。
  3. 该页被回收为 PTE 页,从而可以读写物理内存。
  4. 它将 core_pattern 重写为运行 root helper。

有问题的记账随提交 773ba4fe9104 引入。当前的 MSG_SPLICE_PAGES 触发器在提交 ce650a166335 之后变得可达。IPv6 路径已在提交 736b380e28d0 中修复。

漏洞分析

概述

UDP corking 将多次发送合并为一个数据报。数据报可能跨越分片边界。然后 __ip6_append_data() 将前一个 skb 中超过边界的字节计为 fraggap 并将其移入下一个 skb 的线性区域。基本 skb 如下所示。

pasted image 1784187985518

fraggap 是将被复制到线性区域的数据。因此它必须包含在新 skb 的线性分配中,并且必须从管道页上剩余的长度中排除。易受攻击的代码却做了相反的操作。它将 fraggap 从线性数据区域移除并放入 pagedlen

内核将 fraggap 字节复制到线性尾部,而未在数据区域预留空间。这使我们能够将紧跟在尾部后面的 skb_shared_info 设置为任意 15 字节。仅将单个 nr_frags 字节设置为 1,就会使越界范围的 frags[0] 将堆上残留的管道页地址视为已分配地址。

根本原因

此漏洞的发生是因为同一字节在不同路径下被以不同方式计数。构建 datalen 时将 fraggap 视为下一个 skb 必须处理的数据。而 paged 分支则将相同的 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;
}

datalen 加上 fraggap,但 alloclen 没有。 pagedlen 派生自 datalen,因此也包含该间隙。这使得 copy 缩减为 -fraggap。当设置 MSG_SPLICE_PAGES 时,负值 copy 检查被跳过。

之后代码忽略 fraggap 是非线性数据的事实,并执行真正的线性复制。

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

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

最终,paged 分支在未为其预留空间的情况下复制了间隙内容。

触发 15 字节越界写入

目标是使第一个 skb 恰好比分片边界大 15 字节。下一个 skb 的线性尾部随后落在 skb_shared_info 的起始处。

  1. 一个带有 320 字节路由头且 MTU 为 1287 的 UDPv6 环回套接字
  2. 一个 919 字节的管道负载,使第一个 skb 恰好比分片边界大 15 字节
  3. 附加到同一 cork 队列的第二个 20 字节管道负载

从管道到套接字的 splice() 调用会使 splice_to_socket() 构建 bvec 迭代器并设置 MSG_SPLICE_PAGESSPLICE_F_MORE 变为 MSG_MORE。结合 UDP_CORK,这会将第一个 skb 保留在写入队列中而不是发送它。

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() 保留 page-splice 路径并设置 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. 第二次 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_copy_and_csum_bits 被复制到 skb_shared_info + 0x00+0x0e。这使我们能够将想要的 15 字节写入 skb_shared_info

pasted image 1784190717346

用于利用的原语

我们现在可以在 skb_shared_info 的起始处写入 15 字节。第一个 skb 在边界前持有 1272 - 360 - 8 = 904 个负载字节。因此源负载 [904..918] 映射到目标 [+0x00..+0x0e]

nr_frags 位于 skb_shared_info + 0x02。因此利用程序仅将源字节 906 设置为 1,其余保持为 0。

pasted image 1784190867845

nr_frags 位于偏移量 0x02frags[0] 位于 0x30。整个 skb_shared_info 为 320 字节。完整的 pahole 输出位于 附录

仅凭 15 字节,我们能控制的比一个标志多不了多少。因此唯一有用的字段是 nr_frags。将保存真实页描述符的 frags[0] 位于越界范围之外。我们如何解决这个问题?

解决以下问题即可获得 root。

利用细节

利用摘要

  • 重用未初始化变量 → 释放一个曾持有有效 skb_frag_t 的对象,然后将其重新分配为易受攻击的 skb,以重用未初始化的 frags[0]
  • Fraggap 越界写入 → 将 nr_frags 从 0 翻转为 1,使 frags[0] 被视为已分配
  • 附加管道 → 将第二个管道页附加到槽位 1,使 nr_frags == 2
  • 不匹配的页释放 → 析构函数对 frags[0] 运行 put_page()
  • 脏页表 → 将悬垂页重新分配为 PTE 页
  • 物理写入 → 更改 PTE 以查找并覆盖 core_pattern 并运行 root helper

利用背景

为什么残留的 frags[] 能存活?

分配新的 skb head 并不会清除 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 越界写入将 nr_frags 设置为 1,使内核将该页视为已分配。

脏页表

页表是来自页分配器的 4 KiB 页。将悬垂管道页与 PTE 页重叠,可让管道写入破坏 PTE。仅更改 PTE 的 PFN,即可将所需物理页映射到用户空间。

植入残留 frags[0]

目前我们唯一能控制的字段是 nr_frags,而所需的 frags[0] 位于越界范围之外。因此在触发漏洞之前,利用程序在同一缓存中构建一个有效描述符,并回收一个这些字节仍保留的对象。

首先,它向源管道 wp 写入 256 字节标记。然后通过 tee() 将其复制到 8 个持有管道,将页引用计数提升至 9。这些额外引用在 grooming 和触发期间保持源页存活。

描述符是在单个 corked 数据报中构建的。所有 paged 负载均通过 tee() 来自同一源页。因此每个目标 head 都获得一个有效的描述符和 frags[0] 中的有效页引用。

pasted image 1784192239418

触发套接字和两个管道负载在 groom 套接字关闭前准备好。随后在两次 splices 之间不进行其他分配。第一个触发 skb 的请求为 392,因此使用 kmalloc-1k 且不触及 small-head 空闲列表。只有第二个 384 请求会重用刚释放的目标之一。

被重用的对象现在在 frags[0] 中持有源页描述符字节,但在 nr_frags == 0 时保持无害。下一阶段使用越界写入激活此残留槽位。

nr_frags 到悬垂页

一旦 Fraggap 越界写入将 nr_frags = 1,析构函数就会将 frags[0] 视为真正的分片。第二次 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。第二个触发管道页不同于源 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

回收为 PTE

现在有一个 4 KiB 页已返回分配器,但仍被 wp->pipe_buffer 指向。调用 mmap() 然后触摸它以创建 PTE 页即可回收已释放的页。

现在可以从用户空间读写任何物理页。

查找并覆盖 core_pattern

通过 PTE 控制的物理读写,我们搜索 core_pattern

找到后,我们将其值更改如下。当子进程随后引发 SIGSEGV 时,内核将以下命令作为 root 运行。

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

Flag

Fraggap kernelCTF result

附录

已验证的 skb_shared_info 布局

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 */
};

缓解措施

补丁

该补丁恢复了上述不变量。它添加了真正被复制到线性区域的 fraggap,并在非线性侧减去相同的值。

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

IPv6 修复还从负 copy 检查中移除了 MSG_SPLICE_PAGES 例外。IPv4 路径存在相同的记账漏洞,已在提交 eca856950f7c 中修复。

部分缓解措施

限制 splice() 有效,但不是一个好的选择。

时间线

日期事件
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 主线。
2026-07-04Linux 内核 CNA 发布了 CVE-2026-53362
2026-07-14我们已通知 Openwall linux-distros 列表。
2026-07-16Linux 内核 CNA 发布了 CVE-2026-53366
2026-07-20我们发布了 writeup 和 exploit。

致谢

Fraggap 由 @physicube@qwerty 在 IPv4/IPv6 中发现,并在 kernelCTF 中使用。我们感谢 Sultan Alsawaf(CIQ 团队)确认 IPv4 漏洞可被利用。

参考资料