Search This Blog

Sunday, October 10, 2021

pbctf 2021 Nightclub Writeup: More Fun with Linux Kernel Heap Notes!

This weekend, I played with DiceGang in pbctf 2021 and we ended up placing 1st. There were so many cool (and absolutely insane) challenges, but school was really busy so I was unable to play as much as I liked. However, I still worked on some challenges and solved the kernel pwnable NightClub by IKEA with noopnoop, and thought it would be nice to make a writeup about it. Feel free to let me know if anything is unclear or incorrect.

In some of our initial analysis, we notice that SMAP, SMEP, and KASLR are turned on, and we are on version 5.14.1. In terms of important kernel hardening options, the SLUB allocator is used without randomization or hardening, and usercopy is not hardened; userfaultfd was disabled as well (but didn't really matter as unprivileged userfaultfd has been disabled by default since 5.11). The nightclub driver itself was a generic character device, and can be interfaced with via ioctls. 

In this driver, there are two main structs: a user request struct and the note struct itself. The note struct in kernel land itself was 0x80 in size, placing it under the kmalloc-128 slab, and a lot of useless padding was placed throughout just to make it more difficult for players to exploit.

Four functions are also provided. If you make an ioctl request with 0xcafeb001, an allocation is made. You specify the size of data to copy in (which is capped up to 0x20), and an offset and 0x10 bytes of “special” data to copy in (both of which is never used again for some reason). The kernel also generates 4 random bytes for the key to store in the note, and returns it to the user. After setting up the struct, it is linked into a doubly linked list with the head located in the module data section with the symbol “master_list." When copying data over, alloc sets a null byte at note->data[size], leading to a trivial null byte poisoning bug.

An ioctl request with 0xcafeb002 triggers a deletion. The user specifies a key to delete a specified note from the list. The driver unlinks it and sets the pointers of the note itself to poison constants before freeing. Edit allows you to specify a key, data, size, and a offset (which is capped at 0x10). This leads to a trivial heap overflow and also has a poison null byte bug afterwards.

Note that in all the functions above, the note pointers during linked list traversal or kmalloc return are checked to be in range of a few constants. Noopnoop and I didn't dig too deeply and aren't completely sure, but just assumed those acted as a pseudo "address sanitizer."

Lastly, 0xcafeb004 is a “leak” function. It just leaks the difference between the addresses of edit_ioctl in the module and __kmalloc from the kernel text itself. While information from this paper and page about Linux memory mappings show that kernel text and modules have known regions (and the first only has 9 bits of entropy with KASLR and the latter only has 10 bits), the difference still doesn't provide us with enough information to avoid bruteforcing, which isn't feasible with remote POW.

Without SLAB randomization or hardening, this challenge should be pretty trivial with the overflow right?

Sadly, the answer was no. Not only was there no easy way to achieve leaks in this driver, but in recent Linux kernel versions, the freelist pointer in SLUB chunks have been moved into the middle of chunks (so a 0x10 byte overflow cannot reach it in kmalloc-128 chunks). In our case, it is at offset 0x40 upon freeing. We could also attempt to attack via msg_msg structure, but arb write would be quite difficult without userfaultfd, and we cannot hit the size and next fields (located at offset 0x18 and 0x20). There is also lack of well documented structs for exploitation in kmalloc-128. subprocess_info used to be in this slab, but has since moved to kmalloc-96; I went through both of these papers (paper 1, paper 2) and was not able to find a useful struct given our overflow limits.

Our first breakthrough was noopnoop's heap massage. He massaged the heap in the following manner to achieve a UAF chunk in the linked list.

First, we want to create a linked list chain of consecutive chunks in the following order (only the arrow in the direction of traversal is showed for simplicity's purposes, but keep in mind that it is a doubly linked list).

Then we poison null byte the next pointer of the second chunk, causing the next pointer to now point to chunk III.

Upon freeing the third, we will have UAF capabiltiies.

Even with this massage, there is not much you can do due to the nature of where we can write. Double freeing would impose new issues because of the poison pointers set from unlinking (we do not have leaks to overwrite them with valid pointers). A brief idea we had was to replace the UAF chunk with a msg_msg object, and link it into another slab; then by adding more msg_msg objects of different sizes, we can have the driver's linked list traversal end up in another slab. However, this was unfeasible at this point because we cannot apply this to overwrite other chunks in larger slabs, so the only option was to target kmalloc-64 with this technique (we did not know that kmalloc-96 existed at this point), but the writeable offsets combined with the chunk alignment made it difficult to find a structure to target properly.

Our second breakthrough came once I realized that kmalloc-96 existed (we somehow both thought it didn't exist); I guess the moral of the story here is to always check /proc/slabinfo. With this knowledge, we managed to solve pretty quickly via the following methodology.

First ,we performed the same heap massage mechanism to achieve a freed chunk in a linked list. Then, we replaced that chunk with a msg_msg object in the kmalloc-128 slab, and added two more msg_msg objects in the kmalloc-96 slab. This way, you get msg_msg objects (along with the msg_queue in kmalloc-64) linked into your list.

Since we control the contents of the msg_msg object over where the key is stored in the note structs, we can easily lead the driver to find the location we want. Due to the misaligned nature between 0x60 sized objects and 0x80 objects, we can perfectly align the OOB writes with offsets to change the size field of the second msg_msg object in kmalloc-96 as I discussed in my last post. Now, we can allocate a subprocess_info struct by triggering call_usermodehelper_setup from module autoloading, which as many previous research posts have been discussed, can just be triggered with socket(22, AF_INET, 0);

Take note that the offset write is really important. If you just do a generic overflow, you will end up corrupting msg_msg pointers. This kernel did not have the checkpoint_restore option compiled in, so MSG_COPY will not work, and any attempt to unlink without known kernel addresses will lead to a panic. Now, if we message receive from our queue, we can achieve kernel leaks!

Now, with a leak, the most direct approach is to achieve a UAF overlap as done previously, create a double free, and freelist poison to modprobe_path to bypass SMAP. I sprayed a lot more chunks to help me reach a cleaner region, and then redid the same unlinking approach. However, after freeing chunk 3, I used chunk 2 to overflow into chunk 3 and fix its pointers to go back to the master_list. This way, upon the next free, we will not have a kernel panic. The address for master_list can be trivially calculated after a kernel leak combined with the 4th ioctl option.

We now freed another chunk in kmalloc-128 (I saved a msg_msg object in another msg_queue for this), then freed the UAF'd note in our linked list again. With the LIFO behavior of the kernel allocator, I allocated a new msg_msg object, overwrote the freelist pointer with modprobe_path, allocated 2 more messages, and finally allocated a new msg_msg object to then overwrite modprobe. Any attempt to execute an invalid executable will trigger the kernel execution of the program specified in modprobe_path, effectively giving us RCE with root privileges.

Here's the final exploit with comments:

And here's our success on remote!

Afterwards, I talked to the author and another player (pql) and discovered that noopnoop and I completely unintended this challenge. We just assumed that a limited OOB combined with a poison null byte was enough. The sections of the code that we brushed aside (and labeled a pseudo ASAN) actually provided a primitive to bruteforce and probe for both kernel and module base via partial overwrites on a given chunk's prev address.

Overall, I had a lot of fun with pbctf and working with noopnoop and the rest of DiceGang. I am definitely looking forwards to pbctf 2022, and hope I have more time next year to play even more challenges!