Heya infosec folks, in this write-up we will cover the Knote (kernel-note) kernel-pwn challenge on HackTheBox. We can trigger a local privilege escalation attack by exploiting a use-after-free bug. The description of the challenge is as follows:
Secure your secrets in the kernel space!
What are kernel modules?
How does this kernel CTF work?
Analyzing the kmodule
Creating an exploit
Creating a real world version
What are kernel modules?
Linux kernel modules are a way to extend the Linux kernel in a hotswappable way. Kernel modules are also used for creating drivers, which is why it's useful to learn how to exploit them. Thankfully, you can use the same pwn / exploitation techniques in kernel modules as in the core Linux kernel.
Kernel modules (kmodules) can do a lot of things that the core kernel can do as well: manage a virtual filesystem such as /proc, manage task structs, et cetera. They can register a device file as well, which you can use to communicate with the kmodule using read(), write(), ioctl(), et cetera.
You can insert, list, and remove kernel modules by respectively using the binaries insmod, lsmod, and rmmod.
How do kernel pwn CTFs work?
The goal of most kernel pwn CTFs are local privilege escalation exploits, in which a user becomes root in order to read a root-only flag file. Typically, you will be given 3 files:
qemu.sh: a BASH script to run a QEMU command. QEMU (Quick EMUlator) is a FOSS instructionset simulator which you can use to run custom Linux kernels in custom filesystems. It may sound like a VM, but it is not.
initramfs.cpio.gz / rootfs.img: the custom (compressed) filesystem to run QEMU with.
bzImage : the custom Linux kernel to run QEMU with.
Make sure to remove -no-kvm from qemu.sh as it is for old versions of Qemu. Also note that there's no kaslr, no smap, no smep, etc.
Now you might see that I use initramfs.cpio.gz instead of the initramfs.cpio.gz which is supplied in the challenge. This is because I first extracted it using cpio -iF rootfs.img. After that, I used the following scripts to compress and decompress the resulting directory:
So firstly I create an initramfs.cpio.gz for QEMU using irfs_compress.sh initramfs/exploit.c && ./qemu-cmd.sh. Now, we can test QEMU by running ./qemu.sh:
Analyzing the kmodule
We are given the following C sourcecode of the knote.ko kernel module:
The first thing that the kernel calls in a newly inserted module (c.q. knote.ko) is the function with keyword __init, which in this case belongs to the following init functions:
As we can see, it registers a character device (chrdev) with the name "knote" and it enables the device operation unlocked_iotctl, which means that it's possible to interact with the device using ioctl().
This means that our only userland form of messing with the kmodule is using ioctl() to interact with the knote_ioctl function. As said, we need to use int ioctl(int fd, unsigned long request, ...) in the exploit to pass the file, cmd and arg arguments to long knote_ioctl(struct file *file, unsigned int cmd, unsigned long arg) . This function performs several commands: KNOTE_CREATE, KNOTE_DELETE, KNOTE_READ, KNOTE_ENCRYPT and KNOTE_DECRYPT.
As we can read, the arg parameter is used to supply values to a knote_user object, using copy_from_user(&ku, arg, sizeof(struct knote_user)): copies sizeof(struct knote_user) bytes from userland pointer arg to kernel pointer ko. Secondly, it executes one of the KNOTE_<CMD> cases.
The first step of exploit development is identifying protections: earlier we found out that there's no active kernel protections (no kaslr, no smap, no smep, et cetera). Next, there's finding exploit primitives: let's start off with finding execution flow hijacking. Firstly I checked for any forms of buffer overflow bugs on the stack and on the heap, but I couldn't find anything. However, once I took a look at KNOTE_CREATE, I saw that a use-after-free bug can be triggered.
Finding a memory corruption bug
The KNOTE_CREATE command allocates a knote and it's data using kmalloc, which stands for kernel malloc. Then, it tries to copy the userland data to the kernel note data. However if that copy fails, it will kfree (kernel free) both the kernel note and the kernel notes' data.
Before we dive into the details, please realize that the kernel heap cache works like a stack containing heap chunk pointers: you push them with kfree and pop them with kmalloc
It took me a bit of puzzling but I figured out that we can leverage this to trigger a use-after-free (UAF) bug. If we create a knote that fails copy_from_user by providing an invalid pointer, the kmodule will kfree(data) and after that it will kfree(knote) but it wont reset knotes[ku.idx] = NULL. Additionally, the allocation is in the wrong order of kmalloc(data) and then kmalloc(knote). Because of this, a weird UAF scenario arises in the kernel memory cache where we can overwrite knotes[ku.idx] with userland ku.data. For clarification of this mindboggling bug I have made the following diagram:
Finding a way to hijack execution flow
Now we have a UAF bug, we need to find ways to get code execution by utilizing it. After analyzing more commands, I noted that the KNOTE_ENCRYPT command calls knote->encrypt_func, stored in the knote structure.
This means that we can execute arbitrary code by committing a UAF bug, overwrite knote->encrypt_func and calling it.
Creating an exploit
So now we have our primitives to get local code execution through a UAF bug in the kernel module, we can start building the exploit. Firstly, I defined a bunch of kernel module specific code, such as the structures and ioctl() calls to interact with the kmodule. These structures are copy/pasted from the knote.c file.
After I got all necessary kernel module code, I created the UAF code. Please ignore the set_ctx_reg() and &privesc_ctx_swp variables. As you can see, we're firstly triggering the swap by allocating a knote with an invalid data pointer so that kn->data becomes kn. Then, we're allocating our custom kn by passing it as kn->data. Please note that I'm using knote index 1, and not 0 to prevent encrypt_func from being overwritten in the following code of knote.c:
Then, we're triggering the function call to encrypt_func by using KNOTE_ENCRYPT:
Now, coming back to set_ctx_reg() and privesc_ctx_swp(). When we commit the code execution attack in the kmodule, we are in kernel space whilst we want to run a shell as root in userland. In order to get our beloved shell, we need to perform a context swap from kernel to userland. Such context swaps happen with every system call being made in the kernel so it's very important. In order to keep this write-up relatively short, you can read more about context swapping in the context swapping blogpost by geekculture.
Since these functions are very standard and used in most kernel pwn challenges I made it a header file:
Basically in a nutshell, the context swap requires the registers ss, rsp, rflags and csfrom userland, since they are mission critical for returning to userland context. We store those registers in the set_ctx_reg() function:
After we set them, we can use our own privesc and context swap function which also sets the new userland execution pointer. Keep in mind that the following code snippet uses global variables in the assembly. The code starts off by calling prepare_kernel_cred(0) (which prepares the credentials to be set to UID 0 and GID 0) and then calls commit_creds(creds) to set the process credentials indefinitely. At last, it prepares the context swap registers and performs the context swap.
This sets the new RIP to spawn_shell, which contains our userland code to spawn a shell:
In our exploit we prepared the userland context registers, made a fake UAF knote object that would trigger privesc_ctx_swp, and set the addresses for the kernel functions prepare_kernel_cred and commit_creds.
Then, I tested the exploit locally by compiling it using compress.sh (given earlier in this post):
If you want to try the exploit yourself, here's the complete source code for exploit.c:
Now it's time to perform the exploit on the remote machine. I wisely chose musl-gcc as the compiler in compress.sh since it decreases the size of static builds A LOT. The static binary sizes from gcc and musl-gcc are respectfully 800000 bytes and 34000 bytes. In order to transfer the exploit to the remote machine, I used encode.sh to encode the exploit binary, copy it to clipboard and decoded it using BASH utilities on the remote machine:
I really hope you enjoyed the challenge and write-up as much as I did. Please let me know on Twitter if you want me to make a write-up about exploiting this CTF with real kernel primitives like seq_operations and setxattr.