基于 pipe_buffer 与 buddy system 的页级 UAF 利用技术分析
本文基于 D^3CTF 2023 的 d3kcache 赛题,深入剖析利用 pipe_buffer 与 buddy system 实现页级 UAF(Use-After-Free)的复杂利用技术。该技术突破了传统 slub allocator 的隔离,通过物理内存页的重新分配,将漏洞转化为对内核任意地址的读写能力。
1. 核心原理:页级堆风水(Page-level Heap Fengshui)
1.1 什么是页级 UAF?
传统的 UAF 发生在 kmem_cache 的 slub 层面,对象被释放回 freelist 后被重新分配。而 页级 UAF 指的是对内存页结构体 struct page 的释放后利用。当 kmem_cache 的 slab page 耗尽时,会向底层的 buddy system 请求新的物理页。如果攻击者能控制 buddy system 释放的物理页被重新分配给另一个子系统(如 pipe_buffer),就能打破不同 kmem_cache 之间的隔离。
1.2 利用 Buddy System 进行布局
buddy system 以 2^order 个连续物理页作为分配粒度。攻击手法如下:
- 请求两份连续内存页:利用特定 API(如
PACKET_TX_RING)向buddy system申请两块物理连续的内存页池。 - 释放并重分配:
- 释放第一份内存页,在
victim kmem_cache(如pipe_buffer的 cache)上堆喷,让其取走这份页。 - 释放第二份内存页,在
vulnerable kmem_cache(题目模块的 cache)上堆喷,让其取走这份页。
- 释放第一份内存页,在
- 夹心布局:最终形成
[victim page] [vuln page] [victim page]的物理布局。当vuln page发生越界写时,溢出的字节会直接破坏相邻victim page上的对象。
2. 题目分析:d3kcache
2.1 漏洞定位
题目模块 d3kcache.ko 创建了一个独立的 kmem_cache(kcache_jar),对象大小为 2048 字节(正好占半页)。在 ioctl 的写操作(case 0x800)中,存在典型的 Off-by-One Null (Off-by-Null) 漏洞:
// 伪代码还原
struct kcache_cmd {
int idx;
unsigned int sz; // 用户可控大小
void *buf;
} arg;
copy_from_user(&arg, user_arg, 0x10);
obj = kcache_list[arg.idx];
if (arg.sz > 0x800) arg.sz = 0x800;
// 漏洞:写入 arg.sz 字节,但在末尾多写了一个 0
memcpy(obj->data, arg.buf, arg.sz);
obj->data[arg.sz] = 0; // Off-by-Null 写到了下一个对象/页的开头
2.2 利用难点
- 对象大小 2K:在 4K 页中,一页只能放两个对象。第二个对象(
obj1)的末尾紧贴页末。Off-by-Null 会写入下一页的第 0 字节。 - 独立 Cache:无法直接溢出到通用的
kmalloc-*对象,必须通过buddy system进行跨 Cache 溢出。 - CFI 保护:开启了
CONFIG_CFI_CLANG,直接劫持函数指针会崩溃,必须采用数据导向的攻击(如修改cred)。
3. 利用链构建
3.1 阶段一:物理页布局与一级 UAF
3.1.1 选择 Victim:pipe_buffer
为什么选择 pipe_buffer?
- 结构特性:
struct pipe_buffer的第一个字段是struct page *page(指向数据页)。如果 Off-by-Null 覆盖了pipe_buffer数组的第一个元素的低字节为 0,可能使其page指针指向一个错误的struct page(例如指向了承载pipe_buffer数组本身的那张 slab page)。 - 可控性:管道允许用户态直接读写数据页,且可以通过
fcntl(F_SETPIPE_SZ)动态调整pipe_buffer的数量,从而控制其从kmalloc-cg-1k还是kmalloc-cg-2k分配(本题需要 2K 以对齐题目对象)。
3.1.2 页分配器:PACKET_TX_RING
为了精确控制 buddy system 的页分配,我们使用 AF_PACKET 套接字的 PACKET_TX_RING 选项。
- 调用链:
setsockopt(PACKET_TX_RING)->packet_set_ring()->alloc_pg_vec()->__get_free_pages()。 - 作用:
alloc_pg_vec会直接向buddy system请求tp_block_nr份2^order张连续内存页。通过大量申请和释放,可以耗尽低阶页,迫使内核分配物理连续的高阶页,从而稳定布局。
3.1.3 布局实现
- 占位:创建大量
PACKET_TX_RING占满内存。 - 释放 Victim 槽:关闭部分 socket,释放出空洞,然后创建大量管道(
pipe_buffer)占据这些空洞。这些就是未来的victim page。 - 释放 Vuln 槽:关闭紧邻的 socket,释放出空洞,然后通过题目模块的
ioctl分配kcache_jar对象。此时,kcache_jar的 slab page 被夹在两个pipe_buffer的 slab page 中间。 - 触发溢出:对
kcache_jar的最后一个对象(位于页末尾)进行写操作,设置sz=0x800。执行obj->data[0x800] = 0,这一字节 0 正好写入下一页(pipe_bufferslab page)的第一个字节。
3.1.4 75% 成功率原理
pipe_buffer 数组在内存中是连续存放的。假设有 64 个 pipe_buffer(总大小 64*32=2048B),它们占据了一整张 4K 页。
- 页前半部分(低 2K)存放
pipe_buffer[0]到pipe_buffer[31]。 - 页后半部分(高 2K)存放
pipe_buffer[32]到pipe_buffer[63]。
Off-by-Null 覆盖的是下一页的第 0 字节,即下一页低 2K 的第一个字节。这个位置对应的是下一页中 pipe_buffer 数组的索引 0 的 struct page *page 指针的最低字节。
- 情况 A (75%):该指针原来的最低字节非 0。被写 0 后,指针向前偏移(或不变,但指向了另一张页)。此时,该
pipe_buffer的page指针可能指向了承载pipe_buffer数组本身的那张 slab page(自指)或另一张可控页。这就形成了 页级 UAF:管道读写的物理页变成了它自己的控制结构所在页。 - 情况 B (25%):该指针原来的最低字节已经是 0。写 0 后指针不变,利用失败。
3.2 阶段二:二级 UAF 与任意读写原语
获得一级 UAF 后,我们可以通过 UAF 管道读取到 pipe_buffer 结构体本身的内容,泄露内核地址(如 pipe_buf_operations)。但我们的目标是任意读写。
3.2.1 从 UAF 到 Dirty Pipe
通过 UAF 管道,我们可以直接修改其对应的 pipe_buffer 结构体。如果我们修改 pipe_buffer.page 指针指向任意内核地址,那么后续对该管道的 read/write 就会操作目标地址,这就是类 Dirty Pipe 原语。
问题:pipe_buffer 的 offset 和 len 字段会在每次 IO 后变化,导致指针“跑偏”,无法维持稳定的任意读写。
3.2.2 三管道循环维持机制(A-B-C Chain)
为了解决“跑偏”问题,需要三个管道互相修整,形成一个闭环:
| 管道 | 角色 | 作用 | 问题 |
|---|---|---|---|
| Pipe A | 工作管道 (Primitive) | 实际执行 read/write,搬运目标数据。 |
每 IO 一次,A.offset 和 A.len 就变,很快指向错误地址。 |
| Pipe B | 控制管道 (Steering) | 读取 UAF 数据,修改 Pipe A 的 pipe_buffer,将 A.offset 和 A.len 重置回正确值。 |
修改 A 时,B 自己也要做 IO,导致 B 的元数据也跑偏。 |
| Pipe C | 维护管道 (Meta-Controller) | 读取 UAF 数据,修改 Pipe B 的 pipe_buffer,将 B.offset 和 B.len 重置回正确值。 |
C 通常不需要被修,或者由 A 在后期修 C(链式或环形)。 |
工作流:
- A 从目标地址读/写数据。
- A 的指针跑偏。
- B 读 UAF 页,找到 A 的结构,把 A 的指针拨回去。
- B 的指针跑偏。
- C 读 UAF 页,找到 B 的结构,把 B 的指针拨回去。
- 循环往复。
通过这个机制,只要 UAF 页保持映射,就能实现稳定的任意地址读写。
3.3 阶段三:提权
由于 CFI 的存在,放弃劫持 modprobe_path 或函数指针。采用最直接的方式:修改当前进程的 task_struct->cred 为 init_cred。
- 定位当前任务:使用
prctl(PR_SET_NAME, "myname")设置进程的comm字段,然后在内核中扫描task_struct结构,通过匹配comm找到当前进程的task_struct。 - 查找 init_cred:通过当前任务的
real_parent或group_leader指针遍历任务链表,找到init进程(PID 1),其cred指针即为init_cred。 - 覆写 cred:使用三管道原语将当前进程的
cred字段覆盖为init_cred的地址。退出 shell 获得 root 权限。
4. 关键代码片段与数据结构
4.1 页地址转换
在 x86_64 四级页表中,需要熟悉两个关键区域:
- Direct Map Base (
0xffff888000000000):物理内存的直接映射。VA = base + PA。 - Vmemmap Base (
0xffffea0000000000):所有struct page的数组映射。page = vmemmap_base + PFN * 0x40。
// 通过 direct map 地址找到对应的 struct page 地址
size_t direct_map_addr_to_page_addr(size_t direct_map_addr) {
size_t page_count = ((direct_map_addr & (~0xfff)) - page_offset_base) / 0x1000;
return vmemmap_base + page_count * 0x40; // sizeof(struct page) = 0x40
}
4.2 Packet Ring 分配器
int create_socket_and_alloc_pages(unsigned int size, unsigned int nr) {
struct tpacket_req req;
int socket_fd = socket(AF_PACKET, SOCK_RAW, PF_PACKET);
// ... 设置 PACKET_VERSION ...
req.tp_block_size = size; // 例如 0x1000 (4K, order=0)
req.tp_block_nr = nr; // 申请的数量
req.tp_frame_size = 0x100;
req.tp_frame_nr = (size * nr) / req.tp_frame_size;
setsockopt(socket_fd, SOL_PACKET, PACKET_TX_RING, &req, sizeof(req));
return socket_fd;
}
4.3 管道大小控制(切换 Cache)
// 默认 pipe_bufs = 16, 16*sizeof(pipe_buffer)=512B,会从 kmalloc-cg-512 分配
// 调整为 64,使得 64*32=2048B,从 kmalloc-cg-2k 分配,与题目对象对齐
fcntl(pipe_fd, F_SETPIPE_SZ, 64 * sizeof(struct pipe_buffer));
5. 总结
本技术栈是高级内核漏洞利用的集大成者,涉及以下知识点:
- Buddy System 逆向工程:绕过 SLUB 分配器,直接操作物理页分配。
- Cross-Cache 溢出:利用 Off-by-One 在页边界实现跨 Cache 的字节覆盖。
- Pipe Buffer 内存语义:利用
page指针制造 UAF,并将管道转化为内存操作原语。 - 稳定性工程:通过多管道反馈循环解决状态机漂移问题。
这种利用手法虽然复杂,但是一种在强化防护(如 CONFIG_SLAB_FREELIST_HARDENED, CFI)下依然有效的通解。