Post

Kernel Crafting: Building, Running, and Debugging Your Custom Linux Kernel with Busybox and QEMU

Kernel Crafting: Building, Running, and Debugging Your Custom Linux Kernel with Busybox and QEMU

In this step-by-step tutorial, we’ll walk through the entire process of building a Linux kernel, creating a minimal filesystem using Busybox, running it on QEMU, and debugging the kernel. Finally, we’ll wrap up by learning how to compile and add custom Linux kernel modules to enhance our kernel. I’m using a Linux system for this demonstration, specifically Ubuntu 22.04.5 LTS x86_64 with kernel version 6.8.0-60-generic. However, the steps should be similar for other Linux distributions. Let’s dive in!

Before we begin, ensure you have all the necessary tools and libraries installed on your system. This includes development tools, compilers, and libraries essential for building the kernel.

1
2
sudo apt update
sudo apt install build-essential libncurses5-dev bison flex libssl-dev libelf-dev qemu qemu-kvm 

Downloading the Linux Kernel

First, let’s download the Linux kernel source code. We’ll choose version 5.11.4 for this example:

1
2
3
wget https://www.kernel.org/pub/linux/kernel/v5.x/linux-5.11.4.tar.xz
tar xvf linux-5.11.4.tar.xz
cd linux-5.11.4/

Configuring and Compiling the Kernel

Next, we’ll configure the kernel. For simplicity, we’ll use the default configuration:

1
make defconfig

When configuring the Linux kernel, you might want to use the configuration file specific to your current Linux distribution. This can help ensure that the kernel configuration matches the settings and modules already in use on your system. To do this, you can copy one of the existing configuration files from /boot/config-$(uname -r) in the Linux kernel source root directory and name it .config.

Or else you can do -

1
make defconfig

The following command provides a text-based menu interface that allows us to configure various kernel options, including enabling or disabling features, selecting specific device drivers, and more.

1
make menuconfig

Before we compile the kernel, we need to enable some options for debug symbols, KASLR, and other useful features.

Kernel hacking ->

  • Kernel debugging
    CONFIG_DEBUG_KERNEL

  • Compile the kernel with debug info
    CONFIG_DEBUG_INFO

  • Generate DWARF version 4 debugging information
    CONFIG_DEBUG_INFO_DWARF4

  • Enable GDB scripts (if available)
    CONFIG_GDB_SCRIPTS

  • Debug slab memory allocations
    CONFIG_SLUB_DEBUG or CONFIG_DEBUG_SLAB

  • Export All Kernel Symbols
    CONFIG_KALLSYMS or CONFIG_KALLSYMS_ALL

Check the .config file in a text editor and ensure these options are set:

1
2
3
4
5
6
CONFIG_DEBUG_KERNEL=y
CONFIG_DEBUG_INFO=y
CONFIG_GDB_SCRIPTS=y
CONFIG_SLUB_DEBUG_ON=y
CONFIG_KALLSYMS=y
CONFIG_KALLSYMS_ALL=y

Now, let’s compile the kernel. This process may take some time:

1
make -j$(nproc)

This will utilize all available CPU cores for faster compilation. On my system, it took roughly 3 minutes.

After a successful build, you should see the following output files:

  • arch/x86/boot/bzImage → The compressed bootable kernel image.
  • vmlinux → The uncompressed ELF image with full debug symbols. This is the file you’ll use with GDB.

Creating a Minimal Filesystem with BusyBox

To boot a custom kernel with QEMU or use it for debugging, you’ll often need a minimal root filesystem. The easiest way to build one is by using BusyBox, a lightweight collection of Unix utilities in a single binary.

Step 1: Download and Build BusyBox

Start by downloading and building BusyBox:

1
2
3
4
wget https://busybox.net/downloads/busybox-1.36.1.tar.bz2
tar xvf busybox-1.36.1.tar.bz2
cd busybox-1.36.1/
make defconfig  # Use the default configuration

Now configure it for static linking (important for minimal rootfs without dynamic libraries):

1
make menuconfig

Set the following (Under Settings):

  • [*] Build BusyBox as a static binary (no shared libs)
  • You can leave everything else as default.

After selecting the "Build static binary (no shared libs)" option in the make menuconfig interface, exit the menu by selecting “Exit” or pressing ‘Esc’ repeatedly until prompted to save changes. Then, proceed to build the Busybox filesystem:

1
2
make -j$(nproc) # Ignore "Trying libraries: crypt m resolv" error
make install

This will install BusyBox into the _install/ directory.

1
2
3
4
5
6
$ tree -d _install/
├── bin
├── sbin
└── usr
    ├── bin
    └── sbin
1
2
$ file busybox
busybox: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=4a456612187a08793907e7565e1a41736f9adb43, for GNU/Linux 3.2.0, stripped

You now have a statically linked BusyBox binary — clean, minimal, and ready for use in your root filesystem (e.g. for QEMU, initramfs, or kernel debugging).

Step 2: Add init Script

BusyBox looks for /init or /sbin/init as the first process (PID 1). Create a basic init script:

1
2
cd _install
nano init
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
#!/bin/sh

# Create mount points 
mkdir -p /proc /sys /dev

mount -t devtmpfs none /dev
mount -t proc none /proc
mount -t sysfs none /sys

# clear the screen
clear

# Banner
echo " __________"
echo "< nyxFault >"
echo " ----------"
echo "        \   ^__^"
echo "         \  (oo)\_______"
echo "            (__)\       )\/\\"
echo "                ||----w |"
echo "                ||     ||"
echo ""

# Display boot time
echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"

# Welcome message
echo -e "\n[+] Welcome to Minimal BusyBox Rootfs"
echo "H4ppy K3rnel H4cking!"

# Start the shell
setsid  cttyhack sh
exec /bin/sh

Make it executable:

1
chmod +x init

We’ve completed the setup for our custom Linux system using Busybox and a custom initialization script (init). Let’s summarize the steps we’ve taken:

  • Busybox Compilation: We compiled Busybox, which provides a single executable capable of providing various Linux utilities such as sh, echo, vi, and more.

  • Filesystem Creation: After compiling Busybox, we used make install to create a filesystem hierarchy (_install directory) containing these utilities as links to the Busybox executable. This filesystem structure resembles a basic Linux filesystem.

  • Custom Initialization Script: We created a shell script named init. This script will be executed after the kernel loads during the boot process.

  • Mounting Essential Directories: In the init script, we mounted essential special directories such as /dev, /proc, and /sys. These directories provide access to kernel information and system devices.

Step 3: Create the initramfs

To create the filesystem (initramfs) containing our custom Linux system, we’ll run the following commands inside the _install directory:

1
2
3
$ find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../initramfs.cpio.gz
$ file ../initramfs.cpio.gz
initramfs.cpio.gz: gzip compressed data

initramfs

The initramfs (initial RAM filesystem) contains the files needed for the Linux kernel to mount the root filesystem and start the system. It’s used during the early stages of the boot process.

After running the command, the initramfs.cpio.gz file will be created in the parent directory. This file contains the entire filesystem structure that we created using Busybox and the init script.

We’re now ready to boot our custom Linux system using QEMU or another virtualization platform.

Booting the Kernel with QEMU and Initramfs

Let’s proceed to boot our custom kernel with the minimal filesystem using QEMU:

1
$ qemu-system-x86_64 -kernel ../linux-5.11.4/arch/x86/boot/bzImage -initrd initramfs.cpio.gz -append "root=/dev/ram rw console=ttyS0 quiet" -nographic
1
2
3
4
5
6
7
-kernel: Path to your custom kernel image (bzImage).
-initrd: Path to the initramfs.cpio.gz file.
-append: Specifies kernel command-line parameters. Here, we specify:
    root=/dev/ram: Tells the kernel to use the RAM disk as the root filesystem.
    rw: Mount the root filesystem as read-write.
    console=ttyS0: Redirect kernel console output to the first serial port (ttyS0).
The -nographic option ensures that the output is displayed in the terminal.

If you don’t use the -nographic option, QEMU will open a graphical window to display the boot process of the kernel. We will use terminal to display as I have faced problems while debugging on QEMU Graphical Window.

Debugging the Kernel with GDB

To enable debugging, we need to run QEMU with the -s option to enable debug mode. We’ll also add the -S option to freeze the CPU at startup:

1
$ qemu-system-x86_64 -kernel ../linux-5.11.4/arch/x86/boot/bzImage -initrd initramfs.cpio.gz -append "root=/dev/ram rw console=ttyS0 quiet nokaslr" -nographic -s -S

GDB

In another terminal, start GDB:

1
2
3
4
5
gdb \
    -ex "add-auto-load-safe-path $(pwd)" \
    -ex "file vmlinux" \
    -ex 'target remote localhost:1234' \
    -ex 'continue' 

Now you can set breakpoints, inspect memory, and step through code in GDB.

To stop the execution press Ctrl+C in the gdb window.

If you don’t see vmlinux-gdb.py. Make sure you haveCONFIG_GDB_SCRIPTS=y in .config.

You can try make scripts_gdb command.

To add the vmlinux-gdb.py -

1
(gdb) source vmlinux-gdb.py

Type lx- and hit TAB. You will see following options -

1
2
3
4
5
6
(gdb) lx-
lx-clk-summary
lx-cpus
lx-lsmod 
lx-ps
#...
1
2
(gdb) lx-cmdline 
root=/dev/ram rw console=ttyS0 quiet nokaslr

Let’s try to print some kernel symbols addresses -

1
2
3
4
5
pwndbg> p prepare_kernel_cred
$1 = {struct cred *(struct task_struct *)} 0xffffffff8108a4a0 <prepare_kernel_cred>
pwndbg> p commit_creds
$2 = {int (struct cred *)} 0xffffffff8108a270 <commit_creds>

Let’s verify it in QEMU.

1
2
3
4
~ # cat /proc/kallsyms  | grep -w prepare_kernel_cred
ffffffff8108a4a0 T prepare_kernel_cred
~ # cat /proc/kallsyms  | grep -w commit_creds
ffffffff8108a270 T commit_creds

Till now we’ve successfully printed the addresses of prepare_kernel_cred and commit_creds and verified them in the /proc/kallsyms file.

Now, we will explore how to establish breakpoints at kernel functions and trigger them by initiating system calls. Here are several prevalent kernel functions where breakpoints can be set for effective debugging during development:

  • start_kernel: This function is the entry point of the Linux kernel.
  • do_sys_open: This function is responsible for handling the open() system call.
  • sys_read: The sys_read system call is used by user-space programs to read data from a file descriptor (fd) into a buffer (buffer) for a specified number of bytes (count).
  • sys_write: Writes data to a file descriptor.
  • sys_close: Closes a file descriptor.
  • sys_execve: Creates a new directory.
  • sys_rmdir: Removes a directory.
  • sys_unlink: Removes a file.
  • sys_chmod: Changes file permissions.
  • sys_mmap: Maps files or devices into memory.
  • sys_exit: Handles process termination.

We will now setup a breakpoint on __x64_sys_mkdir as we are on x64 architecture and continue c.

You can also view the source code of __x64_sys_mkdir.

NOTE

I am using pwndbg as GDB extension.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pwndbg> list __x64_sys_mkdir
3677		return do_mkdirat(dfd, pathname, mode);
3678	}
3679	
3680	SYSCALL_DEFINE2(mkdir, const char __user *, pathname, umode_t, mode)
3681	{
3682		return do_mkdirat(AT_FDCWD, pathname, mode);
3683	}
3684	
3685	int vfs_rmdir(struct inode *dir, struct dentry *dentry)
3686	{

pwndbg> c
Continuing.

Now, we will create a directory named AAAA in QEMU terminal.

1
~ # mkdir AAAA

In GDB, we can see that we hit the function __x64_sys_mkdir().

1
2
3
4
5
6
7
8
9
10
11
12
In file: /tmp/linux-5.11.4/fs/namei.c:3680
   3675 SYSCALL_DEFINE3(mkdirat, int, dfd, const char __user *, pathname, umode_t, mode)
   3676 {
   3677         return do_mkdirat(dfd, pathname, mode);
   3678 }
   3679 
 ► 3680 SYSCALL_DEFINE2(mkdir, const char __user *, pathname, umode_t, mode)
   3681 {
   3682         return do_mkdirat(AT_FDCWD, pathname, mode);
   3683 }
   3684 
   3685 int vfs_rmdir(struct inode *dir, struct dentry *dentry)

We can use next to see what arguments are passed to do_mkdirat because this is function called.

Now, we will print the arguments passed to do_mkdirat -

1
2
3
4
5
6
7
8
   3650 static long do_mkdirat(int dfd, const char __user *pathname, umode_t mode)
 ► 3651 {


pwndbg> reg rdi rsi rdx
*RDI  0xffffc900001b7f58 ◂— 0xffffc900001b7f58
*RSI  0
*RDX  0xffffffffffffffff

We can also use the name of the arguments instead of registers in case you don’t remember the calling convention ;)

1
2
3
4
5
6
pwndbg> p dfd
$3 = -100
pwndbg> p pathname
$4 = 0x7ffc41948fc8 "AAAA"
pwndbg> p mode
$5 = 511

We can see register RSI “AAAA”. We can modify it… :)

1
2
3
4
5
6
7
8
9
10
11
pwndbg> x/c 0x7ffc41948fc8
0x7ffc41948fc8:	65 'A'
pwndbg> x/c 0x7ffc41948fc9
0x7ffc41948fc9:	65 'A'
pwndbg> x/c 0x7ffc41948fca
0x7ffc41948fca:	65 'A'
pwndbg> x/c 0x7ffc41948fcb
0x7ffc41948fcb:	65 'A'
pwndbg> x/c 0x7ffc41948fcc
0x7ffc41948fcc:	0 '\000'

1
2
3
4
5
6
7
8
9
pwndbg> set {char}0x7ffc41948fc8 = 'H'
pwndbg> set {char}0x7ffc41948fc9 = 'A'
pwndbg> set {char}0x7ffc41948fca = 'C'
pwndbg> set {char}0x7ffc41948fcb = 'K'
pwndbg> x/s 0x7ffc41948fc8 # Verify
0x7ffc41948fc8:	"HACK"
pwndbg> c
Continuing.

Now, let’s take a look at QEMU window.

1
2
3
~ # ls
HACK     dev      linuxrc  root     sys
bin      init     proc     sbin     usr

There you have it! By hitting a breakpoint at the do_mkdirat() kernel function, we were able to manipulate the memory content and change the directory name. Initially, we used mkdir AAAA, but through modifying the memory content, we ended up creating a directory named HACK.

Congratulations! You’ve successfully built a custom Linux kernel, created a minimal filesystem with Busybox, ran it on QEMU, and even debugged the kernel using GDB. This tutorial has given you a hands-on experience in kernel development and embedded system basics.

Feel free to explore more kernel configurations, Busybox features, and QEMU options to deepen your understanding.

Now you’re equipped with the knowledge to create and debug custom Linux kernels.

Happy kernel hacking!

This post is licensed under CC BY 4.0 by the author.