↓Skip to main content

pwn.college KylebotFS Level 4 Writeup

·5505 words·11 mins·
CTF PWN Kernel Buddy Allocator Page UAF Migratetype VMA
Table of Contents
Kernel Pwn 笔记 - This article is part of a series.
Part 4: This Article
本文由 AI 辅助编写,内容经人工校对。

允许wp

免责声明:这个系列鼓励wp :)

题目背景与 Diff 分析
#

KylebotFS 是 pwn.college The Quarterly Quiz 系列中的自定义内核文件系统模块(challenge.ko)。用户可以通过挂载构造好的磁盘镜像,经由常规 VFS 接口(open / mmap / munmap)与内核模块交互。

环境信息
#

  • 内核版本:Linux 6.7.9 #7 SMP PREEMPT_DYNAMIC
  • 启动参数:开启 KASLR,oops=panic panic_on_warn=1(触发任何内核 WARN 直接 Panic,容错率为零)
  • 权限限制:/challenge/mount_kbfs 权限被设为 0755(移除了 Level 2 的 SUID 位),但 /dev/loop* 权限为 0666。

模块 Diff(Level 2 vs Level 4)
#

对比 Level 2 与 Level 4 的 challenge.ko,实质性改动仅有三处:

位置 Level 2 Level 4 影响
kbfs_fs_type.fs_flags FS_REQUIRES_DEV (1) FS_REQUIRES_DEV | FS_USERNS_MOUNT (9) 允许在 User Namespace 中无特权挂载
kbfs_iget() 读取磁盘 dnode 的 uid/gid 使用挂载进程的 kbfs_info->uid/gid 封死了直接伪造磁盘 inode 属主提权的路子
kbfs_file_mmap() 校验映射范围与整数溢出 删去了边界检查与溢出检查 核心漏洞:物理内存越界映射与越界释放

漏洞成因
#

问题集中在 kbfs_file_mmap() 与 kbfs_vma_cleanup() 对内存分配粒度与映射粒度的处理不一致。

mmap 越界物理映射(OOB Read)
#

对照源码看 kbfs_file_mmap() 的逻辑:

int kbfs_file_mmap(struct file *filp, struct vm_area_struct *vma)
{
    ...
    size = vma->vm_end - vma->vm_start;              // 用户请求映射的 VMA 长度
    max_size = round_up(inode->i_size, PAGE_SIZE);   // 文件实际占用的页对齐长度
    
    // [!] Level 4 删去了以下两个检查:
    // if (vma->vm_pgoff * PAGE_SIZE + size > max_size) return -EINVAL;
    // if (vma->vm_pgoff * PAGE_SIZE + size < size)     return -EINVAL;

    kb_vma->vaddr = __get_free_pages(GFP_KERNEL | __GFP_ZERO, get_order(max_size));
    kb_vma->size = size;

    /* 将文件内容拷贝进新分配的物理页 */
    for (off = 0; off < max_size; off += PAGE_SIZE) {
        memcpy((void *)(kb_vma->vaddr + off), ...);
    }

    pfn = __pa((void *)kb_vma->vaddr) >> PAGE_SHIFT;
    remap_pfn_range(vma, vma->vm_start, pfn, size, vma->vm_page_prot);
    ...
}

这里的分裂非常直观:

  • 分配阶段按文件的 max_size 向 Buddy Allocator 申请物理页。如果镜像中文件 i_size = 16,则 max_size = 0x1000,get_order(max_size) = 0,内核仅分配 1 个物理页(4KB)。
  • 映射阶段 remap_pfn_range() 却按用户请求的 size 建立页表映射。若用户 mmap 长度为 64MB,内核会将该页起始的 连续 64MB 物理内存 直接映射进用户空间。

为什么是只读窗口?因为 kbfs_file_mmap() 强制要求 MAP_PRIVATE(传入 MAP_SHARED 直接返回 -EINVAL)。在私有映射下,即便传入 PROT_WRITE,vma->vm_page_prot 依然是只读的 PAGE_COPY。任何写操作都会触发 Page Fault 进入 wp_page_copy() 走写时复制(COW),修改的只是新分配的匿名页副本,原物理页纹丝不动。

munmap 越界释放(OOB Page Free)
#

当 VMA 解除映射且底层 struct file 引用计数归零时,调用链来到 kbfs_file_release() -> kbfs_vma_cleanup():

free_pages(kb_vma->vaddr, get_order(kb_vma->size)); // 按用户映射长度 size 释放!
kfree(kb_vma);

kb_vma->vaddr 实际只分配了 Order 0(1 页),但释放时传入了 get_order(kb_vma->size)。若 size = 2MB(Order 9,512 页),free_pages(vaddr, 9) 会将该页连同其物理地址后方紧邻的 511 个物理页 一并作为 Order 9 空闲块归还给 Buddy Allocator。

                  kbfs_file_mmap() & kbfs_vma_cleanup()

  Physical Pages: +--------+--------+--------+--------+ - - - +--------+
                  | Page 0 | Page 1 | Page 2 | Page 3 |       |Page 511|
                  +--------+--------+--------+--------+ - - - +--------+
                  ^        ^                                           ^
                  |        |                                           |
  Allocated:      +--------+                                           |
  (max_size=4KB)   Order 0 (1 Page from __get_free_pages)              |
                  |                                                    |
  Mapped & Freed: +----------------------------------------------------+
  (size=2MB)       Order 9 (512 Pages mapped via remap_pfn_range
                            and freed via free_pages(vaddr, 9))

这就给出了本题的两个核心原语:

  1. 64MB 物理内存只读窗口(映射 A):直接透视物理内存中的 task_struct、cred、kbfs_vma_struct 等内核对象。
  2. 2MB 连续物理页越界释放(映射 B):把后方 511 页强行送进 Buddy 空闲链表,构造 Page UAF。

核心机制与原语构造
#

既然没有直接写原语,唯一路径就是:通过越界释放将目标 cred 所在的 Slab 页释放回 Buddy -> 喷射内容可控的内核对象回收该物理页 -> 覆盖 cred 的 uid..fsgid 字段为 0。

要让这条链路在 panic_on_warn=1 下稳定跑通,必须解决以下三个内核机制层面的问题。

Direct Map 地址换算(免泄露突破 KASLR)
#

__get_free_pages() 返回的是 Direct Map 区(线性映射区)的内核虚拟地址,满足:

Virtual Address = Physical Address + page_offset_base

因此,在 64MB 的映射窗口 A 内,任何内核堆指针 V 指向的对象在 A 内的字节偏移 off,都满足线性平移关系:

V = V_A + off   <===>   V_A = V - off

其中 V_A 是窗口 A 起始物理页对应的 Direct Map 虚拟地址。只要我们在窗口 A 中定位到任意一个进程的 task_struct(读出指针 C = task->cred),再在窗口 A 中找到该 cred 结构体所在的实际偏移 coff,就能直接算出 V_A = C - coff。后续判断任何内核指针是否落入窗口 A 或某个映射 B 的范围内,只需做简单的减法即可,全程无需关心内核代码段 KASLR。

  Kernel Direct Map (Virtual)          64MB Read Window A (User mmap)
  +----------------------------+       +----------------------------+
  | V_A (Base Virtual Addr)    | <---> | Offset 0x000000            |
  |   ...                      |       |   ...                      |
  | V_B = V_A + X              | <---> | Offset X      (B[i] Page)  |
  |   ...                      |       |   ...                      |
  | C   = V_A + coff           | <---> | Offset coff   (Target cred)|
  +----------------------------+       +----------------------------+

Migratetype 隔离与 msg_msg 跨层回收
#

目标 cred 位于 cred_jar SLUB 缓存页,而映射 B 是通过 __get_free_pages(GFP_KERNEL, ...) 分配的。

当 free_pages(vaddr, 9) 释放 B 时,这 512 个物理页归入 Buddy Allocator 的 MIGRATE_UNMOVABLE 空闲链表(因为 GFP_KERNEL 不含 __GFP_MOVABLE)。如果尝试在用户态用匿名页(mmap + 写入)去堆喷回收,匿名页走的是 GFP_HIGHUSER_MOVABLE(属于 MIGRATE_MOVABLE),分配器会在各自的 migratetype 链表内拿页,完全碰不到 UNMOVABLE 链表里的页。

  Buddy Free Lists (Order 0..10)
  +-----------------------+-------------------------------------------------+
  | MIGRATE_UNMOVABLE     | <-- B block freed here (GFP_KERNEL)             |
  |                       | <-- Reclaimed by msg_msg (GFP_KERNEL_ACCOUNT)   |
  +-----------------------+-------------------------------------------------+
  | MIGRATE_MOVABLE       | <-- User-space anon page spray lands here (Miss)|
  +-----------------------+-------------------------------------------------+

因此,必须选用同样从 UNMOVABLE 拿页、且整页大小与内容可控的 msg_msg:

  • 调用 msgsnd 发送 4048 字节消息时,sizeof(struct msg_msg) + 4048 = 0x30 + 0xfd0 = 0x1000,正好申请一个 kmalloc-cg-4k 对象(底层直接对应单个 4KB 物理页),分配标志为 GFP_KERNEL_ACCOUNT(同属 UNMOVABLE)。
  • 页内偏移 0x00..0x2f 为 msg_msg 头部,0x30..0xfff 为完全可控的 mtext。

只要目标 cred 的页内偏移 (coff & 0xfff) >= 0x30,我们就可以先把整个 cred 页从窗口 A 原样拷贝出来,将目标 cred 的 uid..fsgid(共 32 字节)清零,再截取 [0x30, 0x1000) 塞进 mtext 进行喷射。这样当 msg_msg 落回该物理页时,目标 cred 原地变为 root,而同页上的其他兄弟 cred 对象保持原样,不会破坏相邻进程。

  Target Cred Page (4096 Bytes) Reclaimed by kmalloc-cg-4k msg_msg:
  +------------------+-----------------+------------------------+-----------+
  | 0x000 .. 0x02f   | 0x030 .. co-1   | co .. co+0x27          | .. 0xfff  |
  +------------------+-----------------+------------------------+-----------+
  | struct msg_msg   | Preserved bytes | Target struct cred     | Preserved |
  | header + marker  | (copied from A) | usage kept, uid..fsgid | bytes     |
  |                  |                 | zeroed (32 bytes = 0)  | (from A)  |
  +------------------+-----------------+------------------------+-----------+
                     |<---------------- mtext (4048 Bytes) ---------------->|

规避 kfree(kb_vma) 触发的 SLUB WARN
#

这是本题最容易踩的坑。再看一眼清理函数:

free_pages(kb_vma->vaddr, get_order(kb_vma->size));
kfree(kb_vma);

每次 mmap 时,驱动都会 kmalloc 一个 32 字节的管理结构 kbfs_vma_struct(位于 kmalloc-32 Slab 页),并挂入全局链表 kbfs_vma_root:

struct kbfs_vma_struct {
    u64 vaddr;              // 0x00: 分配的虚拟地址 V_B
    size_t size;            // 0x08: 映射大小 0x200000
    struct list_head list;  // 0x10: 链表指针 (next 指向 root + 0x10)
};

如果候选 B 释放的 [X, X + 2MB) 物理区间内,恰好包含了 B 自身的 kb_vma 所在的 kmalloc-32 Slab 页(或者 kbfs_vma_root 所在的页),那么第一行的 free_pages() 会把该页的 PG_slab 标志抹掉。紧接着第二行执行 kfree(kb_vma) 时,SLUB 检查发现该页不再是合法的 Slab 页,当场抛出 WARN 触发 Panic。

因此,在挑选用于释放的 B 之前,必须在窗口 A 中扫描特征对 {V_B, 0x200000} 找到其对应的 kb_vma,确保 kb_vma 自身以及 list.next - 0x10(即 root 对象)都不在 [X, X + 2MB) 区间内。

Exploit Chain
#

整体利用链分为六个阶段:

  [Stage 1: Mount]      unshare(USER|NS) + loop bind multi.img + mount kbfs
                                  |
  [Stage 2: Window A]   mmap 64MB Read-Only Window A (allocates 1 page)
                                  |
  [Stage 3: Interleave] 64 rounds: fork child (capset -> new cred)
                                 + parent mmaps 3 x 2MB B blocks (MADV_DONTFORK)
                                  |
  [Stage 4: Locate]     Scan A for B[i] markers ("L4MK"), children task_struct,
                        target cred page (coff), and compute V_A = C - coff
                                  |
  [Stage 5: Filter B]   Pick safe B[pick]: covers coff, small delta (coff - X),
                        excludes kb_vma / root / task_struct / mm / files pages
                                  |
  [Stage 6: Reclaim]    Fork sender -> Parent close+munmap B[pick] (Order-9 free)
                        -> Sender sprays 4KB msg_msg until A[coff+8] == 0
                        -> Wake target child to read /flag into 9p share

Stage 1: 镜像构造与 Userns 无特权挂载
#

  1. 构造特征镜像:创建包含单个文件 f 的镜像 multi.img,将 i_size 设为 16(保证 max_size = 0x1000,每次 mmap 仅分配 1 个物理页)。同时在镜像内填充 250 个数据块,第 i 块头部写入 16 字节特征标记 "L4MK" + u32(i) + "ZZZZZZZZ",偏移 16 字节之后全部填充 0xAA。
    • 区分技巧:当驱动建立映射 B[i](传入页偏移 pgoff = i)时,只会把第 i 块的前 16 字节拷进新分配的清零页(__GFP_ZERO),因此真实的 B[i] 物理页在偏移 16 处为 0x00;而镜像文件本身的 Page Cache 页在偏移 16 处为 0xAA。扫描时判断 q[16] == 0 即可精准识别 B[i] 的物理页。
  2. 命名空间挂载:通过 unshare(CLONE_NEWUSER | CLONE_NEWNS) 创建非特权用户与挂载命名空间,将容器内 UID/GID 0 映射至当前用户的 1000,绑定 /dev/loopN 后挂载 kbfs。

Stage 2: 建立 64MB 物理只读窗口 A
#

打开挂载目录下的 /tmp/l4mnt/f,调用 mmap 建立长度 AL = 64MB 的私有只读映射 A。此时驱动仅分配了 1 页,但我们将该页后方 64MB 的物理内存尽收眼底。

Stage 3: 交错分配布局物理内存
#

为了让目标 cred 页刚好落在某个 B[i] 页后方不远处(满足 X[i] < coff < X[i] + 2MB 且间距 delta 尽可能小),不能先集中 fork 再集中映射 B,否则两类物理页会分属两个相距甚远的物理簇。

这里采用 64 轮交错分配:

  • 子进程耗尽现有 Slab 并保持新 cred 页:每轮 fork 一个子进程。子进程先卸载从父进程继承的所有前置 B 映射,然后调用 capset() 写入唯一的 inheritable 值。capset() 会强制触发 prepare_creds() 分配新 cred,64 个子进程同时存活会迫使 cred_jar 向 Buddy 批发全新的 Slab 页。随后子进程通过管道向父进程上报自己的 PID,并在共享内存数组 gost[idx] 上自旋等待。
  • 父进程穿插建立 B 映射:每 fork 完一个子进程,父进程紧接着建立 3 个映射长度为 BL = 2MB 的 B[i](文件偏移设为 i * 4096 以加载对应块的 "L4MK" 标记),并立即对 B[i] 调用 madvise(..., MADV_DONTFORK) 防止后续 fork 的子进程继承该 VMA 引用。

Stage 4: 扫描窗口 A 建立全局坐标
#

交错分配完成后,父进程在 64MB 窗口 A 内执行三趟扫描(相关结构体偏移基于 Linux 6.7.9 vmlinux):

  1. 定位所有 B[i] 的偏移 X[i]:按 4KB 页边界扫描 "L4MK" 签名且偏移 16 字节处为 0x00 的页面,读出编号 i 并记录 X[i] = offset。
  2. 收集所有候选 kb_vma:按 8 字节步长扫描满足 {页对齐内核指针 v, 0x200000} 的二元组,保存其虚拟地址 v 和在 A 中的偏移 off。
  3. 定位子进程 task_struct 与 cred 页:
    • 父进程在初始化时已通过 prctl(PR_SET_NAME) 将进程名设为独特的 "l4fc-<pid>"(子进程会自动继承)。
    • 在 A 中搜索该字符串(对应 task_struct + 0x710 的 comm 字段),反推 task_struct 起始偏移 t,校验 +0x528 (pid) 与 +0x52c (tgid) 是否匹配子进程 PID,并读取 +0x700 处的 task->cred 指针 C。
    • 提取页内偏移 co = C & 0xfff(要求 co >= 0x30 以避开 msg_msg 头)。遍历 A 中每个物理页的 +co 处,检查是否符合 struct cred 特征(0 < usage <= 100000、uid..fsgid 8 个整型字段均为宿主机映射值 1000 或 0、+0x90 处的 user_ns 为内核指针)。
    • 匹配成功后即得到目标 cred 在 A 中的总偏移 coff = page_offset + co,进而直接算出窗口基址的内核虚拟地址:V_A = C - coff。

Stage 5: 筛选安全且距离最近的 B[pick]
#

遍历所有已定位的子进程目标与映射 B[j],挑出满足以下全部安全约束且 delta = coff - X[j] 最小的组合:

  1. 覆盖检查:满足 X[j] < coff && coff < X[j] + 2MB。
  2. 排除 kb_vma 与 root 自毁:计算 B[j] 对应的内核虚拟地址 V_B = V_A + X[j],在候选 kb_vma 列表中查找 v == V_B 的项。若找到,检查该 kb_vma 自身偏移 o 以及链表头 R = list.next - 0x10 换算后的偏移 R - V_A 是否落在 [X[j], X[j] + 2MB) 内;若落入则直接排除,避免 kfree(kb_vma) 触发 SLUB WARN。
  3. 关键进程结构体保护:当 delta > 0x8000 时,回收过程会覆盖较多前置页。此时额外检查目标子进程、父进程以及后续 sender 进程的 task_struct 所在页,及其核心指针(mm +0x4a8、fs +0x740、files +0x748、nsproxy +0x758、signal +0x760、sighand +0x768)指向的页是否落在 [X[j], X[j] + 2MB) 内,若有重合则跳过。

Stage 6: 触发越界释放与 msg_msg 原地提权
#

  1. 构造伪造页数据:将目标 cred 所在的 4KB 页 A + (coff & ~0xfff) 完整拷贝到本地缓冲区 page,仅将 page + co + 8 起始的 32 字节(即 uid 到 fsgid 共 8 个字段)清零,再将 page + 0x30 起始的 4048 字节拷入消息正文缓冲区 mt。
  2. 隔离 sender 进程:在触发释放前,先 fork 出专门负责发消息的 sender 子进程(并在 Stage 5 中同样将其纳入保护检查)。
    • 设计考量:一方面,在 2MB 物理内存被释放并覆写的窗口期内,把所有系统调用集中在独立的 sender 进程中、让父进程退化为纯用户态共享内存轮询,能最大限度降低踩中损坏页崩溃的概率;另一方面,msgsnd() 内部会执行 ipcperms() -> in_group_p() 读取调用者自身的 cred->group_info,因此绝不能由正在被改写 cred 的目标进程自己调用 msgsnd。
  3. 触发 Order-9 越界释放:父进程执行 close(fds[pick]) + munmap(B[pick], 2MB)。此时底层 struct file 引用归零,触发 kbfs_file_release() 将 B[pick] 起始的 512 个物理页(包含目标 cred 页)释放回 Buddy 的 UNMOVABLE 链表。
  4. 实时观测喷射与唤醒读 Flag:父进程通过管道放行 sender。sender 循环调用 msgget + msgsnd 发送 4048 字节的消息。每发送一条,就通过依然存活的只读窗口 A 直接检查 *(volatile uint32_t *)(A + coff + 8) == 0。一旦观测到 uid 变为 0(实测 delta 为 0x10c0 时约发送 500 余条即命中),立即停止喷射并通过共享内存置位 gost[tidx] = 1 唤醒目标子进程。目标子进程醒来后已是全局 root 权限,直接打开 /flag 并将内容写入 9p 共享目录文件。

踩坑与实战细节记录
#

  1. close(fd) 与 MADV_DONTFORK 的引用计数问题 kbfs_file_release() 只有在 struct file 的引用计数彻底归零时才会执行。由于每次 mmap 创建的 VMA 也会持有一份 file 引用,单纯 close(fd) 不会触发释放,必须配合 munmap()。同理,如果在交错 fork 阶段子进程继承了父进程 B 的 VMA,子进程不退出就会一直占着引用计数,导致父进程 munmap 时无事发生。因此父进程设置 MADV_DONTFORK + 子进程主动清理前置 fd/VMA 缺一不可。
  2. 为什么不直接替换 task->cred = &init_cred? 早期尝试过释放 task_struct 所在页,通过 sched_class 泄露 KASLR slide 后将 task->cred 指向 init_cred。这有两个问题:一是 task_struct 位于大块 Slab 中,同页往往住着 sshd 等关键系统进程的 task_struct,释放极易在 mm_cid_get() 或 acct_collect() 处引发连带 Panic;二是进程处于 User Namespace 中,直接换用 init_cred 会导致安全上下文与命名空间不匹配,只原地清零自身 cred 的 uid..fsgid 最干净稳妥。
  3. Order 大小的选择 vaddr 是按 Order 0 分配的物理页,物理地址几乎不可能自然对齐到 2MB 边界。向 free_pages() 传入未对齐地址和高阶 Order 时,Buddy 在合并伙伴块(__free_one_page)时阶数越高越容易触发内部校验崩溃。实测 Order 9(2MB)既能提供足够的覆盖半径,又不会在释放瞬间炸死在伙伴合并逻辑里。
  4. SSH 管道损坏与 9p 文件落盘 在测试过程中曾遇到过 “uid 已经变成 0,但终端没有任何输出且随后 Panic” 的情况。排查发现是 msg_msg 喷射过程中顺带覆盖了 sshd 用于转发 stdout 的 pipe_inode_info 页,子进程退出关闭管道时触发 free_pipe_info() WARN。解决方案是让子进程读出 /flag 后,第一时间写入宿主与虚拟机共享的 9p 目录(/home/hacker/kylebotfs/level4/flag.out),随后直接调用 pause() 挂起永不退出,彻底绕开后续清理路径。
BeaCox
Author
BeaCox
Stay humble, remain critical.
Kernel Pwn 笔记 - This article is part of a series.
Part 4: This Article

Related

从 Cache of Castaways 学 cross-cache
·6058 words·13 mins
CTF PWN Kernel SLUB Source Code
Kernel 提权路径总结
·6198 words·13 mins
CTF PWN Kernel ROP Paper
从 RWCTF2022 Digging into kernel 1 & 2 学内核提权方法
·2984 words·6 mins
CTF PWN Kernel Source Code ROP UAF