In June 2016, an attacker drained 3.6 million ETH — worth roughly $60 million at the time — from The DAO by exploiting a reentrancy vulnerability. The attack was so severe that the Ethereum community hard-forked the chain to reverse the theft. Yet reentrancy attacks continue to appear in production code nearly a decade later.

How reentrancy works

Reentrancy exploits the EVM's execution model. When a contract calls an external address (to send Ether or interact with another contract), it temporarily hands control to that address. If the recipient is a malicious contract with a receive() or fallback() function, that function can call back into the original contract — before the original function has finished executing and updating its state.

The classic scenario:

  1. Victim contract records that user A has 10 ETH balance.
  2. User A calls withdraw(10 ETH).
  3. Victim contract sends 10 ETH to the attacker's contract.
  4. The attacker's receive() function calls withdraw(10 ETH) again — before step 5.
  5. The victim contract still shows user A has 10 ETH (balance not yet zeroed), so it sends 10 more ETH.
  6. This loop continues until the victim contract is empty.
  7. Only then do the balance-zeroing statements execute — too late.

Single-function vs. cross-function reentrancy

The classic attack re-enters the same function. More subtle is cross-function reentrancy, where the attacker re-enters a different function that reads state set by the original function but not yet finalized. This variant is harder to detect and has appeared in several DeFi exploits where reentrancy guards only protected individual functions.

Read-only reentrancy

The most sophisticated variant, read-only reentrancy, exploits protocols that call external contracts to read state (e.g., querying a liquidity pool's price) at a moment when that external contract's state is in a mid-execution inconsistency. No funds flow through the reentrancy path itself — the attacker profits by making decisions based on the corrupted read.

Real-world exploits

  • The DAO (2016) — 3.6 million ETH; classic single-function reentrancy on the withdrawal function.
  • Lendf.me (2020) — $25 million; ERC-777 token hook reentrancy during a deposit function.
  • Cream Finance (2021) — $18.8 million; flash loan combined with reentrancy.
  • Curve Finance (2023) — $70 million; Vyper compiler bug re-enabled reentrancy in contracts that had intended guards.

Prevention: Checks-Effects-Interactions pattern

The most fundamental protection is the Checks-Effects-Interactions (CEI) pattern: always update all state before making any external calls. Never send Ether or call external contracts while your state is still "dirty."

// WRONG — effects after interaction
function withdraw(uint amount) public {
    require(balances[msg.sender] >= amount);  // Check
    (bool ok,) = msg.sender.call{value: amount}("");  // Interaction
    require(ok);
    balances[msg.sender] -= amount;  // Effect — too late!
}

// CORRECT — effects before interaction
function withdraw(uint amount) public {
    require(balances[msg.sender] >= amount);  // Check
    balances[msg.sender] -= amount;  // Effect first
    (bool ok,) = msg.sender.call{value: amount}("");  // Interaction
    require(ok);
}

Prevention: ReentrancyGuard

OpenZeppelin's ReentrancyGuard provides a mutex (nonReentrant modifier) that prevents any re-entrant calls into the same contract during execution. Apply it to all functions that make external calls or send Ether.

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract SafeVault is ReentrancyGuard {
    function withdraw(uint amount) public nonReentrant {
        require(balances[msg.sender] >= amount);
        balances[msg.sender] -= amount;
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok);
    }
}

To check whether your contract has reentrancy risks, run a free AI audit — it specifically checks for CEI violations and missing reentrancy guards.