Caloogy Article
中文

Reentrancy Attack: Why The DAO Caused an Ethereum Fork

Date
Mar 6, 2025
Series
Onchain
Reading
4 min
Topics
OnchainSmart contractReentrancyThe dao

The one-sentence version

When a smart contract transfers ETH to someone, if the recipient is also a contract, it will automatically execute a piece of the recipient's code. If your contract follows a "transfer money first, then update balance" pattern, the recipient can call the withdraw function again the moment they receive the money, before your ledger has been updated, allowing them to withdraw repeatedly. In 2016, The DAO was drained of approximately 3.6 million ETH this way. The Ethereum community responded with a hard fork to roll back the state, resulting in the Ethereum (ETH) and Ethereum Classic (ETC) we know today. The only mantra for defense is: Check, then update the ledger, and only then interact with external entities.

This article has three layers:

  1. Layer 1: The Intuition: An ATM that dispenses cash before deducting the balance
  2. Layer 2: Real-World Use: The DAO, various variants, and the 2023 Curve compiler incident
  3. Layer 3: Engineering details and mathematical principles: Vulnerable code, fix patterns, reentrancy guards, read-only reentrancy, and cross-contract reentrancy

Layer 1: The Intuition

1.1 An ATM that dispenses cash before deducting the balance

A buggy ATM follows this process:

  1. Check if the balance is sufficient;
  2. Dispense cash;
  3. Deduct the amount from the balance.

A fast-acting person presses "Withdraw" again the exact moment the cash is dispensed in step 2. The machine returns to step 1 to check the balance: it hasn't been deducted yet, so there is enough! It dispenses cash again... As long as they are fast enough, they can keep withdrawing until the machine is empty.

In smart contracts, "fast-acting" doesn't require a human to press buttons: when a recipient contract receives ETH, it automatically runs a function (receive / fallback), which can be used to call the withdraw function once more.

1.2 Why it is written this way

The human intuition for writing code is "give the money first, and if successful, record it in the ledger," which is fine in standard programming. However, in smart contracts, the act of "giving it away" hands over control to the recipient, and the recipient is untrusted.


Layer 2: Real-World Use

2016The DAO stolen ~3.6METHEthereum hard fork,ETH and ETC split2020Lending protocols likeLendf.Me hit byreentrancy viaERC-777 callbacks2021-2022Multiple protocolsexploited viacross-contract andcross-functionreentrancy2023Curve multiple poolslost ~$70M due toVyper compilerreentrancy guard bugReentrancy Attack Milestones

Layer 3: Principles and Engineering

3.1 Vulnerable Code

// Vulnerable
function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0);
    (bool ok, ) = msg.sender.call{value: amount}("");   // ① Transfer first: control is handed to the recipient
    require(ok);
    balances[msg.sender] = 0;                            // ② Update balance later: too late
}

Attacker contract:

receive() external payable {
    if (address(victim).balance >= 1 ether) {
        victim.withdraw();    // withdraw one more time between ① and ②
    }
}

A breakdown of an attack

An attacker deposits 1 ETH, and the contract has a total balance of 10 ETH. The attacker calls withdraw:

  • Layer 1: Balance recorded as 1, transfers 1 ETH → triggers receive → calls withdraw again
  • Layer 2: Balance is still 1 (not yet zeroed), transfers 1 ETH again → triggers again...
  • ...After 10 layers, the contract is emptied, and finally, each layer executes "set balance to zero" sequentially.

The attacker deposits 1 and withdraws 10.

3.2 Fix: Checks-Effects-Interactions

function withdraw() external {
    uint256 amount = balances[msg.sender];   // Check
    require(amount > 0);
    balances[msg.sender] = 0;                // Effect: update balance first
    (bool ok, ) = msg.sender.call{value: amount}("");   // Interaction: interact with external only at the end
    require(ok);
}

When the recipient tries to reenter, the balance is already 0, and require fails directly.

3.3 Reentrancy Guard

modifier nonReentrant() {
    require(!locked);
    locked = true;
    _;
    locked = false;
}

OpenZeppelin's ReentrancyGuard follows this logic. It is a second line of defense and cannot replace the correct order of operations.

3.4 More subtle variants

Variant Description
Cross-function reentrancy Calling a different function transfer of the same contract within the callback of withdraw, where both functions share an unupdated state
Cross-contract reentrancy Multiple contracts share state; Contract A calls Contract B in a callback, and Contract B reads data from A that has not yet been updated
Read-only reentrancy The callback calls a view function (such as "current pool price") that lacks a lock but returns an incorrect intermediate value; other protocols relying on this for pricing will be deceived
Token callbacks ERC-777, ERC-721 / 1155 onReceived callbacks, allowing "token transfers" to also trigger recipient code

3.5 Testing Methods


Summary

Layer Key points
Intuition Dispensing cash before updating the ledger, allowing repeated withdrawals in between
History The DAO caused the Ethereum fork; the Curve incident shows that compilers can also fail
Fix Checks → Effects → Interactions; plus a reentrancy guard
Variants Cross-function, cross-contract, read-only reentrancy, and token callbacks