2026-01-12·16 min read

Reentrancy in 2025: read-only, cross-function, and cross-contract patterns

Reentrancy in 2025: Read-Only, Cross-Function, and Cross-Contract Patterns

By antonio — Security Researcher at Recon

If you think reentrancy is a solved problem, you're not paying attention. The classic ETH withdrawal bug? Sure, most devs know about that one. But the patterns showing up in real exploits today look nothing like the textbook withdraw() example. They're subtler, harder to spot in review, and they slip right past simple reentrancy guards.

Let's walk through the modern reentrancy patterns that are actually draining contracts in 2025 — and more importantly, the invariant properties that catch them before an attacker does.

The classic pattern (quick refresher)

You've seen this a thousand times, but here's the skeleton so we're on the same page:

// VULNERABLE -- state update after external call
function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount);
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok);
    balances[msg.sender] -= amount; // too late
}

The fix is simple: update state before the external call, or slap a nonReentrant modifier on it. Most developers know this. But here's the thing: modern reentrancy doesn't look like this at all.

Pattern 1: read-only reentrancy

This one hit hard in 2023 with the Curve/Vyper exploit and has kept showing up since. The idea is deceptively simple: you don't reenter a state-changing function. You reenter a view function that returns stale data.

How it works

  1. Protocol A calls an external contract during a state-changing operation (say, removing liquidity from a Curve pool).
  2. During that external call, the attacker's contract calls back into Protocol B.
  3. Protocol B reads a price or balance from Protocol A using a view function.
  4. But Protocol A's state is mid-update, so the view function returns an incorrect value.
  5. Protocol B acts on the wrong price.
// Curve-style pool -- simplified
contract Pool {
    uint256 public totalLiquidity;
    uint256 public tokenReserve;

    function removeLiquidity(uint256 shares) external {
        uint256 tokenAmount = (shares * tokenReserve) / totalSupply;

        // External call BEFORE state update
        // Attacker receives ETH here and can reenter
        (bool ok, ) = msg.sender.call{value: ethAmount}("");
        require(ok);

        // State not yet updated -- getPrice() still returns old value
        totalLiquidity -= shares;
        tokenReserve -= tokenAmount;
    }

    // This view function returns stale data during removeLiquidity
    function getPrice() external view returns (uint256) {
        return (tokenReserve * 1e18) / totalLiquidity;
    }
}

// Protocol that reads the stale price
contract VulnerableLender {
    Pool public pool;

    function getCollateralValue(address user) public view returns (uint256) {
        // Reads manipulated price during reentrancy window
        uint256 price = pool.getPrice();
        return userCollateral[user] * price / 1e18;
    }
}

The nasty part? nonReentrant on the pool's removeLiquidity doesn't help Protocol B at all. The view function isn't guarded, and even if it were, the downstream protocol is the one being exploited.

The invariant that catches it

// Property: price derived from pool state should never change
// within the same transaction in a way that benefits a single actor
function invariant_priceConsistencyAcrossCallbacks() public returns (bool) {
    uint256 priceBefore = pool.getPrice();

    // Trigger a liquidity operation
    pool.removeLiquidity(someShares);

    uint256 priceAfter = pool.getPrice();

    // Price change per operation should be bounded
    uint256 delta = priceBefore > priceAfter
        ? priceBefore - priceAfter
        : priceAfter - priceBefore;

    // Flag if price moved more than expected for the given shares
    return delta <= maxExpectedDelta(someShares);
}

When you fuzz this with stateful testing, the fuzzer can interleave callback calls mid-operation and catch the window where view functions return garbage.

Pattern 2: cross-function reentrancy

This one doesn't reenter the same function. It reenters a different function on the same contract, one that the nonReentrant modifier might not cover.

How it works

contract Vulnerable {
    mapping(address => uint256) public balances;
    bool private locked;

    modifier nonReentrant() {
        require(!locked);
        locked = true;
        _;
        locked = false;
    }

    // Protected with nonReentrant
    function withdraw() external nonReentrant {
        uint256 bal = balances[msg.sender];
        (bool ok, ) = msg.sender.call{value: bal}("");
        require(ok);
        balances[msg.sender] = 0;
    }

    // NOT protected -- attacker reenters here
    function transfer(address to, uint256 amount) external {
        require(balances[msg.sender] >= amount);
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}

During the withdraw callback, the attacker calls transfer to move their balance to another address before it gets zeroed out. If the nonReentrant guard isn't global (or if transfer doesn't share it), the attack works.

This showed up in multiple real exploits where teams protected their "dangerous" functions but left "safe" functions unguarded.

The invariant that catches it

// Property: total balance accounting must be conserved
function invariant_balanceSolvency() public returns (bool) {
    uint256 totalTracked = 0;
    for (uint256 i = 0; i < actors.length; i++) {
        totalTracked += vulnerable.balances(actors[i]);
    }
    // Sum of all user balances must never exceed contract's actual ETH
    return totalTracked <= address(vulnerable).balance;
}

This is a classic solvency invariant, and it's exactly the kind of property that invariant testing excels at finding violations for. The fuzzer discovers the withdraw-then-transfer sequence on its own.

Pattern 3: cross-contract reentrancy

Now we're talking about callbacks that flow across contract boundaries. Contract A calls Contract B, which calls back into Contract C, which reads stale state from Contract A. It's the same principle as read-only reentrancy but across a multi-contract system.

How it works

Think of a DeFi protocol with separate Vault, Controller, and Oracle contracts:

contract Vault {
    Controller public controller;

    function liquidate(address user) external {
        uint256 debt = controller.getDebt(user);
        uint256 collateral = controller.getCollateral(user);

        // Seize collateral -- triggers external transfer
        IERC20(collateralToken).transfer(msg.sender, collateral);

        // State update happens AFTER transfer
        controller.clearPosition(user);
    }
}

contract Controller {
    mapping(address => uint256) public debts;
    mapping(address => uint256) public collaterals;

    // During the collateral transfer in Vault.liquidate(),
    // this still returns pre-liquidation values
    function getDebt(address user) external view returns (uint256) {
        return debts[user];
    }
}

If the collateral token has transfer callbacks (ERC-777, or an ERC-1155 with hooks), the attacker reenters during the transfer. They can interact with any other protocol that reads from Controller. Controller still shows the pre-liquidation state.

The invariant

// Property: system-wide accounting consistency
function invariant_crossContractConsistency() public returns (bool) {
    for (uint256 i = 0; i < users.length; i++) {
        uint256 vaultView = vault.userCollateral(users[i]);
        uint256 controllerView = controller.getCollateral(users[i]);

        // These two views must ALWAYS agree
        if (vaultView != controllerView) return false;
    }
    return true;
}

The key insight: when you fuzz with stateful sequences, the fuzzer will naturally interleave calls across contracts and find states where views disagree.

Pattern 4: ERC-777 and ERC-1155 callback reentrancy

These token standards have built-in hooks (tokensReceived for ERC-777, onERC1155Received for ERC-1155) that execute code on the recipient during transfers. Any contract that handles these tokens without reentrancy protection is at risk.

The classic ERC-777 trap

contract VulnerableExchange {
    IERC777 public token;
    mapping(address => uint256) public deposits;

    function deposit(uint256 amount) external {
        token.operatorSend(msg.sender, address(this), amount, "", "");
        deposits[msg.sender] += amount; // State update after hook
    }

    function withdraw(uint256 amount) external {
        require(deposits[msg.sender] >= amount);
        deposits[msg.sender] -= amount;
        token.send(msg.sender, amount, ""); // Triggers tokensReceived hook
    }
}

The imBTC Uniswap V1 drain in 2020 used exactly this pattern. ERC-777's tokensReceived hook let attackers reenter during every swap.

Properties for token callback reentrancy

// Property: no value extraction beyond deposits
function invariant_noFreeTokens() public returns (bool) {
    uint256 totalDeposits = 0;
    for (uint256 i = 0; i < actors.length; i++) {
        totalDeposits += exchange.deposits(actors[i]);
    }
    uint256 actualBalance = token.balanceOf(address(exchange));

    // Contract should never have fewer tokens than total deposits
    return actualBalance >= totalDeposits;
}

Building your reentrancy test suite

Here's how I'd structure invariant tests for reentrancy across all four patterns:

contract ReentrancyInvariantTest is Test {
    // Actors include attacker contracts with callback hooks
    MaliciousReceiver attacker;
    ERC777Attacker tokenAttacker;

    function setUp() public {
        // Deploy system
        // Deploy attacker contracts that implement:
        // - receive() for ETH callbacks
        // - tokensReceived() for ERC-777
        // - onERC1155Received() for ERC-1155
        attacker = new MaliciousReceiver(address(target));
        tokenAttacker = new ERC777Attacker(address(exchange));
    }

    // Core solvency -- catches cross-function and classic reentrancy
    function invariant_solvency() public {
        assertGe(
            address(target).balance,
            target.totalDeposits(),
            "Solvency violated"
        );
    }

    // Price consistency -- catches read-only reentrancy
    function invariant_priceNeverStale() public {
        uint256 computedPrice = target.computeSpotPrice();
        uint256 cachedPrice = target.getPrice();
        uint256 tolerance = computedPrice / 100; // 1%
        assertApproxEqAbs(computedPrice, cachedPrice, tolerance);
    }

    // Cross-contract consistency -- catches cross-contract reentrancy
    function invariant_stateAgreement() public {
        assertEq(
            vault.totalAssets(),
            controller.totalTrackedAssets(),
            "Cross-contract state mismatch"
        );
    }
}

The trick is making sure your fuzzer's actor set includes contracts with callback hooks. Without those, you'll never trigger the reentrancy paths. Both Echidna and Medusa support this, so you just need to deploy the attacker contracts in your setup and include them in the target list.

Mitigations that actually work

  1. Global reentrancy locks. Not per-function, but per-contract or per-system. OpenZeppelin's ReentrancyGuard works, but only if every external-facing function uses it.

  2. Checks-Effects-Interactions pattern. Still the gold standard. Update all state before making any external call. Period.

  3. For read-only reentrancy: Add reentrancy guards to view functions that downstream contracts might call. Curve added this after their exploit (reentrancy_lock checks on get_virtual_price()).

  4. For cross-contract: The consuming protocol needs to protect itself. You can't rely on the upstream contract's guards. Consider using reentrancy-aware oracles or adding your own staleness checks.

  5. Token hook awareness: If you're handling ERC-777 or ERC-1155, treat every transfer/send as a potential callback entry point.

The bottom line

Reentrancy hasn't gone away, it's evolved. The patterns that hit protocols today aren't the ones in your Solidity 101 course. Read-only reentrancy, cross-function, cross-contract, and callback-based variants all exploit the same fundamental issue: external calls happen before state settles.

Invariant testing catches these because it doesn't care about the path to the violation. It checks the property. If solvency breaks, if prices go stale, if cross-contract state disagrees, the fuzzer finds the sequence that causes it.

Want to test your protocol against these patterns? Try Recon Pro and run stateful fuzzing campaigns that include callback-equipped actors. Or if you'd rather have experts write the properties, request an audit and we'll build a custom reentrancy test suite for your codebase.

Further reading

Related Posts

Related Glossary Terms

Test for modern reentrancy patterns