免责声明:这个系列鼓励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))
这就给出了本题的两个核心原语:
- 64MB 物理内存只读窗口(映射 A):直接透视物理内存中的
task_struct、cred、kbfs_vma_struct等内核对象。 - 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 无特权挂载 #
- 构造特征镜像:创建包含单个文件
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]的物理页。
- 区分技巧:当驱动建立映射
- 命名空间挂载:通过
unshare(CLONE_NEWUSER | CLONE_NEWNS)创建非特权用户与挂载命名空间,将容器内 UID/GID0映射至当前用户的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):
- 定位所有
B[i]的偏移X[i]:按 4KB 页边界扫描"L4MK"签名且偏移 16 字节处为0x00的页面,读出编号i并记录X[i] = offset。 - 收集所有候选
kb_vma:按 8 字节步长扫描满足{页对齐内核指针 v, 0x200000}的二元组,保存其虚拟地址v和在A中的偏移off。 - 定位子进程
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..fsgid8 个整型字段均为宿主机映射值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] 最小的组合:
- 覆盖检查:满足
X[j] < coff && coff < X[j] + 2MB。 - 排除
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)触发 SLUBWARN。 - 关键进程结构体保护:当
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 原地提权
#
- 构造伪造页数据:将目标
cred所在的 4KB 页A + (coff & ~0xfff)完整拷贝到本地缓冲区page,仅将page + co + 8起始的 32 字节(即uid到fsgid共 8 个字段)清零,再将page + 0x30起始的 4048 字节拷入消息正文缓冲区mt。 - 隔离
sender进程:在触发释放前,先fork出专门负责发消息的sender子进程(并在Stage 5中同样将其纳入保护检查)。- 设计考量:一方面,在 2MB 物理内存被释放并覆写的窗口期内,把所有系统调用集中在独立的
sender进程中、让父进程退化为纯用户态共享内存轮询,能最大限度降低踩中损坏页崩溃的概率;另一方面,msgsnd()内部会执行ipcperms() -> in_group_p()读取调用者自身的cred->group_info,因此绝不能由正在被改写cred的目标进程自己调用msgsnd。
- 设计考量:一方面,在 2MB 物理内存被释放并覆写的窗口期内,把所有系统调用集中在独立的
- 触发 Order-9 越界释放:父进程执行
close(fds[pick])+munmap(B[pick], 2MB)。此时底层struct file引用归零,触发kbfs_file_release()将B[pick]起始的 512 个物理页(包含目标cred页)释放回 Buddy 的UNMOVABLE链表。 - 实时观测喷射与唤醒读 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 共享目录文件。
踩坑与实战细节记录 #
close(fd)与MADV_DONTFORK的引用计数问题kbfs_file_release()只有在struct file的引用计数彻底归零时才会执行。由于每次mmap创建的 VMA 也会持有一份file引用,单纯close(fd)不会触发释放,必须配合munmap()。同理,如果在交错fork阶段子进程继承了父进程B的 VMA,子进程不退出就会一直占着引用计数,导致父进程munmap时无事发生。因此父进程设置MADV_DONTFORK+ 子进程主动清理前置fd/VMA缺一不可。- 为什么不直接替换
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最干净稳妥。 - Order 大小的选择
vaddr是按 Order 0 分配的物理页,物理地址几乎不可能自然对齐到 2MB 边界。向free_pages()传入未对齐地址和高阶 Order 时,Buddy 在合并伙伴块(__free_one_page)时阶数越高越容易触发内部校验崩溃。实测 Order 9(2MB)既能提供足够的覆盖半径,又不会在释放瞬间炸死在伙伴合并逻辑里。 - 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()挂起永不退出,彻底绕开后续清理路径。