Command Palette

Search for a command to run...

Cross-contract Reentrancy — Read-only Exploit

ETHVault has a nonReentrant guard but sends ETH before updating state. LendingPool reads ETHVault's getSharePrice() view function. During the withdraw() external call, an attacker re-enters LendingPool using ETHVault's stale inflated price to borrow more than allowed.

~8 min read

Available

Classic reentrancy attacks the same contract that sends ETH. Cross-contract reentrancy — specifically read-only reentrancy — is subtler: a locked contract sends ETH to an attacker, who re-enters a DIFFERENT contract that reads the locked contract's view function. The locked contract's state is inconsistent during the call, and the view returns stale data that the second contract trusts.

1. The nonReentrant guard and its limits

OpenZeppelin's nonReentrant modifier prevents the same contract from being re-entered during an active call. When ETHVault.withdraw() runs, re-entering ETHVault is blocked. But the guard is per-contract. An attacker re-entering LendingPool (a different contract) bypasses ETHVault's lock entirely.

The guard does NOT prevent: (a) calling a different contract that reads ETHVault's state, (b) reading ETHVault's view functions from inside the attacker's receive(), (c) calling any function on any other contract while ETHVault's withdraw() is in progress.

Read-only reentrancy specifically targets view functions. The attacker exploits the fact that ETHVault's totalAssets has not been decremented yet when the ETH transfer occurs — so getSharePrice() returns a value based on the pre-decrement totalAssets. LendingPool believes shares are worth more than they are.

2. The state inconsistency window

In ETHVault.withdraw(): (1) ETH is sent BEFORE totalShares/totalAssets are decremented. (2) During the ETH send, the attacker's receive() runs. (3) At this point, ETHVault's storage still shows the old totalAssets (higher than it will be after the withdraw completes). (4) getSharePrice() = totalAssets * 1e18 / totalShares returns an inflated value.

LendingPool.borrow() calls vault.getSharePrice(). The inflated price means maxBorrow = collateral * inflatedPrice * LTV / 1e18 is larger than it should be. The attacker borrows more than their collateral supports at the true post-withdraw price.

This is a CEI (Checks-Effects-Interactions) violation at the system level, not just the function level. ETHVault follows CEI within its own logic but the interaction (ETH send) occurs when the system state (prices in LendingPool) is still based on stale ETHVault data.

3. The attack — exploit via receive()

Attacker has shares in ETHVault and collateral deposited in LendingPool. Attack via the attacker's receive() function:

// Setup: attacker deposited collateral in LendingPool
// Attack:
attacker.receive() {
    // This runs DURING ETHVault.withdraw(), before state updates
    // ETHVault.totalAssets still = pre-withdraw value (inflated)
    // ETHVault.getSharePrice() returns inflated value
    uint256 maxBorrow = lendingPool.borrow(largeAmount);
    // borrow succeeds at inflated price
    // attacker receives more ETH than collateral supports
}
// After receive() returns: ETHVault updates totalShares/totalAssets
// LendingPool's borrow already executed at inflated price

The nonReentrant guard on ETHVault.withdraw() prevents the attacker from calling withdraw() again (classic reentrancy). But it does NOT block the call to lendingPool.borrow() — that is a different contract.

Attack timeline

  1. 1

    Setup

    Attacker deposits ETH into ETHVault to get shares. Attacker also deposits vault shares as collateral in LendingPool. Both positions are legitimate.

  2. 2

    Call withdraw

    Attacker calls ETHVault.withdraw(someShares). ETHVault sends ETH to attacker via call{value}. ETHVault's totalAssets has not yet been decremented.

  3. 3

    Re-enter LendingPool

    Attacker's receive() runs. Attacker calls lendingPool.borrow(largeAmount). LendingPool calls vault.getSharePrice() — returns stale inflated value. maxBorrow is inflated. Borrow succeeds.

  4. 4

    State update

    receive() returns. ETHVault.withdraw() continues and decrements totalShares/totalAssets. But the borrow already executed at the inflated price.

  5. 5

    Net gain

    Attacker received ETH from the withdraw AND borrowed more from LendingPool than their collateral supports. The excess borrow is profit. LendingPool has an undercollateralized position.

4. The fix — guard LendingPool too

Apply nonReentrant to LendingPool.borrow() so it cannot be called while ETHVault's state is inconsistent. Or update ETHVault's state before sending ETH:

// Fix 1: update ETHVault state BEFORE the external call (CEI)
function withdraw(uint256 shareAmount) external nonReentrant {
    require(shares[msg.sender] >= shareAmount, "insufficient");
    uint256 ethOut = (shareAmount * totalAssets) / totalShares;
    // EFFECTS: update state FIRST
    shares[msg.sender] -= shareAmount;
    totalShares -= shareAmount;
    totalAssets -= ethOut;
    // INTERACTIONS: send ETH AFTER
    (bool ok, ) = msg.sender.call{value: ethOut}("");
    require(ok, "transfer failed");
}

// Fix 2: add nonReentrant to LendingPool.borrow()
function borrow(uint256 amount) external nonReentrant {
    // now cannot be called during any other nonReentrant call
    uint256 price = vault.getSharePrice();
    // ...
}

Fix 1 (CEI) is the root-cause fix: updating ETHVault's state before sending ETH means getSharePrice() returns the correct post-withdraw value even if re-entered. This is the preferred fix for ETHVault.

Fix 2 (nonReentrant on LendingPool) is a defense-in-depth layer: even if CEI is violated in ETHVault, LendingPool.borrow() cannot execute during any active nonReentrant call on the same address. Consider applying nonReentrant to all security-critical functions in any contract that reads state from an external vault or AMM.

5. What to look for as an auditor

  • Any external call (ETH send or token transfer) before state updates in a vault, AMM, or lending contract. This is a CEI violation and the root cause of most reentrancy variants.
  • Identify which other contracts read state from the vulnerable contract's view functions (getPrice, getRate, totalSupply, exchangeRate). These are the second-contract targets.
  • A nonReentrant guard on Contract A does NOT protect Contract B. If B reads A's state and B is called from A's receive/callback, B needs its own guard.
  • Read-only reentrancy on Curve Finance: a known real-world example. Curve's remove_liquidity sends ETH before updating virtual price. Protocols that called Curve's virtual_price during remove_liquidity were exploited.
  • Multi-protocol compositions: audit any protocol that uses a price or rate from an external contract (oracle, vault, AMM). Ask: can that price be wrong during a callback or reentrancy scenario?
  • The fix hierarchy: (1) CEI in the vault (always), (2) nonReentrant on downstream consumers, (3) snapshot price before any external call so downstream functions use a pre-call value.

With the cross-contract reentrancy model in mind, open the Hunt tab. Identify the CEI violation in ETHVault, then trace the stale-price path through LendingPool.

← Back to skill tree