2026-03-28·16 min read

Coverage-guided fuzzing deep dive: corpus management, seeds, and convergence

Coverage-Guided Fuzzing Deep Dive: Corpus Management, Seeds, and Convergence

Author: antonio | Deep-Dive

Most people know coverage-guided fuzzing finds bugs. Fewer understand how. If you've ever wondered why your fuzzer found a critical bug in 30 seconds or ran for 6 hours with nothing — this is the post that explains it. We're going deep into corpus management, seed strategies, and knowing when your campaign has converged.

How coverage tracking actually works

Every coverage-guided fuzzer does the same core thing: it runs an input, measures which code paths executed, and keeps inputs that hit new paths. Simple concept. The devil's in the implementation.

Coverage metrics: not all equal

There are three common coverage metrics, and they give you very different signals.

Line coverage tracks which lines of source code executed. It's the crudest metric. If your contract has:

function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount, "insufficient"); // line 1
    balances[msg.sender] -= amount;                          // line 2
    (bool ok, ) = msg.sender.call{value: amount}("");        // line 3
    require(ok, "transfer failed");                          // line 4
}

Line coverage says "we hit all 4 lines" once any valid withdrawal executes. It won't distinguish between a 1 wei withdrawal and a max-balance withdrawal. That's a problem.

Branch coverage is better. It tracks which conditional branches were taken. For the code above, it cares about:

  • Did require on line 1 pass AND fail?
  • Did require on line 4 pass AND fail?

Now the fuzzer has a reason to generate inputs that trigger the failure paths.

Path coverage tracks the exact sequence of branches taken through a function. A function with 10 branches has up to 2^10 = 1024 paths. This explodes fast but catches subtle state-dependent bugs that branch coverage misses.

In practice, EVM fuzzers like Echidna and Medusa primarily use branch-level coverage with some path sensitivity. Here's why: pure path coverage on a Solidity contract with multiple external calls produces an astronomical path space. The fuzzer would spend all its time bookkeeping instead of actually fuzzing.

How the fuzzer tracks it

Under the hood, most fuzzers instrument the code at compile time. For EVM contracts, that means:

  1. The compiler emits coverage probes at branch points
  2. Each probe writes to a shared coverage bitmap
  3. After each execution, the fuzzer checks if any new bits flipped
  4. If yes (new coverage found), save this input to the corpus
Execution Flow:
                                    ┌─────────────┐
   Input ──────> Execute Contract ──> Read Bitmap ──> New bits?
                                    └─────────────┘
                                           │
                                     Yes ──┤── No
                                     │          │
                               Save to      Discard
                               corpus       input

The bitmap is typically 64KB. Each branch point hashes to a position in the bitmap. Collisions happen (two different branches can map to the same bit), but in practice it works well enough for contracts under 10K lines.

Corpus management: the heart of coverage-guided fuzzing

Your corpus is your fuzzer's memory. Every input that discovered new coverage gets saved. Over time, this corpus becomes a curated collection of "interesting" inputs that together cover the reachable code.

Corpus growth curve

A healthy fuzzing campaign shows a characteristic growth pattern:

Coverage %
100 ┤
    │                         ┌──────────────── plateau
 80 ┤                    ┌────┘
    │               ┌────┘
 60 ┤          ┌────┘
    │     ┌────┘
 40 ┤  ┌──┘
    │ ┌┘
 20 ┤┌┘  <── rapid discovery phase
    ││
  0 ┤└───┬───┬───┬───┬───┬───┬───┬───┬───
    0   1h  2h  3h  4h  5h  6h  7h  8h
                    Time

Three phases:

  1. Rapid discovery (first minutes to ~1 hour): The fuzzer hits all the easy branches. Coverage climbs steeply.
  2. Diminishing returns (~1-4 hours): Each new corpus entry takes longer to find. The fuzzer is working harder for smaller gains.
  3. Plateau (4+ hours): Coverage barely moves. Either the fuzzer can't crack the remaining branches, or they're truly unreachable.

If your campaign never leaves phase 1, your harness is probably too simple. If it jumps straight to plateau at 30% coverage, your harness has configuration problems. The fuzzer can't even reach most of the code.

Corpus minimization

After a long campaign, your corpus might have 50,000 entries. Many are redundant, covering the same branches as other entries. Corpus minimization keeps only the minimum set of inputs needed to maintain the same total coverage.

Why minimize? Two reasons:

  1. Faster startup. When you resume a campaign or start a new one with the same corpus, fewer seeds means faster initial replay.
  2. Better mutation. Fuzzers mutate corpus entries to generate new inputs. A lean corpus means mutations are more likely to produce something useful.

Medusa handles this automatically. With Echidna, you can manage it through corpus replay settings. If you're rolling your own setup:

# Example: minimizing a corpus directory
# Keep only inputs that contribute unique coverage
medusa fuzz --corpus-dir ./corpus --minimize

Corpus distillation

This is a trick that experienced fuzzers use. Take a corpus from one tool and feed it to another.

 Echidna corpus ──> Convert format ──> Medusa seeds
                                          │
 Medusa corpus ──> Convert format ──> Echidna seeds

Each fuzzer has different mutation strategies. Echidna's transaction-aware mutations and Medusa's coverage-guided approach complement each other. A call sequence that Echidna found but Medusa wouldn't have (and vice versa) can jump-start new coverage in the other tool.

For a practical comparison of how these tools differ, check Echidna vs Medusa.

Seed selection strategies

Seeds are the starting inputs for your fuzzer. Good seeds dramatically accelerate coverage. Bad seeds waste time.

Manual seeds from deployment scripts

Your best seeds often come from your own deployment and test scripts. If you have a Foundry test that sets up a lending protocol with markets, depositors, and borrowers, that final state is an excellent seed.

// This setup function creates a state the fuzzer can start from
function setUp() public {
    // Deploy core contracts
    token = new MockERC20("USDC", "USDC", 6);
    vault = new Vault(address(token));

    // Seed with realistic state
    token.mint(address(this), 1_000_000e6);
    token.approve(address(vault), type(uint256).max);
    vault.deposit(500_000e6, address(this));

    // Now the fuzzer starts with a vault that has liquidity,
    // an active depositor, and approval state already set up
}

Without this setup, the fuzzer has to randomly discover that it needs to mint, then approve, then deposit before it can even start testing withdrawal logic. That can take hours of wasted cycles.

Dictionary extraction

Smart fuzzers extract "interesting" constants from the contract bytecode. Magic numbers, boundary values, storage slot keys. You can help by adding to the dictionary:

// These constants help the fuzzer find edge cases
uint256 constant MAX_BPS = 10000;
uint256 constant PRECISION = 1e18;
uint256 constant MAX_UINT = type(uint256).max;
uint256 constant BLOCK_TIME = 12;
// Common time values the fuzzer should try
uint256 constant ONE_DAY = 86400;
uint256 constant ONE_YEAR = 365 days;

Targeted seeds for complex state machines

For protocols with complex state transitions (governance proposals, multi-sig operations, timelocked upgrades), you need seeds that put the system into specific states.

// Seed: system in "emergency shutdown" mode
function seed_emergencyState() internal {
    // Trigger conditions that lead to emergency
    oracle.setPrice(0); // Oracle failure
    vault.checkHealth();  // Triggers emergency flag

    // NOW the fuzzer tests what happens in emergency mode
    // Can users still withdraw? Can admin functions be called?
}

Convergence detection: when to stop

This is the question everyone asks: "How long should I run the fuzzer?" The honest answer is that it depends. But there are concrete signals.

Coverage plateau detection

Track coverage over time. If coverage hasn't increased in the last N minutes, you're probably at the plateau.

Practical thresholds:
- Simple contracts (< 200 LOC): 15 min with no new coverage → likely converged
- Medium contracts (200-1000 LOC): 1 hour no new coverage → likely converged
- Complex protocols (1000+ LOC): 4 hours no new coverage → likely converged

But "converged" doesn't mean "done." It means this harness with this configuration has found everything it can. You might need to:

  • Add new handler functions to your harness
  • Improve your seeds
  • Adjust call sequence length
  • Add ghost variables to track more state

Corpus velocity

Monitor how fast new corpus entries appear. Plot entries/hour over time:

Entries/hour
200 ┤██
    │██
150 ┤██
    │████
100 ┤████
    │██████
 50 ┤████████
    │████████████
  0 ┤████████████████████████████████
    0h  1h  2h  3h  4h  5h  6h  7h

When corpus velocity drops below ~1 entry/hour, you're in plateau territory. This is a more reliable signal than raw coverage percentage because it accounts for the rate of discovery, not just absolute coverage.

Mutation effectiveness

Advanced metric: track what percentage of mutations produce new coverage.

Mutation effectiveness = new_coverage_mutations / total_mutations * 100

> 5%  : Still discovering fast, keep going
1-5%  : Slowing down, consider improving harness
< 1%  : Diminishing returns, probably time to stop or restructure
< 0.1%: Definitely converged for this configuration

Practical tips for better coverage

1. Start with small, focused harnesses

Don't fuzz your entire protocol in one campaign. Break it into components:

// Harness 1: Just the vault deposit/withdraw logic
contract VaultHarness is BaseHarness {
    function handler_deposit(uint256 amount) external {
        amount = bound(amount, 1, token.balanceOf(address(this)));
        vault.deposit(amount, address(this));
    }

    function handler_withdraw(uint256 amount) external {
        amount = bound(amount, 1, vault.maxWithdraw(address(this)));
        vault.withdraw(amount, address(this), address(this));
    }
}

// Harness 2: Oracle + liquidation interactions
// Harness 3: Governance + parameter updates

Smaller harnesses converge faster and find bugs earlier. You can combine them later for integration testing.

2. Use coverage reports to find gaps

After a campaign, look at the coverage report. Red (uncovered) lines tell you exactly what the fuzzer couldn't reach.

Common reasons for uncovered code:

  • Missing handler functions: the fuzzer literally can't call that code path
  • Unreachable preconditions: a require that the fuzzer can't satisfy
  • Time-dependent logic: the fuzzer isn't warping timestamps
  • Access control: only owner can call it, and the fuzzer isn't impersonating owner

Fix each gap, re-run, and watch coverage climb.

3. Parallelize campaigns

Run multiple campaigns simultaneously with different configurations:

# Campaign 1: Short sequences, high throughput
medusa fuzz --seq-len 10 --workers 4 --corpus-dir ./corpus-short &

# Campaign 2: Long sequences, fewer workers
medusa fuzz --seq-len 200 --workers 2 --corpus-dir ./corpus-long &

# Campaign 3: Targeted at specific functions
medusa fuzz --target-func "withdraw,liquidate" --corpus-dir ./corpus-targeted &

Merge the corpora periodically. Short-sequence campaigns find shallow bugs fast; long-sequence campaigns find deep state-dependent bugs.

4. Monitor and adapt

Don't fire-and-forget. Check in on your campaigns:

Every 30 minutes:
  ├── Check coverage growth → stalled? Change config
  ├── Check corpus velocity → dropping? Add seeds
  ├── Check property violations → found one? Investigate immediately
  └── Check resource usage → OOM? Reduce workers

Bringing it together

Coverage-guided fuzzing is a feedback loop: generate inputs, measure coverage, keep what's interesting, mutate, repeat. The quality of your results depends entirely on how well you manage this loop.

Start with good seeds from your deployment scripts. Use focused harnesses that give the fuzzer a clear attack surface. Monitor convergence so you don't waste compute. And when coverage plateaus, improve the harness — don't just run longer.

For more on setting up your first fuzzing campaign, head to the fuzzing fundamentals guide. If you want to understand how coverage-guided fuzzing fits into the bigger picture of coverage-guided approaches, we've got you covered there too.

And if you're choosing between fuzzing tools, the Echidna vs Medusa comparison breaks down exactly how each tool handles the corpus management strategies we discussed here.

Request a Security Review

Try Recon Pro

Related Posts

Related Glossary Terms

Get expert fuzzing with maximum coverage