Skip to content

Reentrancy and The DAO

The overview argued that a smart contract is uniquely dangerous because it runs untrusted code against persistent state that it can never take back. This page is the proof. We take the single most famous bug in the history of the chain — the one that cracked Ethereum in two before it was a year old — and rebuild it from first principles until it feels obvious.

The throughline of this whole book is how untrusting strangers agree on the state of a shared world computer, and what it costs to run one. The DAO is where that question got its most expensive answer. A contract’s books said one thing; an attacker made the chain do another; and when the two disagreed, the community had to decide, out loud, whether the code or the people were the final authority. The bug is a few lines. The fallout reshaped Ethereum.

Where the danger enters: one contract calling another

Section titled “Where the danger enters: one contract calling another”

Recall from the account model that Ethereum has two kinds of account: externally-owned accounts (a human with a private key) and contract accounts (code plus a storage map). A contract can send value to an address, and it can call another contract mid-execution. That second power — one contract invoking another before its own work is finished — is the entire attack surface here.

The subtle part is what “send value” does when the recipient is a contract. In Ethereum, transferring ether to a contract account runs that contract’s code — historically its fallback function, the code that runs when a contract is called with no matching function. So this innocent-looking line:

// inside contract A
recipient.call{value: amount}(""); // "just send them their ether"

is not a passive bookkeeping move. It is a function call into code the recipient wrote. If recipient is an attacker’s contract, the attacker’s code now runs — synchronously, inside contract A’s transaction, with contract A’s execution paused mid-way. Whatever A had not yet done, it has not yet done. The attacker gets to act in that gap.

A: withdraw() runs ... reaches "send ether" ...
▼ control jumps INTO the recipient's code
Attacker.fallback() runs — with A frozen mid-function
▼ and it can call back into A, whose books are stale
A: withdraw() runs AGAIN (re-entered) before the first call finished

That re-entry — a function being entered again before its first invocation has returned — is reentrancy. It is not exotic; it is ordinary recursion. What makes it a vulnerability is when the re-entry happens relative to when the contract updates its own records.

Here is the pattern that drained The DAO, distilled to its essence. Read it slowly — the bug is the order of two lines.

mapping(address => uint256) public balances;
function withdraw() public {
uint256 amount = balances[msg.sender]; // 1. how much do they have?
// 2. INTERACTION: send the ether (this calls the recipient's code!)
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
// 3. EFFECT: only now zero their balance
balances[msg.sender] = 0;
}

The contract sends the money on line 2 and updates its own books on line 3. Between those two lines, the balance still reads full. Now watch an attacker exploit exactly that window.

The attacker deploys a small contract whose fallback function does one thing: call withdraw() again.

// attacker's contract
fallback() external payable {
if (address(victim).balance >= 1 ether) {
victim.withdraw(); // re-enter before balances[me] is zeroed
}
}

Trace the money. Say the attacker deposited 1 ETH, and the victim holds 100 ETH from other depositors.

depth 0 withdraw() amount = balances[attacker] = 1 ETH
└─ send 1 ETH ──► attacker.fallback() runs
depth 1 withdraw() amount = balances[attacker] = STILL 1 ETH (never zeroed!)
└─ send 1 ETH ──► attacker.fallback() runs
depth 2 withdraw() amount = balances[attacker] = STILL 1 ETH
└─ send 1 ETH ──► ... repeat ...
...until the victim is nearly empty or gas runs out...
(unwind) each nested call finally reaches "balances[attacker] = 0"
— but the ether is already gone, 1 ETH per level, ~100 times.

Because line 3 (balances[msg.sender] = 0) is below the external call, it never runs until the recursion unwinds — and by then the attacker has looped back through line 2 dozens of times, each time reading the same stale, non-zero balance and pulling another slice of ether that isn’t theirs. They deposited 1 ETH and walked out with the whole pool. The contract’s ledger was never wrong about what should happen; it was just updated too late to stop it from happening.

The bug is the order of operations, so the fix is the order of operations. Adopt one discipline in every function that touches external code:

  1. Checks — validate the request (require balance ≥ amount, require caller allowed, etc.).
  2. Effects — update this contract’s own state to reflect what is about to happen.
  3. Interactions — only now make external calls (send ether, call other contracts).

The rule is: update your own books before you hand control to anyone else. Re-entry becomes harmless, because by the time the attacker’s code runs, the balance it wants to re-read is already zero.

function withdraw() public {
uint256 amount = balances[msg.sender]; // CHECK: read the amount
require(amount > 0, "nothing to withdraw");
balances[msg.sender] = 0; // EFFECT: zero it FIRST
(bool ok, ) = msg.sender.call{value: amount}(""); // INTERACTION: last
require(ok, "transfer failed");
}

Now trace the attack again. The attacker’s fallback re-enters withdraw() — but balances[attacker] is already 0, so amount is 0, and there is nothing to send. The recursion is defused at depth 1. Same code, same attacker, reordered two lines: exploit dead.

This ordering is important enough that the next security page, Integer Overflow Before Solidity 0.8, and much of modern Solidity practice assume it as a baseline. We will return to checks-effects-interactions as a named pattern throughout this part.

Under the hood — reentrancy guards and mutexes

Section titled “Under the hood — reentrancy guards and mutexes”

Checks-effects-interactions is the first line of defence, but some functions genuinely must call out before they finish, and some attacks re-enter a different function than the one they left (cross-function reentrancy). For those, add a reentrancy guard: a mutex — a single boolean lock — that a function sets on entry and clears on exit, refusing to run if the lock is already held.

bool private locked;
modifier nonReentrant() {
require(!locked, "reentrant call");
locked = true; // lock before doing anything
_; // run the function body
locked = false; // unlock after it returns
}
function withdraw() public nonReentrant {
// ... even a re-entrant call is rejected: the lock is still held
}

The guard turns “am I already running?” into an explicit, checkable piece of state. A re-entrant call finds locked == true and reverts before it can do damage. In practice, well-audited contracts use both: checks-effects-interactions as the default discipline, and a nonReentrant guard on any function that moves value or calls untrusted code. Belt and suspenders, because on an immutable chain a single miss is unrecoverable.

The fork: code is law, versus social consensus

Section titled “The fork: code is law, versus social consensus”

The bug was understood within hours. The hard part was philosophical. The stolen ether sat in a child DAO, visible to everyone, protected only by the very immutability that Ethereum promised. Two irreconcilable positions emerged.

  • “Code is law.” The contract did exactly what its code said. That the code was buggy is the depositors’ problem, not the network’s. Reversing it — even to undo a theft — means the chain’s history is editable whenever enough people are upset, which destroys the one property that made it worth trusting.
  • “Social consensus is the final layer.” A chain exists to serve its community. A clear theft that the community overwhelmingly wants reversed should be reversed. Immutability is a means to an end, not a suicide pact.

In July 2016, the majority chose the second path: a hard fork — a coordinated, backwards-incompatible protocol change — that effectively moved the stolen funds to a recovery contract where original depositors could reclaim them. Most of the ecosystem (exchanges, developers, users) followed the forked chain. That chain is the Ethereum (ETH) we use today.

But not everyone agreed. A minority ran the original, un-forked software on principle — the theft stood, because on their chain the code was still law. That chain persisted as Ethereum Classic (ETC), a separate coin and network with its own community, continuing the exact history the fork had branched away from.

... common history ...
The DAO drained (17 Jun 2016)
┌─────────────┴─────────────┐
HARD FORK (Jul 2016) NO FORK
theft reversed theft stands
│ │
ETH ETC
"social consensus "code is law"
can override code" (original chain, unbroken)

The uncomfortable truth the fork exposed: immutability is a promise until enough of the community decides it isn’t. A blockchain’s rules are not enforced by physics; they are enforced by the people who choose to run the software. Most of the time that distinction never surfaces, and “immutable” behaves like a hard guarantee. The DAO was the moment it surfaced — and it showed that the ultimate authority over “the state of the shared world computer” is not the code, but the rough consensus of the strangers who agree to keep running it. That is the book’s central question, answered under maximum pressure.

The lens here is on the pattern (checks-effects-interactions and reentrancy guards) that this incident forced into standard practice.

  • Why does it exist? Because sending value or calling another contract hands execution control to code you don’t trust, mid-function, before your own state is settled. The pattern exists to make that handoff safe.
  • What problem does it solve? It removes the window in which an attacker can re-enter a function and act on stale internal state — the exact gap that drained The DAO.
  • What are the trade-offs? Ordering state updates before external calls is nearly free, but reentrancy guards cost extra storage writes (gas) and can be over-applied, blocking legitimate composability where one protocol should call back into another.
  • When should I avoid it? You never avoid checks-effects-interactions — it’s a default. You might skip an explicit guard on a function that makes no external calls and touches no shared lock, where the mutex would only burn gas.
  • What breaks if I remove it? The reentrancy door reopens. Any function that sends ether or calls untrusted code before finalising its books can be recursively drained — and on an immutable chain, that mistake is permanent.
  1. In the vulnerable withdraw(), which single line is out of order, and what does moving it fix?
  2. Why does sending ether to a contract account create a security risk that sending ether to a person (an EOA) does not?
  3. Walk the recursion: the attacker deposited 1 ETH into a pool holding 100 ETH. Why do they walk away with far more than 1 ETH, and what stops the loop?
  4. State checks-effects-interactions in your own words, and explain why a reentrancy guard is still sometimes needed on top of it.
  5. The fork split Ethereum into ETH and ETC. What principle did each chain choose, and what does the split reveal about the meaning of “immutable”?
Show answers
  1. The line balances[msg.sender] = 0; (the effect) sits below the external call (the interaction). Moving it above the call zeroes the balance before control is handed to the recipient, so a re-entrant call reads 0 and can withdraw nothing — the exploit is defused.
  2. Sending to a person’s account is passive bookkeeping — no code runs. Sending to a contract runs that contract’s code (historically its fallback function) synchronously, inside your transaction, with your function paused mid-way. That handoff of control to untrusted code, before your own state is finalised, is where reentrancy lives.
  3. Because line 3 that zeroes the balance never runs until the recursion unwinds, every re-entered call on line 1 reads the same stale, non-zero balance (1 ETH) and sends another slice on line 2. The attacker loops through the send repeatedly, extracting ~1 ETH per level, ~100 times — draining the whole pool. The loop stops when the victim is (nearly) empty or the transaction runs out of gas.
  4. Checks-effects-interactions: validate the request, then update your own state to reflect what’s about to happen, and only then make any external call — update your books before you hand control to anyone else. A reentrancy guard (a mutex) is still needed because some functions must call out before finishing, and because attackers can re-enter through a different function (cross-function reentrancy) that shares state; the guard’s single lock rejects any re-entry outright.
  5. Ethereum (ETH) chose social consensus can override code — the community hard-forked to reverse the theft. Ethereum Classic (ETC) chose code is law — the theft stood on the original, un-forked chain. The split reveals that immutability is enforced by the people running the software, not by physics: it is a promise that holds until enough of the community decides it doesn’t.