基于pipe_buffer与buddy system的页级UAF利用技术分析
字数 4871
更新时间 2026-02-28 12:07:38

基于 pipe_buffer 与 buddy system 的页级 UAF 利用技术分析

本文基于 D^3CTF 2023 的 d3kcache 赛题,深入剖析利用 pipe_bufferbuddy 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 system2^order 个连续物理页作为分配粒度。攻击手法如下:

  1. 请求两份连续内存页:利用特定 API(如 PACKET_TX_RING)向 buddy system 申请两块物理连续的内存页池。
  2. 释放并重分配
    • 释放第一份内存页,在 victim kmem_cache(如 pipe_buffer 的 cache)上堆喷,让其取走这份页。
    • 释放第二份内存页,在 vulnerable kmem_cache(题目模块的 cache)上堆喷,让其取走这份页。
  3. 夹心布局:最终形成 [victim page] [vuln page] [victim page] 的物理布局。当 vuln page 发生越界写时,溢出的字节会直接破坏相邻 victim page 上的对象。

2. 题目分析:d3kcache

2.1 漏洞定位

题目模块 d3kcache.ko 创建了一个独立的 kmem_cachekcache_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_nr2^order 张连续内存页。通过大量申请和释放,可以耗尽低阶页,迫使内核分配物理连续的高阶页,从而稳定布局。

3.1.3 布局实现

  1. 占位:创建大量 PACKET_TX_RING 占满内存。
  2. 释放 Victim 槽:关闭部分 socket,释放出空洞,然后创建大量管道(pipe_buffer)占据这些空洞。这些就是未来的 victim page
  3. 释放 Vuln 槽:关闭紧邻的 socket,释放出空洞,然后通过题目模块的 ioctl 分配 kcache_jar 对象。此时,kcache_jar 的 slab page 被夹在两个 pipe_buffer 的 slab page 中间。
  4. 触发溢出:对 kcache_jar 的最后一个对象(位于页末尾)进行写操作,设置 sz=0x800。执行 obj->data[0x800] = 0,这一字节 0 正好写入下一页(pipe_buffer slab 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_bufferpage 指针可能指向了承载 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_bufferoffsetlen 字段会在每次 IO 后变化,导致指针“跑偏”,无法维持稳定的任意读写。

3.2.2 三管道循环维持机制(A-B-C Chain)

为了解决“跑偏”问题,需要三个管道互相修整,形成一个闭环:

管道 角色 作用 问题
Pipe A 工作管道 (Primitive) 实际执行 read/write,搬运目标数据。 每 IO 一次,A.offsetA.len 就变,很快指向错误地址。
Pipe B 控制管道 (Steering) 读取 UAF 数据,修改 Pipe Apipe_buffer,将 A.offsetA.len 重置回正确值。 修改 A 时,B 自己也要做 IO,导致 B 的元数据也跑偏。
Pipe C 维护管道 (Meta-Controller) 读取 UAF 数据,修改 Pipe Bpipe_buffer,将 B.offsetB.len 重置回正确值。 C 通常不需要被修,或者由 A 在后期修 C(链式或环形)。

工作流

  1. A 从目标地址读/写数据。
  2. A 的指针跑偏。
  3. B 读 UAF 页,找到 A 的结构,把 A 的指针拨回去。
  4. B 的指针跑偏。
  5. C 读 UAF 页,找到 B 的结构,把 B 的指针拨回去。
  6. 循环往复。

通过这个机制,只要 UAF 页保持映射,就能实现稳定的任意地址读写。

3.3 阶段三:提权

由于 CFI 的存在,放弃劫持 modprobe_path 或函数指针。采用最直接的方式:修改当前进程的 task_struct->credinit_cred

  1. 定位当前任务:使用 prctl(PR_SET_NAME, "myname") 设置进程的 comm 字段,然后在内核中扫描 task_struct 结构,通过匹配 comm 找到当前进程的 task_struct
  2. 查找 init_cred:通过当前任务的 real_parentgroup_leader 指针遍历任务链表,找到 init 进程(PID 1),其 cred 指针即为 init_cred
  3. 覆写 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. 总结

本技术栈是高级内核漏洞利用的集大成者,涉及以下知识点:

  1. Buddy System 逆向工程:绕过 SLUB 分配器,直接操作物理页分配。
  2. Cross-Cache 溢出:利用 Off-by-One 在页边界实现跨 Cache 的字节覆盖。
  3. Pipe Buffer 内存语义:利用 page 指针制造 UAF,并将管道转化为内存操作原语。
  4. 稳定性工程:通过多管道反馈循环解决状态机漂移问题。

这种利用手法虽然复杂,但是一种在强化防护(如 CONFIG_SLAB_FREELIST_HARDENED, CFI)下依然有效的通解。

 全屏