AFL++ Advanced Techniques: Persistent Mode, CmpLog, and Custom Mutators

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 binary

AFL++ 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 @@ &
done

The -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 crashes

Watch for:

  • execs_per_sec below 1,000: performance problem (slow target, missing persistent mode)
  • bitmap_cvg plateau before 20%: likely missed coverage (custom mutator or CmpLog needed)
  • corpus_count > 100,000: run afl-cmin to 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.

Read more

Start now free