Coverage: The Feedback That Makes Fuzzing Smart

Complete Fuzzing Series - Blog 5 of N

Part Title Status
1 What Is Fuzzing and Why Does It Actually Work? :white_check_mark:
2 How a Fuzzer Works: Mutations, Seeds, and Corpus Explained :white_check_mark:
3 The Taxonomy: Every Type of Fuzzing Explained :white_check_mark:
4 What Happens Inside a Program When It Crashes :white_check_mark:
5 - You are here Coverage: The Feedback That Makes Fuzzing Smart :white_check_mark:
6 AFL++ Installation: Full Setup Guide :soon_arrow:

→ Next: Blog 6 - AFL++ Installation: Full Setup Guide
← Previous: Blog 4 - What Happens Inside a Program When It Crashes

Part of the Complete Fuzzing Series on IoTSec.in


In Blog 1, I said fuzzing is not random. It is guided.

In Blog 2, I showed you the loop - mutations, corpus, bitmap, feedback. The fuzzer keeps the inputs that reach new code and throws away everything else.

But I never stopped to explain the word sitting at the center of all of it.

Coverage.

Every time someone says “AFL++ tracks coverage” or “coverage went up” or “coverage plateaued” - what does that actually mean? What is being measured? How does the number grow? What does it mean when it stops?

I glossed over this when I first started learning. I assumed I understood it. I did not. And that gap made a lot of other things confusing for longer than they needed to be.

So this blog is the one I wish existed when I started.


What Code Coverage Actually Means

A program is not a straight line. It is full of decisions.

void process(int x) {
    if (x > 100) {
        do_big_thing();     // Line A
    } else {
        do_small_thing();   // Line B
    }

    if (x < 0) {
        handle_negative();  // Line C
    }
}

If I call process(50), which lines actually run?

if (x > 100)      → checked ✅
do_big_thing()    → SKIPPED (50 is not > 100)
do_small_thing()  → runs ✅
if (x < 0)        → checked ✅
handle_negative() → SKIPPED (50 is not < 0)

Line A and Line C never executed. There could be a buffer overflow in do_big_thing(). A use-after-free in handle_negative(). Anything. And we would never know - because we never ran them.

Code coverage is a map of what ran versus what did not.

That is the whole definition. Which parts of the program actually executed during a run.

And from the fuzzer’s perspective, coverage is the only signal that matters. If a new input executes code that nothing before it has reached - that is interesting. That is worth saving, mutating, and exploring further. If an input covers only ground already visited - it is useless. Throw it away.

Without coverage tracking, the fuzzer is blind. It mutates inputs and hopes. With coverage tracking, it knows exactly what it has explored and what it has not. That difference is the reason modern fuzzers find bugs that purely random tools never would.


Line Coverage, Branch Coverage, and Edge Coverage

When people say “coverage” they are being imprecise. There are actually different types. And they measure very different things.

Let me show you all three using one function.

void check(int x, int y) {
    if (x > 0) {
        do_thing_A();    // Line 1
    }
    if (y > 0) {
        do_thing_B();    // Line 2
    }
}

Line Coverage

The simplest type. Did this line execute at least once? Yes or no.

If I call check(1, 1) - both x and y are positive. Both lines run. Line coverage says: 100%. Both lines executed. Done.

This sounds good. It is not.

What if a bug only triggers when x > 0 AND y <= 0 together? My single test with check(1, 1) got 100% line coverage and never touched that combination. The bug is hiding in plain sight and line coverage has no way to flag it.

Line coverage asks “did you visit this room.” It does not care how you got there or what conditions were active when you arrived.

Branch Coverage

One level deeper. Instead of “did this line run,” it asks: did both sides of every decision run?

Every if has two sides - the YES path and the NO path.

if (x > 0) → YES path: x is positive
            → NO path:  x is zero or negative

Branch coverage requires both sides to be tested, for every single decision.

To satisfy branch coverage for our function, I need:

if (x > 0) → YES side tested ✅
if (x > 0) → NO side tested  ✅
if (y > 0) → YES side tested ✅
if (y > 0) → NO side tested  ✅

Better than line coverage. But still missing something.

Branch coverage checks each if independently. It does not care about combinations. These two calls give 100% branch coverage:

  • check(1, 1) - both YES paths
  • check(-1, -1) - both NO paths

But I never tested check(1, -1) or check(-1, 1). Those specific combinations might behave completely differently. They are invisible to branch coverage.

Edge Coverage

This is what AFL++ actually tracks. And it is smarter than both.

An edge is not just “which branch ran.” An edge is which branch ran, coming from which previous branch.

In other words - the transition between two points in the code.

entry → if(x>0) YES → if(y>0) YES   ← one unique edge
entry → if(x>0) YES → if(y>0) NO    ← completely different edge
entry → if(x>0) NO  → if(y>0) YES   ← another different edge
entry → if(x>0) NO  → if(y>0) NO    ← another one

Each combination of “where I came from + where I went” is a unique edge. AFL++ treats each one as separate territory to discover.

The reason this matters: bugs live in combinations, not individual lines.

A heap overflow might only trigger when the first check passed AND the second check failed AND you arrived at that state from a specific prior function. Edge coverage captures that. Line coverage and branch coverage both miss it.

One line to remember:

Line coverage counts rooms visited. Edge coverage counts which door you used to enter each room.

Same room, different door - that is a different edge. It could behave completely differently. It could have a bug in one path and not the other.


How AFL++ Tracks Edges Using the Bitmap

In Blog 2, I described the bitmap as a 65,536-slot array. Each slot represents one branch. Hit = 1. Not hit = 0.

Now that you understand edges, I can show you what the bitmap is actually doing.

Every edge gets a unique number

When AFL++ compiles your program with instrumentation, it assigns a unique ID to every location in the code. But it does not just record “this location fired.” It records the transition - where the code was, and where it went next.

It does this with a simple XOR calculation:

edge_id = current_location XOR previous_location

So:

came from A, went to B  →  A XOR B  =  some number  →  slot 47 in bitmap
came from C, went to B  →  C XOR B  =  different number  →  slot 312 in bitmap

Same destination B. But arrived from completely different places. Different journey. Different edge. Different slot in the bitmap.

This is how AFL++ captures edge coverage in a structure that fits in 64KB of RAM.

After every run, one question

After your program runs and exits, AFL++ looks at the bitmap and asks one thing:

Did any slot go from 0 to 1?
→ YES: this input reached code that nothing before it reached
       → save to corpus, use as a mutation base
→ NO:  nothing new
       → discard forever

That is the entire decision. One bitmap comparison. It happens in microseconds - which is why AFL++ can run hundreds of thousands of inputs per hour without slowing down.

The shared memory region

The program runs as a separate process from AFL++. So how does the bitmap get from the program back to AFL++ after each run?

Shared memory.

Before the program starts, AFL++ creates a special memory region that both AFL++ and the target program can read and write simultaneously.

AFL++ process        shared memory           target program
      │            [bitmap - 65536 slots]          │
      │                     │                      │
      └── reads after run ──┘──── writes during ───┘
                                       run

The program writes to the bitmap as it executes - every time an instrumented branch fires, the corresponding slot gets updated. AFL++ reads the bitmap after the program exits. No files. No network. Just one chunk of RAM that both processes share.

This is why it is so fast. Writing to shared memory takes nanoseconds. The entire feedback loop - run the program, check what it hit, decide to keep or discard - completes in a fraction of a millisecond.


Seeing It In a Real Crash

The best way to see edge coverage in action is not a toy example. It is a real crash output.

Here is a crash from a libFuzzer run on Dovecot’s mail decoder:

INFO: Loaded 1 modules (11396 inline 8-bit counters): 11396 [0x560c2ffe8fd8, 0x560c2ffebc5c)

==14==WARNING: MemorySanitizer: use-of-uninitialized-value
    #0 in uni_utf8_char_bytes         unichar.h:115
    #1 in uni_utf8_partial_strlen_n   unichar.c:222
    #2 in charset_utf8_to_utf8        charset-utf8.c:53
    #3 in charset_to_utf8_try         charset-iconv.c:64
    #4 in iconv_charset_to_utf8       charset-iconv.c:113
    #5 in translation_buf_decode      message-decoder.c:238
    #6 in message_decode_body         message-decoder.c:375
    #7 in message_decoder_decode_next_block  message-decoder.c:411
    #8 in LLVMFuzzerTestOneInput      fuzz-message-decoder.c:26

Look at that call chain. Eight functions deep from the entry point.

Each arrow in that chain is an edge the fuzzer had to discover. The fuzzer did not find this crash in one leap. It built toward it incrementally:

Run 1:   LLVMFuzzerTestOneInput → message_decoder_decode_next_block
         → new edges → input saved to corpus

Run N:   mutated input goes deeper
         → message_decode_body → new edges → saved

Run M:   mutated further
         → translation_buf_decode → new edges → saved

...eventually...

Run X:   reaches uni_utf8_char_bytes with exactly the right malformed UTF-8
         → crash

Every saved corpus file was a stepping stone. Every new edge discovered was a door that unlocked the next mutation cycle. Without edge coverage tracking, the fuzzer would have had no idea it was making progress. It would have wandered and probably never reached uni_utf8_char_bytes at all.

The 11396 inline 8-bit counters line at the top? That is libFuzzer’s equivalent of AFL++'s bitmap. 11,396 instrumented locations in this binary. Each one reporting back after every run.


How Coverage Grows Over Time

When you actually run AFL++, you see something like this on your screen:

total execs    :  1,200,000
corpus size    :  23
edges covered  :  843

That edges covered number is your coverage count. Every time a new edge fires that has never fired before, the count goes up by one.

Early in a run - fast growth

At the start, almost everything is new. The seed itself covers hundreds of edges. The first few mutations unlock dozens more. The corpus grows quickly. New paths are being drawn on the map constantly.

minute  1  →  200 edges
minute  5  →  450 edges
minute 10  →  700 edges
minute 20  →  843 edges

This is the exciting phase. The fuzzer is genuinely exploring territory nobody has been to yet.

Later - growth slows

hour 1  →  843 edges
hour 2  →  891 edges
hour 3  →  897 edges
hour 4  →  899 edges
hour 5  →  899 edges
hour 6  →  899 edges

New edges are getting harder to find. Then the number stops moving entirely.

This is a coverage plateau.


What a Coverage Plateau Means - And What To Do

A plateau does not mean the fuzzer is broken. It means the fuzzer has explored everything reachable from its current corpus.

Remember: AFL++ can only explore paths that exist in the code. If a function is only reachable when the input satisfies some condition the fuzzer has never managed to produce, that function stays dark. The fuzzer hits the same 899 edges every run because every mutation of every corpus file keeps landing in the same territory.

reachable with current corpus  →  899 edges  ← fuzzer found all of these
locked behind unsatisfied conditions  →  ???  ← fuzzer cannot get here yet

Three ways to break through a plateau

1. Better seeds

The most common cause of an early plateau is that no seed ever reached a major subsystem of the program. The fuzzer explored everything reachable from the front door, but three entire modules are only reachable through a different entrance.

Add a seed that manually reaches that subsystem. Coverage jumps immediately.

current seeds  →  only exercise the UTF-8 parser
add new seed   →  one that triggers the base64 decoder path
result         →  fuzzer now explores the entire base64 code

2. Dictionary

Some programs gate entire code paths behind magic values - specific byte sequences, keywords, or protocol markers they look for before doing any real work.

// Program checks for PNG header before doing any parsing
if (input[0] != 0x89 || input[1] != 0x50 ||
    input[2] != 0x4E || input[3] != 0x47) {
    return ERROR;  // not a PNG, reject immediately
}
// All the interesting parsing code lives past here

A fuzzer mutating random bytes will fail this check 99.9% of the time and never see the parsing logic. Tell AFL++ about the magic bytes and it will start using them in mutations:

# tokens.dict
"\x89\x50\x4E\x47"
"IHDR"
"IDAT"
"IEND"
afl-fuzz -i seeds/ -o output/ -x tokens.dict -- ./png_parser @@

Now the fuzzer has the keys to get past the front door.

3. Accept and analyze

Sometimes a plateau means you genuinely covered the program. It is small, well-tested, and 899 edges is most of what exists. At that point the right move is to stop adding seeds and start triaging whatever crashes the fuzzer already found.

Not every plateau is a problem. Some are a sign the job is done.


What a Practical Run Looks Like

Note: Full AFL++ installation - every command, every dependency, every error - is covered in Blog 6. For now, follow along conceptually. Understanding what is happening matters more than running it right now.

Here is what the workflow looks like end to end:

Step 1 - Write a target with multiple paths

#include <stdio.h>
#include <string.h>

void handle_input(char *buf, int len) {
    if (len > 10) {
        if (buf[0] == 'A') {
            if (buf[1] == 'B') {
                // deep path - this is where interesting bugs hide
                printf("deep path reached\n");
            }
        }
    }
}

int main() {
    char buf[256];
    int len = fread(buf, 1, sizeof(buf), stdin);
    handle_input(buf, len);
    return 0;
}

Step 2 - Compile with AFL++ instrumentation

afl-clang-fast target.c -o target

AFL++ will report how many locations it instrumented:

[+] Instrumented 8 locations (non-hardened mode)

Step 3 - Create a seed and run

mkdir seeds
echo "hi" > seeds/seed1.txt

afl-fuzz -i seeds/ -o output/ -- ./target @@

Step 4 - Watch coverage grow in real time

AFL++'s UI shows you live:

┌─ process timing ─────────────────────────────────────────────┐
│        run time : 0 days, 0 hrs, 5 min, 23 sec               │
├─ overall results ────────────────────────────────────────────┤
│         cycles  : 12                                         │
│    total execs  : 284,719                                    │
│   corpus count  : 7                                          │
│  saved crashes  : 0                                          │
├─ map coverage ───────────────────────────────────────────────┤
│    map density  : 0.01% / 0.01%                              │
│ count coverage  : 1.00 bits/tuple                            │
└──────────────────────────────────────────────────────────────┘

Early on, corpus count grows fast. New files being saved. New edges being discovered. Later it slows. Then it flatlines - that is your plateau.

At plateau, you look at what is still dark. Add seeds or a dictionary. Coverage climbs again.


What I Found Confusing (And Now Don’t)

“Coverage and corpus sound like the same thing. What is the difference?”

Coverage is the measurement - how many edges have been reached in total. The corpus is the collection of files that produced that coverage. Coverage is the number on the screen. Corpus is the folder of inputs that earned it.

“AFL++ says ‘map density 0.01%’. Is that bad?”

No. The bitmap has 65,536 slots. Most real programs only use a small fraction of those slots. Low map density just means the program is smaller than the bitmap. It does not mean coverage is low. Watch edges covered as your real number, not map density.

“If the fuzzer can’t read my code, how does it know which edges exist?”

It does not know in advance. It discovers them. Every time a new edge fires - a slot that was 0 becomes 1 - that is the discovery. The fuzzer builds a map of the program by exploring it, not by reading it. The bitmap starts empty. It fills up as the fuzzer finds new paths.

“My coverage stopped growing at 300 edges but I can see functions in the code that were never hit. Why?”

Because those functions are only reachable through a path the fuzzer has not found yet. Maybe they require a specific magic value at byte position 0. Maybe they only run after a specific sequence of operations. The fuzzer exhausted everything reachable from its current corpus. Those functions are locked behind a condition that needs a better seed or a dictionary hint to satisfy.

“If I run the fuzzer longer, will coverage always eventually reach 100%?”

No. Some paths are practically unreachable through mutation alone - they require inputs that satisfy multiple simultaneous constraints that random mutation will statistically never produce. This is exactly the problem white-box fuzzers and symbolic execution try to solve. AFL++ handles it partially through dictionaries and seed improvement. For the hardest cases, tools like Driller add constraint solving on top.


What We Learned

Code coverage - A measurement of which parts of a program actually executed during a run. Coverage is the core feedback signal in coverage-guided fuzzing. Higher coverage means more of the program has been explored and more potential bugs have been exposed.

Line coverage - The simplest form of coverage. Tracks whether a given line of code executed at least once. Misses bugs that only appear under specific combinations of conditions.

Branch coverage - Tracks whether both sides of every decision (the YES path and the NO path of every if) have been executed. Better than line coverage, but still checks each decision independently and misses combinations.

Edge coverage - Tracks transitions between locations in the code. An edge is a “came from here, went there” pair. Two paths that visit the same line but arrive from different places are different edges. This is what AFL++ tracks, because bugs live in combinations - not individual lines.

Bitmap - AFL++'s 65,536-slot array stored in shared memory. Each slot represents one edge. 0 means not yet reached. 1 means reached. After every run, AFL++ checks whether any slot changed from 0 to 1. If yes - new edge, interesting input, save to corpus. If no - nothing new, discard.

Shared memory - A memory region that both AFL++ and the target program can read and write simultaneously. The target writes to the bitmap as it runs. AFL++ reads the bitmap after the target exits. No file I/O. Nanosecond-speed feedback.

Instrumentation - The process of compiling a program with hidden markers at every branch point. These markers fire during execution and write to the bitmap. AFL++ uses afl-clang-fast to inject these markers at compile time. The result is a working program that also reports its own coverage.

Coverage plateau - When the edges covered count stops growing. Means the fuzzer has exhausted all paths reachable from its current corpus. Not a broken fuzzer - a signal that something is blocking progress.

Seed improvement - Adding new seed files that manually reach code areas the fuzzer never explored. The most direct way to break through a coverage plateau.

Dictionary - A file containing tokens (magic bytes, keywords, special values) that the fuzzer injects into mutations. Used when a program gates code paths behind specific values that random mutation is unlikely to produce.


→ Next: Blog 6 - AFL++ Installation: Full Setup Guide

← Previous: Blog 4 - What Happens Inside a Program When It Crashes

Part of the Complete Fuzzing Series on IoTSec.in