← Back to Blog

Cross-Contract Reentrancy and Read-Only Reentrancy: The Subtle Attack Pattern

2026-04-18 reentrancy cross-contract read-only reentrancy solidity defi flashloan 2026

In August 2023, Curve Finance suffered a $61M exploit. The vulnerability wasn't a direct reentrancy — it was something subtler. An attacker reentered Curve's pool through a callback, called a view function that read prices during the reentrant execution, and got stale state values that hadn't been updated yet. Those stale prices were then used by dependent protocols to make decisions, draining millions across the ecosystem.

The attack worked even though Curve used nonReentrant guards on sensitive state-changing functions. The problem: view functions weren't protected, and downstream protocols trusted prices they read during moments when the contract was in an inconsistent state.

This is read-only reentrancy, and it's become one of the most dangerous attack vectors in DeFi — precisely because developers assume their view functions are safe.


Classic Reentrancy: A Quick Recap

In traditional reentrancy, an attacker calls Contract A, which transfers ETH to the attacker's contract. The attacker's fallback function calls back into Contract A before the first call finished, withdrawing again. The contract's balance is decremented twice, but the fund transfer happens twice too. The attacker drains the contract.

The fix is the nonReentrant modifier (Checks-Effects-Interactions pattern):

// VULNERABLE
function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount);
    (bool success,) = msg.sender.call{value: amount}("");
    require(success);
    balances[msg.sender] -= amount;  // Effects happen after external call
}

// FIXED
function withdraw(uint256 amount) external nonReentrant {
    require(balances[msg.sender] >= amount);
    balances[msg.sender] -= amount;  // Effects before external call
    (bool success,) = msg.sender.call{value: amount}("");
    require(success);
}

But nonReentrant only guards against direct reentrancy — calling back into the same function. It doesn't guard against cross-contract reentrancy or read-only attacks. That's where the real danger lives in 2026.


Cross-Contract Reentrancy: Indirect State Modification

Cross-contract reentrancy occurs when:

  1. Contract A calls Contract B (e.g., an ERC-20 transfer)
  2. Contract B calls back to Contract A through Contract C (not directly, but through an intermediary)
  3. The reentrant call modifies Contract A's state in an unexpected way

The attacker never directly reenters the function they called. They enter through a different function, or through a different contract in the same protocol.

Example: Router and Token

Consider a swap router that swaps Token A for Token B, then immediately uses the output:

// contracts/SwapRouter.sol - VULNERABLE to cross-contract reentrancy
contract SwapRouter {
    mapping(address => uint256) public userBalance;

    function swapAndDeposit(
        address tokenIn,
        address tokenOut,
        uint256 amountIn
    ) external nonReentrant {
        // Transfer tokenIn to swap pool
        IERC20(tokenIn).transferFrom(msg.sender, pool, amountIn);

        // Receive tokenOut from pool
        uint256 amountOut = IPool(pool).swap(tokenOut, amountIn);

        // Deposit the output
        userBalance[msg.sender] += amountOut;
    }

    function withdraw(uint256 amount) external {
        require(userBalance[msg.sender] >= amount);
        userBalance[msg.sender] -= amount;
        IERC20(token).transfer(msg.sender, amount);
    }
}

// contracts/MaliciousToken.sol
contract MaliciousToken is ERC20 {
    address router;
    bool reentering = false;

    constructor(address _router) {
        router = _router;
    }

    function transferFrom(
        address from,
        address to,
        uint256 amount
    ) public override returns (bool) {
        if (!reentering && to == address(pool)) {
            reentering = true;
            // Reenter through withdraw(), not swapAndDeposit()
            SwapRouter(router).withdraw(1e18);
            reentering = false;
        }
        return super.transferFrom(from, to, amount);
    }
}

The attack:
1. Attacker calls swapAndDeposit() with the malicious token
2. swapAndDeposit() is protected by nonReentrant, but it calls transferFrom() on the malicious token
3. The malicious token's transferFrom() reenters the router through withdraw()
4. withdraw() has no guard, so it succeeds and transfers funds to the attacker
5. The attacker's balance wasn't incremented yet (that happens after swap() returns), so they withdraw funds that should belong to someone else

The nonReentrant guard on swapAndDeposit() didn't help — the attack didn't reenter swapAndDeposit(), it entered withdraw().

Fix: Guard All State-Changing Functions

contract SwapRouter {
    mapping(address => uint256) public userBalance;
    uint256 private locked;

    modifier nonReentrant() {
        require(locked == 0, "no reentrancy");
        locked = 1;
        _;
        locked = 0;
    }

    function swapAndDeposit(
        address tokenIn,
        address tokenOut,
        uint256 amountIn
    ) external nonReentrant {
        IERC20(tokenIn).transferFrom(msg.sender, pool, amountIn);
        uint256 amountOut = IPool(pool).swap(tokenOut, amountIn);
        userBalance[msg.sender] += amountOut;
    }

    // Now protected — uses same lock
    function withdraw(uint256 amount) external nonReentrant {
        require(userBalance[msg.sender] >= amount);
        userBalance[msg.sender] -= amount;
        IERC20(token).transfer(msg.sender, amount);
    }
}

If the protocol owns multiple contracts, the lock must span the entire system, not just one contract:

// contracts/GlobalLock.sol
contract GlobalLock {
    mapping(address => uint256) locks;

    modifier nonReentrant() {
        require(locks[msg.sender] == 0, "locked");
        locks[msg.sender] = 1;
        _;
        locks[msg.sender] = 0;
    }
}

// Both SwapRouter and TokenVault inherit from GlobalLock
contract SwapRouter is GlobalLock { }
contract TokenVault is GlobalLock { }

Read-Only Reentrancy: State Inconsistency in View Functions

Read-only reentrancy is more subtle and more dangerous. It exploits the fact that view functions read state during a reentrant execution when the contract is in an inconsistent state.

The Curve Finance Exploit (August 2023)

Curve is a stablecoin liquidity pool. It uses an exchange function that calls external tokens and an internal price function that depends on the pool's internal reserves:

// Simplified Curve pool — VULNERABLE
contract CurvePool {
    uint256[3] public reserves;

    function exchange(
        int128 i,
        int128 j,
        uint256 dx,
        uint256 minDy
    ) external nonReentrant returns (uint256) {
        // Transfer from user
        tokens[i].transferFrom(msg.sender, address(this), dx);
        // <- External call happens here

        // Update state
        reserves[i] += dx;
        reserves[j] -= minDy;

        // Transfer to user
        tokens[j].transfer(msg.sender, minDy);

        return minDy;
    }

    // View function — reads stale state during reentrancy
    function get_dy(int128 i, int128 j, uint256 dx) external view returns (uint256) {
        uint256[3] memory _reserves = reserves;
        // Use _reserves to calculate price
        // But during a reentrant call, reserves aren't updated yet!
        return _calcDy(_reserves, i, j, dx);
    }
}

The exploit:
1. Attacker's smart contract calls exchange() with a malicious ERC-20 token
2. Curve calls transferFrom() on the malicious token
3. The malicious token reenters Curve and calls get_dy() (a view function)
4. get_dy() reads the pool's reserves, but reserves haven't been updated yet (that happens after the external call returns)
5. The attacker gets a price calculation based on stale reserves, which is wrong
6. Downstream protocols (price feeds, liquidation oracles, arbitrage bots) read this price
7. The attacker exploits the stale price to profit, and the pool is drained

The attacker didn't directly reenter exchange() — they read a view function. The nonReentrant guard doesn't protect view functions.

Proof of Concept

pragma solidity ^0.8.0;

// VULNERABLE: Read-only reentrancy vulnerability
contract VulnerablePool {
    mapping(address => uint256) public balances;
    uint256[2] public reserves = [1000e18, 1000e18];
    address constant USDC = 0x...;
    address constant USDT = 0x...;

    function swap(uint256 amountIn) external nonReentrant returns (uint256) {
        // Transfer in
        IERC20(USDC).transferFrom(msg.sender, address(this), amountIn);
        // <- Reentrancy can happen here

        // Calculate output
        uint256 amountOut = (amountIn * reserves[1]) / reserves[0];

        // Update state
        reserves[0] += amountIn;
        reserves[1] -= amountOut;

        // Transfer out
        IERC20(USDT).transfer(msg.sender, amountOut);

        return amountOut;
    }

    // View function that can return stale data during reentrancy
    function getPrice() external view returns (uint256) {
        // Returns price based on current reserves
        // But if called during swap(), reserves haven't been updated yet!
        return (1e18 * reserves[0]) / reserves[1];
    }
}

// Attacker contract
contract Attacker {
    VulnerablePool pool = VulnerablePool(0x...);
    address constant USDC = 0x...;

    function exploit() external {
        uint256 amountIn = 1000e6;
        IERC20(USDC).approve(address(pool), amountIn);

        // Call swap with malicious USDC that reenters
        pool.swap(amountIn);
    }

    function onERC20Receive() external {
        // This is called during pool.swap() at the transferFrom line
        // Reenter and read the stale price
        uint256 stalePrice = pool.getPrice();

        // Use stalePrice to make profitable decisions
        // For example, liquidate positions on other protocols
        // that rely on this price feed
    }
}

Why This Breaks Downstream Protocols

Many DeFi protocols use prices from other protocols directly:

// Oracle contract that reads Curve's price
contract CurveOracle {
    address constant curvePool = 0x...;

    function getPrice() external view returns (uint256) {
        // Read price directly from Curve
        return ICurvePool(curvePool).get_dy(0, 1, 1e18);
    }
}

// Lending protocol that uses the oracle
contract LendingProtocol {
    CurveOracle oracle;

    function liquidate(address borrower) external {
        uint256 price = oracle.getPrice();
        uint256 collateralValue = borrowerCollateral[borrower] * price;
        require(collateralValue < borrowerDebt[borrower], "not liquidatable");
        // Liquidate...
    }
}

During the Curve attack, oracle.getPrice() returns a stale price calculated from out-of-date reserves. Liquidations happen at the wrong price. Arbitrage trades execute at the wrong price. Billions flow to the attacker.


Why Standard Reentrancy Guards Miss These Attacks

1. Guards Don't Protect View Functions

nonReentrant typically uses a state variable that's checked at function entry:

bool reentering = false;

modifier nonReentrant() {
    require(!reentering, "reentrancy");
    reentering = true;
    _;
    reentering = false;
}

View functions don't modify state, so they're not guarded. But if a view function reads state that's being modified elsewhere, it can read inconsistent values.

2. Cross-Contract Reentrancy Bypasses Single-Contract Guards

A guard on Contract A doesn't stop reentrancy into Contract B. If Contract A calls Contract B, and Contract B's external call reenters Contract A through a different function, the guard on that different function does nothing if it wasn't guarded.

3. State Inconsistency Isn't Reentrancy

Read-only reentrancy isn't "reentrancy" in the traditional sense — the same function isn't called twice. It's reading state during a transaction where that state is being modified. Reentrancy guards can't protect against this because view functions don't enter the guarded section.


Detection: Which Tools Catch These?

Tool Cross-Contract Read-Only Reentrancy Notes
Slither Partial No Detects unguarded functions and external calls, but not cross-contract attack chains
Mythril No No Symbol execution catches some paths but misses complex reentry chains
ContractScan AI Yes Partial Flags all unguarded state-changing functions and analyzes call graphs for reentry
Manual Review Yes Yes Required for read-only attacks — needs understanding of which view functions are relied upon downstream

ContractScan flags:
- State-changing functions without reentrancy guards when they call external contracts
- View functions that read state that could be modified during external calls
- Call chains between contracts in a protocol that could enable reentry
- Views that are used as price oracles (common pattern in DeFi)

But AI-assisted detection of read-only reentrancy in complex DeFi stacks is still evolving. The Curve attack was only caught after the fact.


Mitigations

1. Guard All State-Modifying Functions in the Protocol

// Global reentrancy lock
contract ProtocolBase {
    uint256 internal reentrancyGuard;

    modifier nonReentrant() {
        require(reentrancyGuard == 0, "reentrancy locked");
        reentrancyGuard = 1;
        _;
        reentrancyGuard = 0;
    }
}

// All contracts in the protocol inherit from ProtocolBase
contract Pool is ProtocolBase {
    function swap(...) external nonReentrant { }
    function deposit(...) external nonReentrant { }
    function withdraw(...) external nonReentrant { }
}

contract Vault is ProtocolBase {
    function deposit(...) external nonReentrant { }
    function claim(...) external nonReentrant { }
}

2. Protect View Functions That Return State Used Elsewhere

If a view function is likely to be used as an oracle or price feed, protect it from reading stale state:

// Option A: Don't read state that's being modified
function getPrice() external view returns (uint256) {
    // Instead of reading reserves directly, use a stored price
    // that's updated only when the state is consistent
    return lastUpdatedPrice;
}

// Option B: Guard the view function too (unusual, but sometimes necessary)
contract StrictPool {
    uint256 reentrancyGuard;

    modifier nonReentrant() {
        require(reentrancyGuard == 0);
        reentrancyGuard = 1;
        _;
        reentrancyGuard = 0;
    }

    function getPrice() external view nonReentrant returns (uint256) {
        // Now getPrice reverts if called during a reentrant execution
        return (1e18 * reserves[0]) / reserves[1];
    }
}

Option B is unusual because view functions shouldn't modify state. But in Solidity, you can read-only guard with a check (doesn't enter the reentrant section if locked).

3. Use Checks-Effects-Interactions at the System Level

Order all operations so external calls happen last:

function swap(uint256 amountIn) external nonReentrant returns (uint256) {
    // Checks
    require(amountIn > 0, "invalid amount");

    // Effects (state changes first)
    uint256 amountOut = (amountIn * reserves[1]) / reserves[0];
    reserves[0] += amountIn;
    reserves[1] -= amountOut;

    // Interactions (external calls last)
    IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);
    IERC20(tokenOut).transfer(msg.sender, amountOut);

    return amountOut;
}

The key: state is updated before any external call. If reentrancy happens, the reentering code sees updated state, not stale state.

4. Separate Price Feeds from State-Modifying Pools

Curve's issue was that get_dy() was used as both an internal calculation and an external price oracle. Better design:

// Price feed — doesn't depend on real-time state
contract CurvePriceFeed {
    address constant curvePool = 0x...;

    function getPrice() external view returns (uint256) {
        // Use a time-weighted average, not current state
        // Or read from a separate, snapshotted price feed
        return timeWeightedPrice;
    }
}

// Actual pool — doesn't expose raw get_dy() to external callers
contract CurvePool {
    function swap(...) external nonReentrant { }

    function get_dy(...) internal view returns (uint256) {
        // Internal only — can't be called by external contracts
        // even during reentrancy
    }
}

5. Audit All Downstream Uses of Your Contracts' View Functions

If you expose view functions, audit every protocol that uses them:

// Internal audit checklist
// - Which protocols call our view functions?
// - Do they use the returned values for critical decisions (prices, liquidations)?
// - Can our view functions return stale data during a reentrant execution?
// - Should we warn them to implement their own guards?

Real Incidents

Curve Finance (August 2023) — $61M

Curve's tricrypto pool allowed reentrancy through the exchange() function when transferring certain tokens. Attackers reentered and read get_dy(), which returned prices based on pool state that hadn't been updated yet. They used the stale prices to create liquidation opportunities on Compound and Aave, draining millions. Curve lost significant TVL as a result.

Balancer (June 2023) — $900K

Balancer's Boosted Aave pools allowed reentrancy through token transfers. Attackers exploited the reentrant execution to read pool prices and execute profitable swaps against outdated reserve balances.

Sentiment (April 2023) — $1M

Sentiment's lending protocol allowed reentrancy during liquidations. Attackers reentered to claim liquidation rewards multiple times on a single liquidatable position.

Lido stETH (Ongoing Risk)

Lido's staking contract is exposed to read-only reentrancy risks because balanceOf() returns shares rather than ETH, and if other protocols rely on this for pricing, they could read stale balances during Lido's reentrant execution.


Conclusion

Classic reentrancy was a solvable problem. Checks-Effects-Interactions and nonReentrant guards made direct reentry nearly extinct. But cross-contract and read-only reentrancy are systemic risks in DeFi: they're hard to detect, they span multiple contracts, and they exploit the boundary between view functions and state-modifying functions.

The fix isn't a single guard — it's system design:

  1. Guard all state-modifying functions in every contract in your protocol
  2. Use global locks that span all contracts, not per-function locks
  3. Protect view functions that are used as oracles
  4. Order operations to put state updates before external calls
  5. Audit downstream usage of your contracts' exposed functions
  6. Test for reentrancy not just on your contracts, but on the entire protocol stack

In 2026, if a DeFi protocol hasn't audited its reentrancy surface against cross-contract and read-only attacks, it hasn't been audited.


ContractScan is a multi-engine smart contract security scanner. QuickScan is free and unlimited — no sign-up required. Try it now.

Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →