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

漏洞摘要
需要
CONFIG_IPV6=y。
- 两次
splice()调用覆盖新 skb 后面的skb_shared_info的前 15 字节。 - 越界写入使堆上的残留管道地址充当
frags[0],随后触发put_page()以创建悬垂管道页。 - 该页被回收为 PTE 页,从而可以读写物理内存。
- 它将
core_pattern重写为运行 root helper。
有问题的记账随提交 773ba4fe9104 引入。当前的 MSG_SPLICE_PAGES 触发器在提交 ce650a166335 之后变得可达。IPv6 路径已在提交 736b380e28d0 中修复。
漏洞分析
概述
UDP corking 将多次发送合并为一个数据报。数据报可能跨越分片边界。然后 __ip6_append_data() 将前一个 skb 中超过边界的字节计为 fraggap 并将其移入下一个 skb 的线性区域。基本 skb 如下所示。

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 的起始处。
- 一个带有 320 字节路由头且 MTU 为 1287 的 UDPv6 环回套接字
- 一个 919 字节的管道负载,使第一个 skb 恰好比分片边界大 15 字节
- 附加到同一 cork 队列的第二个 20 字节管道负载
从管道到套接字的 splice() 调用会使 splice_to_socket() 构建 bvec 迭代器并设置 MSG_SPLICE_PAGES。 SPLICE_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 分片的易受攻击路径。
实际序列如下。
- 第一次
splice(919, SPLICE_F_MORE)在添加 8 字节 UDP 头后附加length = 927。320 字节路由头使fragheaderlen为 360。第一个 skb 长度恰好为 1287。 UDP_CORK将 skb 保留在队列中。计算出的分片边界为 1272,因此 skb 尾部的最后 15 字节必须移到下一个分片。- 第二次
splice(20, SPLICE_F_MORE)找到同一队列中的尾部 skb。第一个copy为 0 且小于新长度 20,因此根据边界重新计算为copy = -15。该值进入alloc_new_skb并创建一个fraggap = 15的新 skb。

所需的计算如下。
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。

用于利用的原语
我们现在可以在 skb_shared_info 的起始处写入 15 字节。第一个 skb 在边界前持有 1272 - 360 - 8 = 904 个负载字节。因此源负载 [904..918] 映射到目标 [+0x00..+0x0e]。
nr_frags 位于 skb_shared_info + 0x02。因此利用程序仅将源字节 906 设置为 1,其余保持为 0。

nr_frags 位于偏移量 0x02,frags[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] 中的有效页引用。

触发套接字和两个管道负载在 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。最后关闭管道会留下悬垂页。

回收为 PTE
现在有一个 4 KiB 页已返回分配器,但仍被 wp->pipe_buffer 指向。调用 mmap() 然后触摸它以创建 PTE 页即可回收已释放的页。
现在可以从用户空间读写任何物理页。
查找并覆盖 core_pattern
通过 PTE 控制的物理读写,我们搜索 core_pattern。
找到后,我们将其值更改如下。当子进程随后引发 SIGSEGV 时,内核将以下命令作为 root 运行。
|/proc/%P/fd/666 %P
Flag

附录
已验证的 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-21 | IPv6 修复 736b380e28d0 已进入 netdev net 树。 |
| 2026-06-25 | 修复已合并到 Linux 主线。 |
| 2026-07-04 | Linux 内核 CNA 发布了 CVE-2026-53362。 |
| 2026-07-14 | 我们已通知 Openwall linux-distros 列表。 |
| 2026-07-16 | Linux 内核 CNA 发布了 CVE-2026-53366。 |
| 2026-07-20 | 我们发布了 writeup 和 exploit。 |
致谢
Fraggap 由 @physicube 和 @qwerty 在 IPv4/IPv6 中发现,并在 kernelCTF 中使用。我们感谢 Sultan Alsawaf(CIQ 团队)确认 IPv4 漏洞可被利用。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.