DEV Community

ohmygod
ohmygod

Posted on

Building a DeFi Exploit Detection Lab: Foundry Invariant Tests That Would Have Caught $100M in Hacks

Your smart contracts passed every unit test. All edge cases covered. Code coverage: 98%.

Then someone calls three functions in a sequence nobody imagined, and $40 million disappears.

Unit tests verify what you expect. Invariant tests find what you didn't.

This guide shows you how to build invariant tests modeled on real 2026 DeFi exploits — oracle manipulation, flash loan attacks, and access control failures — so your protocol doesn't become the next post-mortem.


Why Unit Tests Aren't Enough

Unit tests answer: "Does function X produce output Y for input Z?"

Invariant tests answer: "Does property P hold after any sequence of function calls?"

The difference matters because DeFi exploits rarely break a single function. They break assumptions between functions — calling borrow() after manipulating the oracle that borrow() depends on, or using flashLoan() to enter a state that withdraw() never expected.

// A unit test catches this:
function test_withdraw_reverts_if_insufficient_balance() public {
    vm.expectRevert("Insufficient balance");
    vault.withdraw(1 ether);
}

// But NOT this (requires specific call sequence):
// 1. deposit() → 2. manipulate oracle → 3. borrow() → 4. withdraw()
// The invariant "totalDeposits >= totalBorrows" breaks
Enter fullscreen mode Exit fullscreen mode

Let's build tests that catch real attack patterns.


Setup: The Exploit Detection Lab

forge init exploit-detection-lab
cd exploit-detection-lab
Enter fullscreen mode Exit fullscreen mode

Configure foundry.toml for thorough invariant testing:

[invariant]
runs = 512            # Number of random call sequences
depth = 50            # Calls per sequence (higher = finds deeper bugs)
fail_on_revert = false # Don't fail on expected reverts
dictionary_weight = 80 # Use storage values as fuzz inputs

[fuzz]
runs = 10000
Enter fullscreen mode Exit fullscreen mode

Pattern 1: Oracle Manipulation → Liquidation Cascade

Real-world reference: Aave's CAPO oracle desync (March 2026, $26M loss). The oracle's constrained rate diverged from reality, triggering false liquidations.

Vulnerable Contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SimpleLending {
    mapping(address => uint256) public deposits;
    mapping(address => uint256) public borrows;
    uint256 public collateralPrice; // Set by oracle
    uint256 public constant LTV = 75; // 75% loan-to-value

    address public oracle;

    constructor(address _oracle) {
        oracle = _oracle;
        collateralPrice = 1000e18; // $1000 initial
    }

    function updatePrice(uint256 _price) external {
        require(msg.sender == oracle, "Not oracle");
        collateralPrice = _price; // No bounds checking!
    }

    function deposit() external payable {
        deposits[msg.sender] += msg.value;
    }

    function borrow(uint256 amount) external {
        uint256 collateralValue = deposits[msg.sender] * collateralPrice / 1e18;
        uint256 maxBorrow = collateralValue * LTV / 100;
        require(borrows[msg.sender] + amount <= maxBorrow, "Exceeds LTV");
        borrows[msg.sender] += amount;
        payable(msg.sender).transfer(amount);
    }

    function liquidate(address user) external {
        uint256 collateralValue = deposits[user] * collateralPrice / 1e18;
        uint256 maxBorrow = collateralValue * LTV / 100;
        require(borrows[user] > maxBorrow, "Not liquidatable");

        // Liquidator gets collateral at discount
        uint256 seized = deposits[user];
        deposits[user] = 0;
        borrows[user] = 0;
        payable(msg.sender).transfer(seized);
    }

    receive() external payable {}
}
Enter fullscreen mode Exit fullscreen mode

The Invariant Test

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/SimpleLending.sol";

contract LendingHandler is Test {
    SimpleLending public lending;
    address[] public actors;

    // Ghost variables — track expected state
    uint256 public ghost_totalDeposits;
    uint256 public ghost_totalBorrows;
    uint256 public ghost_liquidationCount;
    mapping(address => uint256) public ghost_userDeposits;

    constructor(SimpleLending _lending) {
        lending = _lending;
        actors.push(address(0x1));
        actors.push(address(0x2));
        actors.push(address(0x3));

        // Fund actors
        for (uint i = 0; i < actors.length; i++) {
            vm.deal(actors[i], 100 ether);
        }
        vm.deal(address(lending), 1000 ether);
    }

    function deposit(uint256 actorSeed, uint256 amount) external {
        address actor = actors[actorSeed % actors.length];
        amount = bound(amount, 0.01 ether, 10 ether);

        vm.prank(actor);
        lending.deposit{value: amount}();

        ghost_totalDeposits += amount;
        ghost_userDeposits[actor] += amount;
    }

    function borrow(uint256 actorSeed, uint256 amount) external {
        address actor = actors[actorSeed % actors.length];
        amount = bound(amount, 0.001 ether, 5 ether);

        vm.prank(actor);
        try lending.borrow(amount) {
            ghost_totalBorrows += amount;
        } catch {}
    }

    function updatePrice(uint256 newPrice) external {
        // Simulate oracle updates — including malicious ones
        newPrice = bound(newPrice, 1e16, 100_000e18); // $0.01 to $100K

        vm.prank(lending.oracle());
        lending.updatePrice(newPrice);
    }

    function liquidate(uint256 actorSeed, address target) external {
        address actor = actors[actorSeed % actors.length];

        vm.prank(actor);
        try lending.liquidate(target) {
            ghost_liquidationCount++;
        } catch {}
    }
}

contract LendingInvariantTest is Test {
    SimpleLending public lending;
    LendingHandler public handler;
    address public oracle = address(0xCAFE);

    function setUp() public {
        lending = new SimpleLending(oracle);
        handler = new LendingHandler(lending);

        // Only target the handler
        targetContract(address(handler));
    }

    /// @notice No user should lose deposits to liquidation 
    /// if the oracle price hasn't moved more than 50% in one update
    function invariant_no_flash_crash_liquidation() public view {
        // If price swings > 50% in a single update, 
        // liquidations are likely unfair
        uint256 currentPrice = lending.collateralPrice();

        // This invariant will FAIL — proving the oracle needs 
        // rate-of-change limits (exactly what broke Aave's CAPO)
        if (handler.ghost_liquidationCount() > 0) {
            // At least one liquidation happened — 
            // check if it was from a reasonable price move
            assertTrue(
                currentPrice >= 500e18, // Price shouldn't crash below $500
                "Liquidations triggered by unreasonable oracle price"
            );
        }
    }

    /// @notice Protocol solvency: total deposits should cover obligations
    function invariant_protocol_solvency() public view {
        uint256 contractBalance = address(lending).balance;
        // The contract should always have enough to cover deposits
        // This catches extraction attacks
        assertTrue(
            contractBalance >= 0, // Simplified — real invariant would track all flows
            "Protocol is insolvent"
        );
    }

    /// @notice Ghost variable consistency check
    function invariant_deposit_tracking() public view {
        // Verify our ghost tracking matches contract state
        // If this fails, our handler has a bug (meta-testing)
        for (uint i = 0; i < 3; i++) {
            address actor = handler.actors(i);
            assertEq(
                lending.deposits(actor),
                handler.ghost_userDeposits(actor),
                "Ghost deposit mismatch"
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

What This Catches

Run it: forge test --match-contract LendingInvariantTest -vvv

The fuzzer will discover that calling updatePrice(1e16) (crashing price to $0.01) followed by liquidate() allows seizing deposits at a massive discount. The fix: rate-of-change limits on oracle updates, exactly what Aave's CAPO was supposed to enforce.

// Fix: Bounded oracle updates
function updatePrice(uint256 _price) external {
    require(msg.sender == oracle, "Not oracle");

    uint256 maxChange = collateralPrice * 10 / 100; // Max 10% per update
    require(
        _price >= collateralPrice - maxChange && 
        _price <= collateralPrice + maxChange,
        "Price change too large"
    );

    collateralPrice = _price;
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Flash Loan + Reentrancy Extraction

Real-world pattern: Dozens of protocols in 2025-2026 lost funds to flash loans that manipulate balances mid-transaction.

The Invariant

/// @notice Conservation of value: ETH in + borrows out = ETH out + deposits
function invariant_conservation_of_value() public view {
    uint256 contractETH = address(lending).balance;
    uint256 totalDeposited = handler.ghost_totalDeposits();
    uint256 totalBorrowed = handler.ghost_totalBorrows();

    // Contract balance should equal deposits minus borrows
    // If this breaks → funds are being extracted outside normal flows
    assertGe(
        contractETH + totalBorrowed,
        totalDeposited,
        "Value leak detected — possible extraction attack"
    );
}
Enter fullscreen mode Exit fullscreen mode

This invariant is deceptively powerful. Any flow that extracts value without going through the tracked deposit/borrow paths will trip it — including reentrancy, flash loan manipulation, or accounting errors.


Pattern 3: Access Control Escalation

Real-world reference: CrossCurve bridge hack (Feb 2026, $3M). Missing access control on expressExecute allowed anyone to call a privileged function.

Handler With Role-Based Actions

contract AccessControlHandler is Test {
    ProtocolWithRoles public protocol;

    address public admin = address(0xAD);
    address public user1 = address(0x1);
    address public attacker = address(0x666);

    // Track which roles have performed which actions
    mapping(bytes4 => bool) public adminOnlyFunctionsCalled;
    mapping(bytes4 => address) public lastCaller;

    function callAdminFunction(uint256 callerSeed) external {
        address caller = _randomCaller(callerSeed);

        vm.prank(caller);
        try protocol.adminOnlyFunction() {
            lastCaller[protocol.adminOnlyFunction.selector] = caller;
            if (caller != admin) {
                // This should NEVER succeed for non-admins
                // If it does, we found an access control bug
                emit log_named_address("ACCESS CONTROL BREACH by", caller);
            }
        } catch {}
    }

    function _randomCaller(uint256 seed) internal view returns (address) {
        uint256 idx = seed % 3;
        if (idx == 0) return admin;
        if (idx == 1) return user1;
        return attacker;
    }
}

contract AccessControlInvariantTest is Test {
    // ...

    /// @notice Only admin should successfully call admin functions
    function invariant_admin_only_functions() public view {
        address caller = handler.lastCaller(
            protocol.adminOnlyFunction.selector
        );
        assertTrue(
            caller == address(0) || caller == handler.admin(),
            "Non-admin called admin function!"
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 4: ERC-4626 Vault Share Inflation

Real-world pattern: The "donation attack" where an attacker inflates the share price of a vault by directly sending tokens, causing rounding errors that steal from future depositors.

contract VaultInvariantTest is Test {
    ERC4626Vault public vault;
    VaultHandler public handler;

    function setUp() public {
        vault = new ERC4626Vault(address(token));
        handler = new VaultHandler(vault, token);
        targetContract(address(handler));
    }

    /// @notice Depositing X assets should always mint > 0 shares
    /// (catches share inflation / first depositor attack)
    function invariant_no_zero_share_deposits() public view {
        if (vault.totalSupply() > 0 && vault.totalAssets() > 0) {
            // After initial deposits exist, a deposit of 1e18 
            // should always yield shares
            uint256 sharesFor1Token = vault.previewDeposit(1e18);
            assertTrue(
                sharesFor1Token > 0,
                "Share inflation detected: deposit yields 0 shares"
            );
        }
    }

    /// @notice No single withdrawal should drain more than deposited
    function invariant_no_profit_withdrawal() public view {
        for (uint i = 0; i < handler.actorCount(); i++) {
            address actor = handler.actors(i);
            uint256 currentValue = vault.previewRedeem(
                vault.balanceOf(actor)
            );
            uint256 totalDeposited = handler.ghost_deposited(actor);

            // Allow 0.1% tolerance for rounding
            assertLe(
                currentValue,
                totalDeposited * 1001 / 1000,
                "User can extract more than deposited — vault accounting broken"
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Invariant Checklist: 10 Properties Every DeFi Protocol Should Test

Copy this into your test suite. Adapt the specifics to your protocol.

// ═══════════════════════════════════════════════════════
// THE DEFI INVARIANT CHECKLIST
// ═══════════════════════════════════════════════════════

// 1. SOLVENCY: Contract balance >= sum of all user claims
function invariant_solvency() public view;

// 2. CONSERVATION: Total value in = total value out + held
function invariant_conservation() public view;

// 3. NO FREE MONEY: No user can withdraw more than deposited + earned
function invariant_no_free_money() public view;

// 4. ORACLE BOUNDS: Price changes within expected range per update
function invariant_oracle_bounded() public view;

// 5. ACCESS CONTROL: Privileged functions only callable by authorized roles
function invariant_access_control() public view;

// 6. NO ZERO SHARES: Deposits always mint > 0 shares (ERC-4626)
function invariant_no_zero_shares() public view;

// 7. MONOTONIC COUNTERS: Nonces, IDs, epochs only increase
function invariant_monotonic() public view;

// 8. SUPPLY CONSERVATION: Token totalSupply = sum of all balances
function invariant_supply_conservation() public view;

// 9. LIQUIDATION FAIRNESS: Liquidations only when truly undercollateralized
function invariant_fair_liquidation() public view;

// 10. REENTRANCY GUARD: No nested calls to protected functions
function invariant_no_reentrancy() public view;
Enter fullscreen mode Exit fullscreen mode

Pro Tips: Making Invariant Tests Actually Find Bugs

1. Use Ghost Variables Religiously

Ghost variables track expected state outside the contract. When they diverge from on-chain state, you've found a bug:

// In handler:
uint256 public ghost_totalDeposited;

function deposit(uint256 amount) external {
    amount = bound(amount, 1e15, 100e18);
    token.mint(actor, amount);
    vm.prank(actor);
    vault.deposit(amount, actor);
    ghost_totalDeposited += amount; // Track independently
}

// In invariant test:
function invariant_ghost_matches_reality() public view {
    assertEq(
        vault.totalAssets(),
        handler.ghost_totalDeposited() - handler.ghost_totalWithdrawn(),
        "Accounting mismatch — ghost vs reality"
    );
}
Enter fullscreen mode Exit fullscreen mode

2. Bound Inputs, Don't Exclude Them

Don't skip edge cases — bound them to meaningful ranges:

// BAD: Skips small amounts entirely
function deposit(uint256 amount) external {
    vm.assume(amount > 1e18); // Misses dust attacks!
    ...
}

// GOOD: Includes small amounts that might trigger rounding bugs
function deposit(uint256 amount) external {
    amount = bound(amount, 1, type(uint128).max);
    ...
}
Enter fullscreen mode Exit fullscreen mode

3. Include "Attacker" Actions in Your Handler

Model malicious behavior explicitly:

function attackerDirectTransfer(uint256 amount) external {
    // Simulate donation attack (direct token transfer to vault)
    amount = bound(amount, 1, 1000e18);
    token.mint(address(this), amount);
    token.transfer(address(vault), amount);
    ghost_donated += amount;
}
Enter fullscreen mode Exit fullscreen mode

4. Increase Depth for Sequence-Dependent Bugs

Some bugs only appear after 20+ function calls. Default depth of 15 misses them:

# foundry.toml — go deeper for complex protocols
[invariant]
runs = 256
depth = 100  # Find deep sequence bugs
Enter fullscreen mode Exit fullscreen mode

5. Use afterInvariant() for End-of-Campaign Checks

function afterInvariant() public {
    // Final check: can all users withdraw their full balance?
    for (uint i = 0; i < handler.actorCount(); i++) {
        address actor = handler.actors(i);
        uint256 shares = vault.balanceOf(actor);
        if (shares > 0) {
            vm.prank(actor);
            try vault.redeem(shares, actor, actor) {} 
            catch {
                emit log_named_address(
                    "USER CANNOT WITHDRAW", actor
                );
                fail();
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Running Your Exploit Detection Lab

# Standard run
forge test --match-contract InvariantTest -vvv

# Maximum thoroughness (CI/pre-audit)
forge test --match-contract InvariantTest \
  -vvv \
  --fuzz-runs 10000 \
  --invariant-runs 1024 \
  --invariant-depth 100

# Quick smoke test (pre-commit hook)
forge test --match-contract InvariantTest \
  --invariant-runs 64 \
  --invariant-depth 25
Enter fullscreen mode Exit fullscreen mode

When Foundry finds a violation, it prints the exact call sequence:

[FAIL: Assertion violated]
        [Sequence]
            sender=0x666 addr=[handler] calldata=updatePrice(1000000000000000)
            sender=0x1   addr=[handler] calldata=borrow(49999999999999999999)
            sender=0x666 addr=[handler] calldata=liquidate(0, 0x1)

        [Counterexample]
            invariant_no_flash_crash_liquidation(): Liquidations triggered by unreasonable oracle price
Enter fullscreen mode Exit fullscreen mode

That sequence is your exploit proof-of-concept. Turn it into a unit test, fix the code, and verify the invariant holds.


Conclusion: Test Properties, Not Paths

The protocols that lost $100M+ in 2026 all had unit tests. Many had audits. What they didn't have were invariant tests that modeled adversarial behavior.

The core insight: you can't enumerate all attack paths, but you can define the properties that must never break.

  • "The protocol is always solvent" catches extraction attacks you never imagined
  • "Oracle prices change within bounds" catches manipulation you didn't model
  • "No user profits without providing value" catches economic exploits across any call sequence

Start with the 10-property checklist above. Add protocol-specific invariants. Run them in CI. The bugs Foundry finds at 3 AM are the bugs that would have cost you millions at mainnet.


This article is part of the DeFi Security Research series. Follow for weekly breakdowns of real incidents, audit techniques, and defense patterns.

DreamWork Security — dreamworksecurity.hashnode.dev

Top comments (0)