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.
/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.
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!