Smart Contract Fuzzing Tools: How to Break Your Code Before Attackers Do
TL;DR: Fuzzing finds bugs by throwing thousands of random inputs at your code until something breaks. It’s how I’ve caught vulnerabilities that passed manual review and formal verification. Here’s how to actually use fuzzing tools to break your smart contracts before attackers do.
Most developers test the happy path. They write tests that verify functionality works when users behave correctly. Then they deploy to mainnet — and attackers immediately start testing the unhappy paths.
Fuzzing flips the script. Instead of testing what *should* work, you test what *shouldn’t* break. You bombard your contract with random, malicious, and nonsensical inputs until you find edge cases that cause failures.
After reviewing 200+ audits, I can tell you: the bugs that cost millions are almost always edge cases no one thought to test manually.
Why Manual Testing Isn’t Enough
Let’s say you have a simple lending function:
function borrow(uint256 amount) external {
require(collateral[msg.sender] >= amount * 150 / 100, "Insufficient collateral");
balanceOf[msg.sender] += amount;
totalBorrowed += amount;
token.transfer(msg.sender, amount);
}
You write tests:
- Can user borrow with sufficient collateral? ✅
- Does it revert with insufficient collateral? ✅
- Does totalBorrowed update correctly? ✅
All tests pass. Ship it.
Then someone calls borrow(type(uint256).max). The multiplication overflows. The collateral check passes because the result wraps to zero. They drain the entire pool.
A fuzzer would have found that in seconds. It automatically tests boundary values: 0, 1, max, max-1. It tries integer overflows, underflows, and every arithmetic edge case.
This isn’t theoretical — integer overflow/underflow vulnerabilities have cost hundreds of millions. The batchOverflow bug in BEC token (2018) let attackers mint unlimited tokens through exactly this pattern.
How Fuzzing Actually Works
Traditional fuzzing (from web2 security):
- Generate random input
- Feed it to the target function
- Watch for crashes, errors, or unexpected behavior
- Repeat thousands of times
Smart contract fuzzing is similar but adapted for the EVM:
- Stateful fuzzing: Multiple transactions in sequence, maintaining state between calls
- Invariant testing: Define properties that must always be true, fuzz until you break them
- Coverage-guided: Prioritize inputs that explore new code paths
- Mutation-based: Start with valid inputs, then mutate them to find edge cases
The goal isn’t just to break things randomly — it’s to systematically explore the state space and find violations of your security assumptions.
Foundry Fuzzing (What I Actually Use)
Foundry is my primary tool for blockchain security work. Its fuzzing capabilities are built-in and powerful. Here’s a real example:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/LendingPool.sol";
contract LendingPoolFuzzTest is Test {
LendingPool pool;
function setUp() public {
pool = new LendingPool();
}
// Foundry automatically runs this with random uint256 values
function testFuzz_CannotBorrowMoreThanCollateral(uint256 borrowAmount) public {
// Set up collateral
uint256 collateral = 100 ether;
pool.depositCollateral{value: collateral}();
// Try to borrow
if (borrowAmount > collateral * 100 / 150) {
// Should revert for amounts exceeding collateralization ratio
vm.expectRevert("Insufficient collateral");
}
pool.borrow(borrowAmount);
// Invariant: borrowed amount never exceeds 66% of collateral
assert(pool.borrowed(address(this)) <= collateral * 2 / 3);
}
// Invariant: total borrowed can never exceed total deposited
function invariant_solvency() public {
assert(pool.totalBorrowed() <= pool.totalDeposited());
}
}
Run forge test --fuzz-runs 10000 and Foundry will:
- Generate 10,000 random values for
borrowAmount - Test every scenario automatically
- Report which inputs caused failures
- Give you exact reproduction steps
When I find a bug through fuzzing, Foundry tells me: "This specific input (0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff) caused invariant violation at line 23." Then I can replay that exact scenario and debug it.
Echidna: Property-Based Testing on Steroids
Echidna takes fuzzing further. It's specifically designed for smart contract security and uses more sophisticated techniques:
- Symbolic execution: Explores multiple execution paths simultaneously
- Smart seed generation: Learns from previous runs to generate better test cases
- Coverage optimization: Prioritizes inputs that hit unexplored code
Example Echidna test:
contract EchidnaLendingTest {
LendingPool pool;
constructor() {
pool = new LendingPool();
}
// Echidna will try to make this return false
function echidna_total_solvency() public returns (bool) {
return pool.totalBorrowed() <= pool.totalDeposited();
}
// Echidna will try to make this return false
function echidna_no_undercollateralized_loans() public returns (bool) {
uint256 borrowed = pool.borrowed(address(this));
uint256 collateral = pool.collateral(address(this));
return borrowed <= collateral * 2 / 3;
}
}
Echidna runs for hours or days, generating millions of test transactions. If it finds a way to make your invariant return false, it gives you the exact sequence of calls that broke it.
I've used Echidna to find bugs in protocols that had been live for months. The bugs were always there — no human had tested the specific sequence of 8 transactions that triggered the failure.
Real Vulnerabilities Found Through Fuzzing
1. Precision Loss in DeFi Math
A lending protocol I audited had this calculation:
uint256 interest = (principal * rate * time) / (365 days * PRECISION);
Looks fine in manual review. But fuzzing with extreme values (tiny principal, short time periods) revealed you could borrow for free due to rounding down to zero.
An attacker could make 1,000 micro-loans that each round to zero interest, extract value, repay principle, repeat. Over time, drain the pool.
2. Reentrancy Through Token Callbacks
The Cream Finance exploit ($18M) happened because ERC-777 tokens have callbacks. Fuzzing with different token implementations would have caught this:
function testFuzz_NoReentrancyWithDifferentTokens(address tokenAddress) public {
// Test with different token implementations
vm.assume(tokenAddress != address(0));
MockToken token = MockToken(tokenAddress);
pool.setToken(token);
// Try to exploit
MaliciousCallback attacker = new MaliciousCallback(pool);
// Should not allow reentrancy regardless of token type
vm.expectRevert();
attacker.attack();
}
Fuzzing different token addresses (including malicious ones with callbacks) surfaces reentrancy risks that basic testing misses.
3. Flash Loan Attack Vectors
Stateful fuzzing can discover multi-step exploits. Something like:
- Flash loan $10M
- Deposit as collateral
- Borrow against it
- Manipulate price oracle
- Withdraw "undercollateralized" (but system thinks it's fine)
- Repay flash loan, keep profit
No single function is vulnerable. It's the sequence that breaks invariants. Echidna can find these multi-transaction exploits automatically if you've defined the right invariants.
My Fuzzing Workflow
Here's how I actually use fuzzing in audits:
Step 1: Define Invariants
Before writing a single test, list what must always be true:
- Protocol can never be insolvent (deposits >= withdrawals)
- User balances never exceed total supply
- Collateralization ratio always >= 150%
- No user can access another user's funds
- Protocol fees can only decrease, never increase user costs unexpectedly
These become your fuzz targets. If fuzzing can break an invariant, you've found a bug.
Step 2: Quick Foundry Fuzzing (Hours)
Start with Foundry because it's fast:
forge test --fuzz-runs 50000 --fuzz-max-test-rejects 100000
This catches obvious issues: overflows, underflows, basic logic errors.
Step 3: Deep Echidna Campaign (Days)
For critical protocols, run Echidna overnight or for multiple days:
echidna-test . --contract EchidnaTest --test-mode assertion --test-limit 10000000
10 million test cases. Covers edge cases Foundry would never find.
Step 4: Differential Fuzzing
If you're upgrading or forking an existing protocol, fuzz both versions with identical inputs and compare outputs:
function testFuzz_MatchesOriginalBehavior(uint256 amount, address user) public {
uint256 resultOld = oldImplementation.calculate(amount, user);
uint256 resultNew = newImplementation.calculate(amount, user);
assert(resultOld == resultNew);
}
Any divergence is either an intentional change (document it) or a bug (fix it).
Common Fuzzing Mistakes
1. Not Enough Runs
Default Foundry fuzzing is 256 runs. That's nothing. Bump it to at least 10,000 for serious testing. For critical contracts, 100,000+.
2. Weak Invariants
"Balance should be positive" is a weak invariant. "User balance can never exceed their deposits plus earned interest" is strong. The more specific your invariants, the more useful fuzzing becomes.
3. Ignoring Reverts
Just because a function reverts doesn't mean it's safe. Check *why* it reverted. If it's reverting due to an assert failure in library code, that could be exploitable.
4. Not Testing Across Block Boundaries
Many bugs only appear when time passes or when transactions span multiple blocks. Use vm.warp() and vm.roll() to fuzz time-dependent logic.
The Tools You Need
- Foundry: Start here. Fast, integrated with Solidity testing, easy to use
- Echidna: For deep, long-running campaigns on critical code
- Medusa: Newer alternative to Echidna with better performance
- Mythril: Includes fuzzing alongside symbolic execution
- Diligence Fuzzing (ConsenSys): Hosted fuzzing service with continuous monitoring
My recommendation: Master Foundry first. It handles 90% of cases. Add Echidna for high-value contracts or when Foundry doesn't find anything but you still don't trust the code.
The Bottom Line
Fuzzing won't find every bug. It won't catch business logic errors or economic attacks that require understanding DeFi mechanics. But it will find the stupid, embarrassing bugs that cost millions: integer overflows, reentrancy, precision loss, broken invariants.
After 27 years in security, here's what I know: the best time to find a bug is before deployment. The second best time is during an internal fuzz campaign. The worst time is when you see "$20M EXPLOIT" trending on Twitter.
Fuzz your contracts. Fuzz them hard. Fuzz them until you're confident an attacker with infinite time and creativity can't break them. Then deploy.
Because in DeFi, you only get one chance to be secure. There are no patches after a $50M exploit.
Stay safe out there.
Learn more about blockchain security tools and techniques at blockchainwhitehackers.com
Disclaimer: This article was researched and written by members of BWH Academy, with AI-assisted research and drafting. While we strive for accuracy, details may slightly differ from exact real-world scenarios. All content is provided for educational and learning purposes only — not as professional security advice.
No Comments