Why fuzz the kernel at all
The Linux kernel is the single largest piece of C running on most machines you
care about. It parses untrusted input from filesystems, network packets, USB
descriptors, and hundreds of ioctl interfaces. Every one of those is an
attack surface reachable from unprivileged userspace, and every one of them is
a candidate for a coverage-guided fuzzer.
This post walks through a fully local setup: no cloud, no fleet, just AFL++ on your own workstation fuzzing two things - a userspace parser as a warm-up, and the kernel itself through a syscall harness under KASAN. By the end you will know how to build instrumented targets, write harnesses that actually exercise interesting code, and triage the crashes that come out.
The mental model
Coverage-guided fuzzing is a feedback loop:
- Pick an input from a queue.
- Mutate it (bit flips, arithmetic, splices, dictionary tokens).
- Run the target and record which edges of the control-flow graph were hit.
- If the input reached a new edge, keep it. Otherwise throw it away.
- Repeat millions of times.
The magic is entirely in step 3. Without edge coverage a fuzzer is a random number generator. With it, the fuzzer hill-climbs toward new code on its own. AFL++ gets that signal through compile-time instrumentation for userspace, and through a mix of KCOV + a harness for the kernel.
Installing AFL++
Build from source - distro packages lag badly and you want the current mutators and LTO instrumentation.
sudo apt-get install -y build-essential git cmake \
clang llvm llvm-dev libclang-dev
git clone https://github.com/AFLplusplus/AFLplusplus
cd AFLplusplus
# PERFORMANCE and LLVM_CONFIG are auto-detected; just build everything.
make distrib
sudo make install
afl-fuzz --versionmake distrib builds the LTO compiler (afl-clang-lto), the QEMU mode for
black-box binaries, and the helper tools (afl-cmin, afl-tmin, afl-showmap).
Warm-up: fuzzing a userspace parser
Before touching ring 0, get the loop working on something trivial. Here is a
deliberately buggy parser - a stand-in for any parse_header() you might find
in a real target.
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
// Parses a toy "record": [1 byte type][1 byte len][len bytes payload]
int parse_record(const uint8_t *data, size_t size) {
if (size < 2)
return -1;
uint8_t type = data[0];
uint8_t len = data[1];
char buf[16];
if (type == 0x41) {
// BUG: len is attacker-controlled up to 255, buf is 16 bytes.
memcpy(buf, data + 2, len);
return buf[0];
}
return 0;
}AFL++ prefers a persistent-mode harness - one process, many iterations, no
fork/exec per input. That is the single biggest speed win available to you.
#include <stdint.h>
#include <unistd.h>
int parse_record(const uint8_t *data, size_t size);
__AFL_FUZZ_INIT();
int main(void) {
// Defer the fork server until after one-time setup is done.
__AFL_INIT();
unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;
while (__AFL_LOOP(10000)) {
int len = __AFL_FUZZ_TESTCASE_LEN;
parse_record(buf, len);
}
return 0;
}Build with the sanitizer on - ASAN turns the silent overflow into a hard, loud crash the fuzzer can detect:
export AFL_USE_ASAN=1
afl-clang-lto -g -O1 harness.c target.c -o harness
mkdir -p in
printf 'A\x04abcd' > in/seed # type=0x41, len=4, 4 bytes payload
afl-fuzz -i in -o out -- ./harnessWithin seconds AFL++ mutates len past 16, ASAN reports a stack-buffer-overflow,
and the crashing input lands in out/default/crashes/. That is the entire loop.
Everything from here is scaling it up and pointing it at harder targets.
Moving to the kernel
The kernel cannot be instrumented at compile time the way a userspace binary can - or rather, it can, but the coverage lives inside ring 0 and you need a bridge to get it back to AFL++ in userspace. Two pieces make that work:
- KCOV - a kernel feature that records the edges hit inside a syscall on the calling thread, and exposes them through a memory-mapped buffer.
- KASAN - Kernel Address Sanitizer, the ring-0 equivalent of ASAN. It turns
out-of-bounds and use-after-free bugs into
BUG:splats instead of silent corruption.
The plan: build a kernel with both enabled, boot it, then run a harness that reads KCOV coverage and feeds it to AFL++ through its shared-memory bitmap.
Building an instrumented kernel
git clone --depth=1 https://github.com/torvalds/linux
cd linux
make defconfig
# Enable coverage + sanitizers via config fragments.
scripts/config -e CONFIG_KCOV \
-e CONFIG_KCOV_INSTRUMENT_ALL \
-e CONFIG_KASAN \
-e CONFIG_KASAN_INLINE \
-e CONFIG_DEBUG_INFO_DWARF5 \
-e CONFIG_GDB_SCRIPTS \
-e CONFIG_DEBUG_FS
make olddefconfig
make -j"$(nproc)"KCOV instrumentation makes the kernel slower and larger - that is expected and fine. You are trading throughput for the coverage signal that makes the fuzzer intelligent.
Run it in a VM, not on bare metal
Fuzzing the kernel means crashing the kernel, repeatedly, on purpose. Do not do that to the machine you are typing on. Boot the instrumented build under QEMU/KVM with a minimal root image so every panic is a two-second reboot, not a lost afternoon.
qemu-system-x86_64 \
-m 2G -smp 2 -enable-kvm \
-kernel arch/x86/boot/bzImage \
-append "console=ttyS0 root=/dev/sda earlyprintk=serial oops=panic panic=1" \
-drive file=rootfs.img,format=raw \
-net user,hostfwd=tcp::10021-:22 -net nic \
-nographicoops=panic panic=1 is the important part: turn any kernel warning into an
immediate panic-and-reboot so the fuzzer never runs against a half-corrupted
kernel and mislabels later crashes.
A KCOV-driven syscall harness
KCOV is used from userspace with a small, fixed dance: open /sys/kernel/debug/kcov,
size the buffer, mmap it, enable tracing on the current thread, run the code
under test, then read how many edges were recorded.
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <linux/types.h>
#define KCOV_INIT_TRACE _IOR('c', 1, unsigned long)
#define KCOV_ENABLE _IO('c', 100)
#define KCOV_DISABLE _IO('c', 101)
#define COVER_SIZE (64 << 10)
#define KCOV_TRACE_PC 0
int main(void) {
int fd = open("/sys/kernel/debug/kcov", O_RDWR);
if (fd == -1) { perror("open kcov"); exit(1); }
if (ioctl(fd, KCOV_INIT_TRACE, COVER_SIZE)) { perror("init"); exit(1); }
unsigned long *cover = mmap(NULL, COVER_SIZE * sizeof(unsigned long),
PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (cover == MAP_FAILED) { perror("mmap"); exit(1); }
if (ioctl(fd, KCOV_ENABLE, KCOV_TRACE_PC)) { perror("enable"); exit(1); }
// Reset the edge counter, then drive the syscall under test.
__atomic_store_n(&cover[0], 0, __ATOMIC_RELAXED);
// Example target: the ioctl / read path of a device you want to stress.
// Replace this with the syscall sequence you are fuzzing.
char buf[64];
int target = open("/dev/target", O_RDWR);
read(target, buf, sizeof(buf));
close(target);
unsigned long n = __atomic_load_n(&cover[0], __ATOMIC_RELAXED);
printf("edges covered: %lu\n", n);
for (unsigned long i = 0; i < n; i++)
printf(" pc[%lu] = 0x%lx\n", i, cover[i + 1]);
ioctl(fd, KCOV_DISABLE, 0);
munmap(cover, COVER_SIZE * sizeof(unsigned long));
close(fd);
return 0;
}That prints the exact program counters the kernel executed while servicing your
syscall. To wire it into AFL++, replace the printf loop with a write into
AFL's shared-memory coverage bitmap: hash each PC into an index and mark that
byte. AFL++ ships afl-fuzz's __AFL_SHM protocol and the afl-showmap tool;
the harness becomes a __AFL_LOOP persistent runner that resets KCOV each
iteration and folds the PC list into the map before the next input arrives.
Harness design: the part that actually matters
Tooling is 20% of the work. What your harness feeds the kernel is the other 80%. A perfect fuzzer pointed at the wrong bytes finds nothing. Some rules we follow on every engagement:
- Fuzz structure, not noise. A raw random buffer into an
ioctlbounces off the first length check. Model the input: give the fuzzer a small struct it can mutate - command number, then a length, then a payload - so mutations land past the validation and reach real logic. - One subsystem per harness. A harness that opens
/dev/target, then also does networking, then also mounts a filesystem, dilutes coverage. Narrow harnesses climb faster. - Reset state every iteration. Persistent mode reuses the process. If your target caches a pointer or leaves a file descriptor open, iteration N+1 runs against dirty state and every crash is unreproducible. Close what you open.
- Seed with real traffic. Capture a valid
ioctlsequence from strace of the real userspace tool and drop it in as a seed. The fuzzer starts inside the interesting code instead of spending a day getting pastopen(). - Dictionaries for magic values. If the parser checks for
0x41414141or a four-byte magic, hand AFL++ a-x dict.txtso it doesn't have to brute-force the constant.
Pre-flight checklist
Before you leave a kernel fuzzing campaign running overnight, verify each of these. Click to check them off:
- Kernel built with
CONFIG_KCOVandCONFIG_KASANenabled - Target booting under QEMU/KVM with
panic=1 oops=panic - Harness resets KCOV coverage at the top of every loop iteration
- Every opened fd / mapping is closed before the next iteration
- Seed corpus captured from real, valid syscall traffic
- Dictionary of magic constants passed with
-x -
afl-showmapconfirms the harness produces non-empty coverage - Crash directory is on a host-shared volume so panics survive reboot
If any box is unchecked, stop and fix it. A campaign that violates even one of these is quietly wasting CPU.
Triage: from crash to root cause
A crashes/ directory full of files is not a finding. The workflow to turn one
into a report:
- Minimize. Run
afl-tmin -i crash -o min -- ./harnessto strip the input down to the bytes that actually trigger the bug. A 4-byte repro is a hundred times easier to reason about than a 900-byte one. - Read the KASAN splat. The kernel log tells you the bug class
(
slab-out-of-bounds,use-after-free), the faulting address, and a backtrace to the exact line. This is your root cause, handed to you. - Confirm reachability. Prove an unprivileged process can reach the code.
A bug behind
CAP_SYS_ADMINis a very different severity from one reachable by any user. - Write the minimal C repro. Independent of the fuzzer. If it panics a clean build of the same kernel, it is real.
# Minimize the crashing input to its essential bytes.
afl-tmin -i out/default/crashes/id:000000* -o crash.min -- ./harness
# Replay it and capture the kernel log with the KASAN report.
./harness < crash.min
dmesg | grep -A40 'BUG: KASAN'The KASAN report plus a minimal repro is the advisory. Everything after that is coordinated disclosure.
Where to point it first
If you are staring at the kernel wondering where to start, the highest-yield surfaces for a local fuzzer are the ones that take the most attacker-controlled, variable-length input:
ioctlhandlers on/devnodes an unprivileged user can open - GPU, DRM, sound, and virtual devices are historically rich.- Filesystem image parsers - mount a malformed image and watch the parser.
- Netlink and packet-parsing paths reachable with
AF_PACKETorAF_NETLINK. setsockopt/getsockopton exotic protocol families.
Pick one, write a narrow harness, seed it with real traffic, and let it run. The kernel is big enough that a focused fuzzer on a single subsystem, running overnight on one workstation, still finds things.
Found a bug with this workflow? We coordinate disclosure and publish the full root-cause analysis as an advisory. Subscribe below to get the next one the day it drops.

