AFL++ Advanced Techniques: Persistent Mode, CmpLog, and Custom Mutators
Basic AFL++ usage — compile with afl-clang-fast, create a seed corpus, run afl-fuzz — finds bugs. Advanced AFL++ usage finds them 10–100x faster, or finds bugs that basic usage misses entirely.
This post covers the techniques that make AFL++ effective against hard targets: persistent mode, comparison logging, custom mutators, and alternative instrumentation modes.
Persistent Mode: The Single Biggest Speedup
Every AFL++ execution in standard mode forks a new child process, feeds it input, and waits for it to exit. Fork overhead limits throughput to ~1,000–10,000 executions/second.
Persistent mode runs the target function in a loop within a single process:
#include "afl-fuzz.h"
int main(int argc, char *argv[]) {
// One-time setup
initialize_global_state();
// AFL++ persistent mode: runs the loop inside a single process
while (__AFL_LOOP(10000)) { // 10000 iterations per fork, then restart
// Read input
uint8_t *buf;
ssize_t len = __AFL_LOOP_BUF(&buf);
// Fuzz target
parse_input(buf, len);
}
return 0;
}Speedup: typically 10–40x over fork mode for in-memory targets.
The __AFL_LOOP(N) macro runs N iterations in the same process, then allows AFL++ to fork a fresh child. The N value balances speed (more iterations before fork = faster) against state contamination (state accumulates over iterations).
Start with N=1000. If you see coverage inconsistencies or false positives, reduce N. If coverage is clean, increase to 10000+.
Shared memory input (FUZZ_USE_SHM)
AFL++ can pass inputs via shared memory instead of stdin or file I/O, eliminating the file write/read overhead:
#include "afl-fuzz.h"
int main(int argc, char *argv[]) {
// Shared memory buffer - AFL++ writes input here directly
__AFL_INIT();
unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;
while (__AFL_LOOP(10000)) {
int len = __AFL_FUZZ_TESTCASE_LEN;
parse_input(buf, len);
}
return 0;
}Combined with persistent mode, shared memory input can push throughput to 500,000–1,000,000 executions/second for fast targets.
Deferred forkserver
If initialization (loading config, parsing schema, opening databases) is slow but must happen once, use deferred forkserver to run setup before the first fork:
int main(int argc, char *argv[]) {
// Expensive one-time setup
load_large_database();
initialize_complex_state();
// Fork server starts HERE - initialization is done in the parent
// All children get this state for free (copy-on-write)
__AFL_INIT();
while (__AFL_LOOP(1000)) {
uint8_t buf[4096];
ssize_t len = read(0, buf, sizeof(buf));
process(buf, len);
}
return 0;
}Without deferred forkserver, each fork runs the initialization. With it, initialization runs once in the parent; children inherit the state via copy-on-write.
CmpLog: Solving Magic Bytes and Checksums
Coverage-guided fuzzing's Achilles heel: targets that check specific magic bytes, checksums, or magic numbers before reaching the interesting code.
void parse_packet(uint8_t *data, size_t len) {
if (memcmp(data, "\xDE\xAD\xBE\xEF", 4) != 0) return; // magic bytes
if (crc32(data+4, len-8) != *(uint32_t*)(data+len-4)) return; // checksum
// Interesting parsing code never reached by random fuzzing
parse_payload(data+4, len-8);
}Without help, the fuzzer must randomly generate the 4-byte magic and a valid CRC. Probability: ~(1/256)^5. Effectively never.
CmpLog solves this by logging comparison operands during execution:
# Build CmpLog binary (separate from main fuzz binary)
afl-clang-fast -fsanitize=address cmplog_target.c -o target_cmplog
# Run with CmpLog
afl-fuzz -i seeds/ -o output/ \
-l target_cmplog \ # CmpLog binary
-- ./target_fuzz @@ # Main binaryAFL++ runs each input through both binaries. The CmpLog binary records every comparison operand. AFL++ then mutates inputs by substituting one comparison operand for the other — if data[0..3] was compared against 0xDEADBEEF and didn't match, try substituting 0xDEADBEEF into the input at that offset.
Result: magic bytes are found in seconds. Checksums require the additional redqueen mutation mode:
afl-fuzz -i seeds/ -o output/ \
-l target_cmplog \
-c 0 \ # Enable CmpLog pass
-- ./target_fuzz @@Redqueen extends CmpLog with feedback about which mutations to apply based on comparison patterns, solving the checksum problem without dictionaries.
Custom Mutators: Structure-Aware Fuzzing
AFL++ ships excellent generic mutators. For targets with specific input formats (JSON, protobuf, ASN.1, domain-specific protocols), custom mutators generate valid structure while fuzzing semantics.
The custom mutator API
// afl_custom.c — implement any subset of these functions
// Initialize: called once at startup
void *afl_custom_init(afl_state_t *afl, unsigned int seed) {
MyMutatorState *state = malloc(sizeof(MyMutatorState));
init_state(state, seed);
return state;
}
// Core mutation: take input, produce mutated output
size_t afl_custom_fuzz(void *data, uint8_t **buf, size_t buf_size,
uint8_t **add_buf, size_t add_buf_size, size_t max_size) {
MyMutatorState *state = (MyMutatorState *)data;
// Parse current input as JSON
JsonValue *json = json_parse(*buf, buf_size);
if (!json) {
// Fallback: return original input unmodified
return buf_size;
}
// Apply structure-preserving mutation
json_mutate_random_field(state, json);
// Serialize back
size_t out_len = json_serialize(json, *buf, max_size);
json_free(json);
return out_len;
}
// Optional: called to validate/trim corpus entries
uint8_t afl_custom_queue_get(void *data, const uint8_t *filename) {
return 1; // return 1 to accept, 0 to skip
}
// Cleanup
void afl_custom_deinit(void *data) {
free(data);
}Load the custom mutator:
AFL_CUSTOM_MUTATOR_LIBRARY=./afl_custom.so \
afl-fuzz -i seeds/ -o output/ -- ./target @@Python custom mutators
For rapid prototyping, AFL++ supports Python mutators without compilation:
# custom_mutator.py
import json
import random
def init(seed):
random.seed(seed)
return {"rng": random.Random(seed)}
def fuzz(buf, add_buf, max_size):
"""Mutate buf and return mutated bytes."""
try:
data = json.loads(buf)
except json.JSONDecodeError:
return buf
# Structure-aware mutation: modify a random field
if isinstance(data, dict) and data:
key = random.choice(list(data.keys()))
# Apply type-appropriate mutation
if isinstance(data[key], int):
data[key] = random.choice([0, -1, 2**31-1, 2**32, -2**31, data[key]+1, data[key]-1])
elif isinstance(data[key], str):
mutations = ["", "A"*1000, "\x00\xff\x00", data[key] + "']};"]
data[key] = random.choice(mutations)
elif isinstance(data[key], list):
data[key].append(None)
return json.dumps(data).encode()AFL_PYTHON_MODULE=custom_mutator \
AFL_CUSTOM_MUTATOR_ONLY=1 \
afl-fuzz -i seeds/ -o output/ -- ./target @@AFL_CUSTOM_MUTATOR_ONLY=1 disables AFL++'s built-in mutations, using only the custom mutator. Remove this flag to stack custom mutations on top of built-in ones.
Grammar-based mutation with libAFL
For complex input languages (programming languages, protocol grammars), grammar-based mutation uses a formal grammar to generate valid inputs:
// libAFL grammar mutator (Rust)
use libafl::prelude::*;
fn main() {
let grammar = json_grammar(); // Define JSON grammar
let mutator = GrammarMutator::new(grammar);
// ... rest of fuzzer setup
}Grammar mutators guarantee syntactic validity, letting the fuzzer focus on semantic edge cases rather than syntax reconstruction.
QEMU Mode: Fuzzing Without Source Code
AFL++ can instrument binaries without source code using QEMU:
# Build QEMU support (one-time)
cd AFL++/qemu_mode && ./build_qemu_support.sh
# Fuzz a closed-source binary
afl-fuzz -Q -i seeds/ -o output/ -- ./closed_source_binary @@QEMU mode patches the binary at runtime to record coverage. Slower than compile-time instrumentation (~2–5x slower) but enables fuzzing of:
- Commercial software binaries
- Firmware images
- Libraries distributed as binaries
QEMU persistent mode recovers most of the speed penalty:
# QEMU persistent mode using function offsets
AFL_QEMU_PERSISTENT_ADDR=0x4008d0 \ # address of fuzz target function
AFL_ENTRYPOINT=0x4008d0 \
afl-fuzz -Q -i seeds/ -o output/ -- ./binary @@Frida Mode: Instrumentation via Dynamic Hooking
For targets where QEMU doesn't work (iOS binaries, complex dynamic linking, custom instruction sets), AFL++ Frida mode instruments via the Frida dynamic instrumentation framework:
# Build Frida mode support
cd AFL++/frida_mode && make
# Fuzz with Frida instrumentation
afl-fuzz -O -i seeds/ -o output/ -- ./binary @@Frida mode supports:
- iOS binaries (via Frida's iOS support)
- ARM binaries on x86 hosts
- Complex binaries that don't work under QEMU
Coverage quality from Frida mode is comparable to QEMU mode, with better compatibility for complex binaries.
Parallel Fuzzing Configuration
AFL++ parallel fuzzing scales linearly with CPU count for most targets:
# Primary instance (generates new seeds)
afl-fuzz -M main -i seeds/ -o output/ -- ./target @@
# Secondary instances (explore from primary's corpus)
for i in {1..7}; do
afl-fuzz -S worker_$i -i seeds/ -o output/ -- ./target @@ &
doneThe -M (main) instance runs deterministic mutations. -S (secondary) instances run exploration-focused mutations. All instances share the output directory and read each other's findings.
For distributed fuzzing across multiple machines, sync the output directory via NFS, rsync, or a tool like afl-sync:
# On each machine, after local fuzzing:
rsync -az output/ remote_host:/shared/output/
# Periodically pull remote findings
rsync -az remote_host:/shared/output/ output/Power Schedule Selection
AFL++ ships multiple power schedules. Choose based on your target:
# Default: fast
afl-fuzz -p fast -i seeds/ -o output/ -- ./target @@
# Explore: better for targets with hard-to-reach coverage
afl-fuzz -p explore -i seeds/ -o output/ -- ./target @@
# Exploit: if you've found crashes and want to find variants
afl-fuzz -p exploit -i seeds/ -o output/ -- ./target @@
# Rare: focuses on least-covered paths
afl-fuzz -p rare -i seeds/ -o output/ -- ./target @@For systematic comparison: run multiple instances with different schedules and compare coverage after 24 hours.
Environment Variables for Fine-Tuning
AFL++ exposes extensive configuration via environment variables:
# Increase bitmap size for complex targets with many edges
AFL_MAP_SIZE=10000000 afl-fuzz ...
# Skip deterministic mutations (faster for large seeds)
AFL_SKIP_DETERMINISTIC=1 afl-fuzz ...
# Disable trimming (faster, worse corpus quality)
AFL_NO_TRIM=1 afl-fuzz ...
# Ignore timeouts on startup (for slow-starting targets)
AFL_SKIP_CRASHES=1 afl-fuzz ...
# Enable CmpLog without a separate CmpLog binary (compile-time)
AFL_LLVM_CMPLOG=1 afl-clang-fast ...Measuring AFL++ Effectiveness
AFL++ provides comprehensive stats in output/fuzzer_stats:
execs_per_sec : 45231 # current throughput
corpus_count : 2847 # corpus entries
bitmap_cvg : 14.23% # % of map bytes touched
unique_crashes : 12 # deduped crashesWatch for:
execs_per_secbelow 1,000: performance problem (slow target, missing persistent mode)bitmap_cvgplateau before 20%: likely missed coverage (custom mutator or CmpLog needed)corpus_count> 100,000: runafl-cminto minimize
Summary
Basic AFL++ finds bugs. Advanced AFL++ finds more bugs, faster:
- Persistent mode: 10–40x throughput improvement for in-process targets
- CmpLog: breaks through magic bytes and checksums that block random fuzzing
- Custom mutators: structure-aware mutation for format-specific targets
- QEMU/Frida modes: coverage instrumentation without source code
Start with persistent mode and CmpLog — they work for most targets with minimal code changes. Add custom mutators when you see coverage plateauing in format-specific parsing code.