AFL++ Fuzzing Tutorial: From Zero to Finding Real Bugs

AFL++ Fuzzing Tutorial: From Zero to Finding Real Bugs

AFL++ is the most widely used coverage-guided fuzzer. It's responsible for finding thousands of CVEs in real-world software — in image parsers, network libraries, file format handlers, and more. This tutorial walks through everything you need to go from installation to finding actual bugs.

Installation

Ubuntu/Debian:

apt-get install afl++

macOS:

brew install afl-fuzz

From source (latest features):

git clone https://github.com/AFLplusplus/AFLplusplus
<span class="hljs-built_in">cd AFLplusplus
make
<span class="hljs-built_in">sudo make install

Verify:

afl-fuzz --version

System Configuration

AFL needs some system tuning to run properly:

# Set CPU scaling governor
<span class="hljs-built_in">echo performance <span class="hljs-pipe">| <span class="hljs-built_in">sudo <span class="hljs-built_in">tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor

<span class="hljs-comment"># Disable core dumps to /dev/null (AFL tracks crashes, not the kernel)
<span class="hljs-built_in">echo core <span class="hljs-pipe">| <span class="hljs-built_in">sudo <span class="hljs-built_in">tee /proc/sys/kernel/core_pattern

<span class="hljs-comment"># Or run with AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES=1 to skip this

AFL will warn if these aren't set and offer to configure them automatically.

Writing a Fuzz Target

Before you can fuzz, you need a target program that:

  1. Reads input from a file or stdin
  2. Processes that input
  3. Exits (without hanging)

The Simplest Target

// target.c — reads from stdin, passes to parse function
#include <stdio.h>
#include <stdlib.h>
#include "myparser.h"

int main(void) {
    unsigned char buf[65536];
    size_t len = fread(buf, 1, sizeof(buf), stdin);
    if (len == 0) return 0;
    
    parse_data(buf, len);
    return 0;
}

File-Based Target

// target_file.c — reads from a file path passed as argv[1]
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "myparser.h"

int main(int argc, char *argv[]) {
    if (argc < 2) return 1;
    
    FILE *f = fopen(argv[1], "rb");
    if (!f) return 1;
    
    fseek(f, 0, SEEK_END);
    size_t size = ftell(f);
    fseek(f, 0, SEEK_SET);
    
    unsigned char *buf = malloc(size);
    if (!buf) { fclose(f); return 1; }
    
    fread(buf, 1, size, f);
    fclose(f);
    
    parse_data(buf, size);
    
    free(buf);
    return 0;
}

Compiling with AFL Instrumentation

AFL uses its own compiler wrappers that add branch coverage instrumentation:

# C
CC=afl-clang-fast ./configure
make

<span class="hljs-comment"># Or directly
afl-clang-fast -o target target.c mylib.c

<span class="hljs-comment"># C++
CXX=afl-clang-fast++ make

<span class="hljs-comment"># With ASAN for better bug detection
AFL_USE_ASAN=1 afl-clang-fast -o target target.c mylib.c

afl-clang-fast uses LLVM's persistent mode for better performance. If LLVM isn't available:

afl-gcc -o target target.c mylib.c  # older, slower

Building a Seed Corpus

The seed corpus is the starting point for fuzzing. Better seeds = better initial coverage = faster interesting mutations.

mkdir -p corpus/seeds

<span class="hljs-comment"># Copy real examples of valid inputs
<span class="hljs-built_in">cp examples/*.json corpus/seeds/
<span class="hljs-built_in">cp test-fixtures/*.bin corpus/seeds/

<span class="hljs-comment"># Or create minimal seeds manually
<span class="hljs-built_in">echo <span class="hljs-string">'{}' > corpus/seeds/empty_json.json
<span class="hljs-built_in">echo <span class="hljs-string">'{"key": "value"}' > corpus/seeds/simple_json.json

Seed corpus principles:

  • Small files fuzz faster than large files (AFL copies and mutates them)
  • Diverse files cover more code paths
  • Valid inputs are better starting points than random bytes
  • 5–20 seeds is often enough; thousands of seeds can slow things down

Minimizing seeds:

# Deduplicate and minimize your corpus
afl-cmin -i corpus/seeds -o corpus/minimized -- ./target @@
afl-tmin -i corpus/minimized/seed1 -o corpus/minimized/seed1_min -- ./target @@

Running AFL

# Single instance
afl-fuzz -i corpus/seeds -o fuzz-output -- ./target @@

<span class="hljs-comment"># With stdin instead of file
afl-fuzz -i corpus/seeds -o fuzz-output -- ./target

<span class="hljs-comment"># With a time limit (300 seconds)
afl-fuzz -i corpus/seeds -o fuzz-output -V 300 -- ./target @@

<span class="hljs-comment"># With ASAN
AFL_USE_ASAN=1 afl-fuzz -i corpus/seeds -o fuzz-output -- ./target @@

@@ is replaced with the path to the current test input file.

Understanding the AFL Status Screen

                        american fuzzy lop ++4.20a
┌─ process timing ─────────────────────────────────────────────────────┐
│     run time : 0 days, 1 hrs, 23 min, 45 sec                         │
│  last new find : 0 days, 0 hrs, 12 min, 45 sec                       │
│ last uniq crash : 0 days, 0 hrs, 5 min, 12 sec                       │
│  last uniq hang : none seen yet                                       │
├─ overall results ────────────────────────────────────────────────────┤
│       cycles done : 2                                                 │
│  corpus count : 847                                                   │
│  saved crashes : 3                                                    │
│   saved hangs : 0                                                     │
├─ stage progress ─────────────────────────────────────────────────────┤
│  now trying : havoc                                                   │
│ stage execs : 204.8k/2.10M (9.74%)                                   │
│ total execs : 1.23M                                                   │
│  exec speed : 1253/sec                                                │
├─ map coverage ───────────────────────────────────────────────────────┤
│    map density : 2.34% / 5.12%                                        │
│  count coverage : 5.89 bits/tuple                                     │
└──────────────────────────────────────────────────────────────────────┘

Key metrics:

  • exec speed: Executions per second. Below 100/s = slow target or bad setup. Above 1000/s = healthy.
  • cycles done: How many times AFL has gone through the corpus. More cycles = more thorough.
  • corpus count: Total interesting inputs in the corpus. Should grow over time.
  • saved crashes: Unique crashes found. Each needs investigation.
  • map density: Percentage of code branches triggered. Higher = better coverage.
  • last new find: Time since AFL found a new interesting input. If this is hours, coverage has plateaued.

Parallel Fuzzing

AFL runs single-threaded. Use multiple instances to utilize multiple cores:

# Master instance (one required)
afl-fuzz -i corpus/seeds -o fuzz-output -M fuzzer0 -- ./target @@

<span class="hljs-comment"># Secondary instances (one per additional core)
afl-fuzz -i corpus/seeds -o fuzz-output -S fuzzer1 -- ./target @@
afl-fuzz -i corpus/seeds -o fuzz-output -S fuzzer2 -- ./target @@
afl-fuzz -i corpus/seeds -o fuzz-output -S fuzzer3 -- ./target @@

Secondary instances share interesting inputs with each other via the fuzz-output directory.

For automation:

# Start N parallel instances
<span class="hljs-keyword">for i <span class="hljs-keyword">in $(<span class="hljs-built_in">seq 0 7); <span class="hljs-keyword">do
  <span class="hljs-keyword">if [ <span class="hljs-variable">$i -eq 0 ]; <span class="hljs-keyword">then
    afl-fuzz -i corpus/seeds -o fuzz-output -M main -- ./target @@ &
  <span class="hljs-keyword">else
    afl-fuzz -i corpus/seeds -o fuzz-output -S worker<span class="hljs-variable">$i -- ./target @@ &
  <span class="hljs-keyword">fi
<span class="hljs-keyword">done
<span class="hljs-built_in">wait

Investigating Crashes

When AFL finds a crash, it saves the input in fuzz-output/crashes/:

# List crashes
<span class="hljs-built_in">ls fuzz-output/crashes/

<span class="hljs-comment"># Reproduce a crash
./target fuzz-output/crashes/id:000000,sig:11,src:000000,op:havoc,rep:4

<span class="hljs-comment"># With ASAN for full error details
AFL_USE_ASAN=1 ./target fuzz-output/crashes/id:000000...

<span class="hljs-comment"># Minimize the crash input
afl-tmin -i fuzz-output/crashes/id:000000... -o minimal_crash -- ./target @@

ASAN output for a heap overflow:

==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000018
READ of size 4 at 0x602000000018
    #0 0x403e23 in parse_header /src/mylib/parser.c:145
    #1 0x403f12 in parse_input /src/mylib/parser.c:89
    #2 0x401234 in main /fuzz/target.c:12

This shows the exact line in your code that triggered the overflow.

Persistent Mode for Performance

Normal AFL forks a new process per input. Persistent mode runs multiple inputs in the same process, avoiding fork overhead. This can improve speed 10–100x.

#include "AFL/aflpp_user_performance.h"

int main(void) {
    while (__AFL_LOOP(10000)) {
        unsigned char buf[65536];
        size_t len = fread(buf, 1, sizeof(buf), stdin);
        if (len == 0) continue;
        
        parse_data(buf, len);  // resets state between iterations
    }
    return 0;
}

Important: your target must not maintain state between iterations (or you must reset it explicitly). A leak or corrupted state from one iteration will affect the next.

Evaluating AFL Results

After running for several hours, evaluate:

  1. Crashes found: Triage each unique crash. Determine if it's security-relevant.
  2. Coverage reached: Check map density in the status screen. If below 10%, your seed corpus may be missing important code paths.
  3. Coverage plateau: If last new find is many hours ago, AFL has likely explored the reachable code. Either add better seeds or accept the result.
  4. No crashes but low coverage: Your target may have validation that rejects malformed inputs before reaching vulnerable code. Consider writing a more focused harness that bypasses initial validation.

Adding Crash Inputs as Regression Tests

Every minimized crash input should become a regression test:

# Add to your test corpus
<span class="hljs-built_in">mkdir -p tests/fuzz-regressions
<span class="hljs-built_in">cp minimal_crash tests/fuzz-regressions/crash-001

<span class="hljs-comment"># Add to your test suite
// In your test suite
#include <stdio.h>

void run_regression_test(const char *path) {
    FILE *f = fopen(path, "rb");
    // ... read and process
}

// Run all regression inputs
run_regression_test("tests/fuzz-regressions/crash-001");

This ensures fixed bugs don't regress. The fuzzing corpus becomes your regression test suite over time.

Read more