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:
- Layer 1: The Intuition: An ATM that dispenses cash before deducting the balance
- Layer 2: Real-World Use: The DAO, various variants, and the 2023 Curve compiler incident
- 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:
- Check if the balance is sufficient;
- Dispense cash;
- 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
- The DAO (2016): A decentralized investment fund that raised about 14% of the circulating ETH at the time. The attacker exploited a reentrancy vulnerability in the split function to withdraw funds repeatedly. After intense debate, the Ethereum community chose a hard fork to return the stolen funds; dissenters continued to maintain the original chain, known as Ethereum Classic. This event also sparked the long-standing "Code is Law" debate.
- Curve / Vyper (2023): In this case, the contract code itself included a reentrancy guard, but the compiler had a bug in certain versions when implementing the guard, meaning the lock effectively did not work. This demonstrates that security must be verified all the way through the toolchain.
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→ callswithdrawagain - 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
- Write a dedicated "malicious recipient" contract in unit tests that attempts to reenter every external function during the callback;
- Static analysis tools (such as Slither) can automatically flag code that "modifies state after an external call";
- Invariant testing: "The contract's ETH balance ≥ the sum of all user balances" must hold true under any call sequence (see Smart Contract Auditing and Formal Verification).
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 |