2025-02-24·12 min read

How We Found Real Vulnerabilities with Fuzzing

By Antonio · Security researcher

How we found real vulnerabilities with fuzzing

Over the past two years, Recon has found critical vulnerabilities in some of DeFi's most important protocols using invariant testing and fuzzing. In this post, we'll walk through real findings and the properties that caught them.

Finding #1: insolvency in a major lending protocol

Severity: Critical Impact: Complete protocol insolvency

The protocol had a vault system where users deposit assets and receive shares. The core invariant should be simple:

totalAssets >= totalShares * pricePerShare

Our fuzzer found a sequence where:

  1. User A deposits a large amount
  2. Rewards are distributed
  3. User B deposits a small amount
  4. User A withdraws
  5. The protocol now owes more than it has

The root cause was a rounding direction error in the share calculation during step 3. When rewards had just been distributed, the price per share increased, but new deposits were rounding in the wrong direction, effectively giving new depositors slightly more shares than they deserved.

The property that caught it:

function invariant_solvency() public returns (bool) {
    return vault.totalAssets() >= vault.convertToAssets(vault.totalSupply());
}

Finding #2: rounding-based cap bypass

Severity: Medium Impact: Bypass of protocol-enforced limits

A protocol had a deposit cap to limit total exposure. The cap was checked against the total deposited amount. However, the conversion between shares and assets introduced small rounding errors.

Our fuzzer discovered that by making many small deposits, a user could accumulate slightly more actual exposure than the cap allowed. While each individual rounding error was tiny (1 wei), across thousands of operations, the cumulative bypass was significant.

The property that caught it:

function invariant_capEnforced() public returns (bool) {
    return vault.totalAssets() <= vault.depositCap();
}

Finding #3: permanent DoS through overflow

Severity: High Impact: Permanent denial of service

In a staking contract, a cumulative reward tracker used uint128 for storage. Our fuzzer found that after a specific sequence of stake/unstake operations with large values, the reward tracker could overflow, permanently bricking the contract.

The interesting aspect: the individual operations all used reasonable values. It was the specific sequence and timing that triggered the overflow. This is exactly the kind of bug that unit tests miss.

The property that caught it:

function invariant_canAlwaysUnstake() public returns (bool) {
    // Try unstaking for each actor - should never revert
    for (uint i = 0; i < actors.length; i++) {
        uint balance = staking.balanceOf(actors[i]);
        if (balance > 0) {
            try staking.unstake(balance) {} catch {
                return false;
            }
        }
    }
    return true;
}

Finding #4: reward distribution desynchronization

Severity: High Impact: Unfair reward distribution across stakers

In a staking protocol, rewards were distributed proportionally based on each user's share of the total stake at the time of distribution. The mechanism seemed straightforward: when rewards arrive, iterate through stakers and allocate based on their percentage of totalStaked.

Our fuzzer discovered that deposits made in the same block as a reward distribution could receive a portion of rewards they hadn't earned. The sequence was:

  1. Reward distribution is triggered with 1000 tokens to distribute
  2. In the same transaction batch, User C deposits a large stake
  3. The reward calculation uses the updated totalStaked (which now includes User C's deposit) but distributes the full 1000 tokens
  4. User C receives rewards proportional to their share, despite having staked zero seconds

The property that caught it:

function invariant_rewardAccounting() public returns (bool) {
    uint256 totalDistributed = staking.cumulativeRewardsDistributed();
    uint256 totalEarned = 0;
    for (uint256 i = 0; i < actors.length; i++) {
        totalEarned += staking.earned(actors[i]);
    }
    return totalDistributed >= totalEarned;
}

The root cause was an ordering issue in state updates. The contract updated totalStaked with the new deposit before calculating each user's reward share. The fix was to snapshot totalStaked at the beginning of the distribution and use that snapshot for all share calculations, regardless of deposits or withdrawals that occur in the same block.

Writing effective properties

The properties that catch real bugs tend to be:

  1. Simple and fundamental. "The protocol is solvent" catches more bugs than complex properties about specific edge cases.
  2. Focused on accounting. Most DeFi bugs are accounting bugs. Track that assets = liabilities.
  3. Tested against withdrawal. The ultimate test is whether all users can withdraw their fair share.
  4. State-aware. Check properties that span multiple transactions, not just single operations.

From violation to fix

Finding a property violation is only the first step. What matters is turning that violation into a fix and making sure it sticks.

When a fuzzer breaks a property, it gives you a reproduction sequence — the exact list of function calls, in order, with the exact arguments that triggered the failure. This sequence is the most useful debugging artifact you can have. It's a concrete proof that the bug exists, not a theoretical concern.

The first thing we do is convert this sequence into a standalone unit test. This gives you a fast, deterministic reproduction that you can run repeatedly while debugging. You step through the sequence call by call, inspecting state after each step, until you find the exact point where the invariant breaks. The root cause is usually one of a few patterns: rounding in the wrong direction, reading stale state that should have been updated first, an off-by-one in a loop or index, or a missing check that allows an unexpected state transition.

Once you identify the root cause, you apply the fix and re-run the fuzzing campaign. The property that originally broke should now hold across millions of new sequences. But you also need to verify that your fix didn't break other properties. A common mistake is fixing a rounding error by adding a check that inadvertently blocks legitimate operations. Running the full property suite after every fix catches these regressions immediately.

The property itself stays in the test suite permanently. It becomes a guard against the entire class of bug it represents, not just the specific instance the fuzzer found. If a future code change reintroduces the same pattern, the property will catch it again. This is how invariant testing compounds in value over time — every finding adds a new permanent check to your security infrastructure.

Tools we use

At Recon, we use our Chimera framework to write tests compatible with Echidna, Medusa, and Foundry simultaneously. This lets us use each fuzzer's strengths:

  • Echidna for thorough corpus-based exploration
  • Medusa for fast parallel fuzzing
  • Foundry for quick iteration during development

We run these in the cloud using Recon Pro for maximum computational power, often running campaigns of millions of test sequences.

When to start fuzzing

A common mistake is treating fuzzing as something you do after the code is "finished." In practice, the best time to start fuzzing is as soon as your core logic compiles.

Early fuzzing catches architectural issues (problems with how contracts interact, how state flows between components, how accounting is tracked) before those patterns are baked into the entire codebase. Fixing an architectural flaw in week two of development costs hours. Fixing it after a full audit costs weeks and potentially a redeployment.

Fuzzing during active development also provides faster feedback than post-audit fuzzing. When you write a new feature, you can add a property and run a short fuzzing campaign immediately. If the property breaks, you fix it while the logic is fresh in your mind. This is dramatically more efficient than finding the same bug three months later in an audit report.

For teams that want continuous protection, CI integration is the way to go. Run short fuzzing campaigns (5 to 10 minutes) on every pull request to catch obvious regressions. Run longer campaigns (1 to 8 hours) nightly or weekly to explore deeper state-dependent interactions. This turns fuzzing from a one-time event into an ongoing part of your development process.

Conclusion

Fuzzing isn't theoretical. It finds real bugs that manual review and unit testing miss. If you're building a DeFi protocol, invariant testing should be a core part of your security process, not an afterthought.

Ready to find bugs before attackers do? Request an audit with Recon.

Related Posts

Related Glossary Terms

Need help securing your protocol?