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-fuzzFrom 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 installVerify:
afl-fuzz --versionSystem 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 thisAFL 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:
- Reads input from a file or stdin
- Processes that input
- 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.cafl-clang-fast uses LLVM's persistent mode for better performance. If LLVM isn't available:
afl-gcc -o target target.c mylib.c # older, slowerBuilding 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.jsonSeed 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">waitInvestigating 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:12This 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:
- Crashes found: Triage each unique crash. Determine if it's security-relevant.
- Coverage reached: Check
map densityin the status screen. If below 10%, your seed corpus may be missing important code paths. - Coverage plateau: If
last new findis many hours ago, AFL has likely explored the reachable code. Either add better seeds or accept the result. - 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.