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
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.
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 priceThe 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
- 2
- 3
- 4
- 5
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 (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.