In August 2023, Curve Finance lost around $61M across several pools due to a reentrancy bug in Vyper's compiler — not Solidity's. The affected Vyper versions (0.2.15, 0.2.16, 0.3.0) had a broken @nonreentrant guard implementation. Attackers reentered pool functions mid-execution and read view functions that returned prices based on reserves that hadn't been updated yet. Downstream protocols consuming those prices made decisions on stale data.
The attack worked even though the intent was to have reentrancy protection. The deeper problem is architectural: view functions weren't protected, and consuming protocols had no way to detect they were reading state mid-update.
This is read-only reentrancy. It exploits the window between when an external call goes out and when state is updated — a window that standard nonReentrant guards leave open on view functions.
Classic Reentrancy
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 finishes, withdrawing again. The balance is decremented twice but the fund transfer executes twice. 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. Cross-contract and read-only attacks bypass it entirely.
Cross-Contract Reentrancy: Indirect State Modification
Cross-contract reentrancy occurs when:
- Contract A calls Contract B (e.g., an ERC-20 transfer)
- Contract B calls back to Contract A through Contract C (not directly, but through an intermediary)
- 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 illustration — VULNERABLE pattern
// (The real Curve pools were written in Vyper; the @nonreentrant decorator
// in affected versions 0.2.15–0.3.0 did not actually prevent reentry.)
contract CurvePool {
uint256[3] public reserves;
function exchange(
int128 i,
int128 j,
uint256 dx,
uint256 minDy
) external returns (uint256) {
// Transfer from user — external call, reentrancy window opens here
tokens[i].transferFrom(msg.sender, address(this), dx);
// State updated AFTER the external call
reserves[i] += dx;
reserves[j] -= minDy;
tokens[j].transfer(msg.sender, minDy);
return minDy;
}
// View function — reads stale reserves if called during the reentry window
function get_dy(int128 i, int128 j, uint256 dx) external view returns (uint256) {
uint256[3] memory _reserves = reserves;
// During a reentrant call, reserves haven't been 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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// VULNERABLE: Read-only reentrancy illustration
contract VulnerablePool {
mapping(address => uint256) public balances;
uint256[2] public reserves = [1000e18, 1000e18];
IERC20 public immutable tokenIn; // set in constructor
IERC20 public immutable tokenOut; // set in constructor
function swap(uint256 amountIn) external returns (uint256) {
// External call — reentrancy window opens before state update
tokenIn.transferFrom(msg.sender, address(this), amountIn);
// Calculate output
uint256 amountOut = (amountIn * reserves[1]) / reserves[0];
// State updated after external call — stale during the window above
reserves[0] += amountIn;
reserves[1] -= amountOut;
tokenOut.transfer(msg.sender, amountOut);
return amountOut;
}
// Returns stale data if called during the reentrancy window in swap()
function getPrice() external view returns (uint256) {
return (1e18 * reserves[0]) / reserves[1];
}
}
// Attacker contract — MaliciousToken calls this during transferFrom()
contract Attacker {
VulnerablePool public pool;
constructor(address _pool) {
pool = VulnerablePool(_pool);
}
// Called by the malicious token's transferFrom hook
function onTransferHook() external {
// State in pool.reserves is not yet updated
uint256 stalePrice = pool.getPrice();
// Use stalePrice to liquidate positions on protocols
// that consume pool.getPrice() as a collateral oracle
}
}
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 an attack, oracle.getPrice() returns a stale price calculated from out-of-date reserves. Liquidations execute at the wrong price. Arbitrage bots act on incorrect data. The losses compound across every protocol consuming the stale feed.
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 | Symbolic 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
The root cause was a broken @nonreentrant guard in Vyper compiler versions 0.2.15, 0.2.16, and 0.3.0. Curve's pools written in those Vyper versions were missing effective reentrancy protection on remove_liquidity(). Attackers exploited the reentrant window to read get_virtual_price() — a view function — which returned a price based on reserves that hadn't finished updating. Protocols consuming that price for collateral valuation (including some Convex-adjacent integrations) made decisions on stale data. The total losses across affected pools reached approximately $61M before Curve contained the damage.
Euler Finance (March 2023) — $197M
Euler's lending protocol had a donateToReserves() function that violated Checks-Effects-Interactions. An attacker used a flash loan to manipulate Euler's internal accounting, then triggered liquidation logic that read inconsistent state. The reentrant path allowed draining collateral that shouldn't have been accessible. Euler recovered approximately $177M through negotiation with the attacker.
Sentiment (April 2023) — ~$1M
Sentiment's lending protocol on Arbitrum was exploited via read-only reentrancy against Balancer's vault. Sentiment used Balancer pool token prices computed from getPoolTokens(), which could return inconsistent values mid-transaction. The attacker manipulated the price Sentiment read, then borrowed against inflated collateral.
The Attack Surface Hasn't Shrunk
Direct reentrancy is largely a solved problem. Checks-Effects-Interactions and nonReentrant guards have made same-function reentry rare in audited code. Cross-contract and read-only variants are where the losses are happening now — they're harder to detect, they span contract boundaries, and they exploit the mismatch between what view functions promise and what they actually return mid-transaction.
The mitigations aren't complicated, but they require thinking at the system level rather than per-function:
- Guard every state-modifying function in the protocol, not just the obvious entry points
- Use a global lock shared across all contracts in the system
- Apply
nonReentrantto view functions that external protocols use as price sources - Order all operations so state updates precede external calls
- Audit every downstream consumer of your view functions — their security depends on yours
The Curve incident is a useful benchmark: the Vyper bug was a compiler defect, but the protocols that consumed get_virtual_price() without reentrancy guards on their own side amplified the damage. Both sides had to be wrong for the exploit to work at scale. That's the nature of read-only reentrancy — the vulnerability is often shared across a protocol stack.
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.