Skip to main content
  1. Posts/

Adding Symbols to a Stripped Kernel for Debugging and Exploitation

·5 mins
Research Kernel Qemu
Table of Contents

Introduction
#

If you like kernel exploitation challenges, you likely have encountered a situation where you are given a vulnerable kernel image which you need to exploit to elevate your privileges to root.

Typically, you will be given the following files:

  • A kernel: bzImage
  • The initial RAM: initramfs.cpio.gz
  • A script to run the challenge: run.sh

The run script can look something like this:

#!/bin/sh

qemu-system-x86_64 \
        -kernel bzImage \
        -initrd initramfs.cpio.gz \
        -monitor none \
        -append "console=ttyS0 quiet oops=panic" \
        -cpu qemu64,+smep,+smap \
        -m 128M \
        -nographic \
        -no-reboot

Crafting a kernel exploit takes precision, so you want to be able debug your prototypes. Therefore, you add the parameters -S -s to qemu so that you can attach with GDB. However, at this point you are in the dark. You have a debugger but no where to break to.

This article will demonstrate how you can drastically improve your kernel exploit development setup by adding symbols to the target kernel image.

Extracting the Kernel Image
#

The first step is to extract the actual ELF file from the compressed bzImage. This can easily be done using the extract-vmlinux script found here in the linux source tree.

./extract-vmlinux bzImage > vmlinux

You can then use file to check if the resulting binary is stripped.

vmlinux: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, BuildID[sha1]=ea9f936ab77a8b18bfb747a3ad2e11d0dead3115, stripped

If it’s not, congratz, you’re done! If it is however, you can move on to the next step.

Disabling KASLR
#

The symbols we are going to add to the kernel are fixed. However, recent kernels run by default with kernel ASLR (KASLR) enabled, which randomizes the addresses. Therefore, we need to deactivate this security feature so that our symbols match the ones inside qemu.

This can be done by passing the nokaslr argument to the kernel command line.

        -append "console=ttyS0 quiet oops=panic nokaslr" \

Getting a Root Shell
#

We are going to retrieve the kernel symbols from the /proc/kallsyms file. However, this file is often not readable by an unprivileged user. If that’s the case, we need to artificially get a root shell. We can follow the steps described in this post and many others.

The Linux kernel can also be compiled without the /proc/kallsyms file altogether by using the CONFIG_KALLSYMS=n option. In this case, the technique described in this post won’t work.

First, we extract the filesystem from the cpio archive.

gunzip initramfs.cpio.gz
cpio -idm < initramfs.cpio

Then, we modify the init script, in our case /init.

#!/bin/sh

/bin/busybox --install -s

stty raw -echo

chown -R 0:0 /

mkdir -p /proc && mount -t proc none /proc
mkdir -p /dev  && mount -t devtmpfs devtmpfs /dev
mkdir -p /tmp  && mount -t tmpfs tmpfs /tmp

echo 1 > /proc/sys/kernel/kptr_restrict
echo 1 > /proc/sys/kernel/dmesg_restrict
echo 1 > /proc/sys/kernel/perf_event_paranoid

chmod 400 /proc/kallsyms
chmod 400 /flag

setsid /bin/cttyhack setuidgid 1000 /bin/sh # Change the UID from 1000 to 0

poweroff -d 1 -n -f

Finally, we recompress the archive.

find . -print0 | cpio --null -ov --format=newc > initramfs.cpio
gzip -9 initramfs.cpio

Now we should get a root shell after the system boots.

As a sidenote, we could also just have modified the init script shown above to get access to kallsyms without needing a root shell by commenting out the chmod line and changing kptr_restrict to 0.

Extracting the Symbols
#

We need to exfiltrate the content of kallsyms outside of the qemu environment. However, the qemu console has a pretty small buffer so we can’t really copy-paste it.

A simple way to achieve this is using a python script with pwntools.

PROMPT = b"~ # "

# Start process
p = process("./run.sh")
p.recvuntil(PROMPT)

# Read symbols
p.sendline(b"cat /proc/kallsyms")
p.recvline() # Discard command echo
syms = p.recvuntil(PROMPT, drop=True)

# Write symbols
with open("kallsyms", "wb") as f:
    f.write(syms)

After a few seconds, the address of all the kernel symbols will be saved in a file.

Adding the Symbols to the Kernel Image
#

Now that we have the list of symbols, we need to find a way to give this information to GDB. I searched for simple ways to do this but couldn’t find anything that worked, so I decided to create my own script.

The trick is to use the --add-symbol option of objcopy to add every symbol to the vmlinux ELF file.

#!/usr/bin/env python3
import subprocess
import shutil
import os
from pwn import ELF
from tqdm import tqdm

CHUNK_SIZE    = 10000
KALLSYMS_FILE = "kallsyms"
VMLINUX_IN    = "vmlinux"
VMLINUX_OUT   = "vmlinux_with_syms"

def get_elf_sections(path):
    elf = ELF(path, checksec=False)
    sections = {}
    for section in elf.sections:
        name = section.name
        addr = section.header.sh_addr
        size = section.header.sh_size
        sections[name] = (addr, addr + size)
    return sections

def run_objcopy(args):
    objcopy_cmd = ["objcopy"]
    objcopy_cmd += args
    objcopy_cmd += [VMLINUX_OUT]
    subprocess.run(objcopy_cmd, check=True)

# Read section addresses from vmlinux
section_addrs = get_elf_sections(VMLINUX_IN)

add_symbols_args = []

with open(KALLSYMS_FILE, "r") as kf:
    for line in kf:
        # Line format: "<addr> <type> <name> [<module>]"
        parts = line.strip().split()
        if len(parts) < 3:
            continue
        addr_str, sym_type, sym_name = parts[0], parts[1], parts[2]

        # Skip module symbols
        if sym_name.endswith(']'):
            if '[' in line:
                continue

        # Determine section for this symbol
        addr_val = int(addr_str, 16)
        section = None
        for sec, (start, end) in section_addrs.items():
            if start <= addr_val < end:
                section = sec
                break
        # If not found, handle the symbol as absolute
        offset_val = addr_val
        if section:
            offset_val = addr_val - section_addrs[section][0]

        # Determine flags
        flags = []
        if sym_type.islower():
            flags.append("local")
        else:
            flags.append("global")
        if sym_type.lower() == 't':  # text code
            flags.insert(0, "function")
        else:
            # data (d,b,r) or others treated as object
            flags.insert(0, "object")

        # Construct the --add-symbol argument
        # --add-symbol name=[section:]value[,flags]
        if section:
            add_sym = f"{sym_name}={section}:{hex(offset_val)}"
        else:
            add_sym = f"{sym_name}={hex(offset_val)}"
        if flags:
            add_sym += "," + ",".join(flags)
        add_symbols_args.append("--add-symbol")
        add_symbols_args.append(add_sym)

# Create new vmlinux file
os.remove(VMLINUX_OUT)
shutil.copy2(VMLINUX_IN, VMLINUX_OUT)

# Add symbols
print(f"Adding {len(add_symbols_args)//2} symbols to '{VMLINUX_OUT}'")
for _ in tqdm(range((len(add_symbols_args)-1)//CHUNK_SIZE)):
    run_objcopy(add_symbols_args[:CHUNK_SIZE])
    add_symbols_args = add_symbols_args[CHUNK_SIZE:]
run_objcopy(add_symbols_args)

print("Done")

After running the script, we can easily add breakpoints in GDB to debug our kernel exploit.

gdb ./vmlinux_with_syms

gef> b *__sys_socket
Breakpoint 1 at 0xffffffff81bb6c40
gef> 

Happy PWNing!