Airoverflow logo
Back to Blog
Blog2026-07-15AirOverflow

Fuzzing the Linux Kernel Locally with AFL++

#AFL++#Linux Kernel#Fuzzing#Memory Safety#Harness Design

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:

  1. Pick an input from a queue.
  2. Mutate it (bit flips, arithmetic, splices, dictionary tokens).
  3. Run the target and record which edges of the control-flow graph were hit.
  4. If the input reached a new edge, keep it. Otherwise throw it away.
  5. 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.

install-aflpp.sh
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 --version

make 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.

target.c
#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.

harness.c
#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:

build.sh
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 -- ./harness

Within 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

build-kernel.sh
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.

run-vm.sh
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 \
    -nographic

oops=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.

kcov-harness.c
#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 ioctl bounces 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 ioctl sequence 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 past open().
  • Dictionaries for magic values. If the parser checks for 0x41414141 or a four-byte magic, hand AFL++ a -x dict.txt so 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_KCOV and CONFIG_KASAN enabled
  • 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-showmap confirms 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:

  1. Minimize. Run afl-tmin -i crash -o min -- ./harness to 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.
  2. 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.
  3. Confirm reachability. Prove an unprivileged process can reach the code. A bug behind CAP_SYS_ADMIN is a very different severity from one reachable by any user.
  4. Write the minimal C repro. Independent of the fuzzer. If it panics a clean build of the same kernel, it is real.
triage.sh
# 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:

  • ioctl handlers on /dev nodes 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_PACKET or AF_NETLINK.
  • setsockopt / getsockopt on 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.

Get the next one the day it drops.

New research, advisories, and deep dives from the AirOverflow team, straight to your inbox. No spam.