The DAO Hack
The original reentrancy attack — drained 3.6M ETH from a decentralized venture fund holding 15% of all ETH in existence.
Background
The DAO was a decentralized venture fund built on Ethereum. In 2016 it completed one of the largest crowdfunding campaigns in history, raising $150M (at the time). It held roughly 15% of all ETH in existence. The idea: token holders voted on which projects to fund. No company. No executives. Just code.
The Vulnerability
The splitDAO() function sent ETH to the caller before updating their balance in storage. An attacker's malicious contract could call back into splitDAO() from its fallback function before the balance was zeroed — draining ETH each time.
// VULNERABLE: sends ETH before updating balance
function splitDAO(uint _proposalID, address _newCurator) {
// ...
// ETH sent to attacker here — attacker re-enters splitDAO()
if (!msg.sender.call.value(
p.splitData[0].balanceInTheDAO * actualBalance /
p.splitData[0].totalSupply
)()) throw;
// Balance zeroed AFTER ETH sent — too late!
p.splitData[0].balanceInTheDAO = 0;
}The Attack — Step by Step
- Attacker deposits ETH into The DAO
- Attacker deploys a malicious contract with a fallback function that calls
splitDAO()again - Attacker calls
splitDAO()— ETH is sent to the malicious contract - The malicious contract's fallback fires, calling
splitDAO()again before the balance is zeroed - Each recursive call drains more ETH. The loop continues until the gas limit is hit
- Attacker drains ~3.6M ETH (~$60M at the time)
Impact
This was the most significant event in Ethereum's history. The community faced an impossible choice: accept the theft and uphold immutability, or fork the blockchain to restore the funds.
The controversial decision to hard-fork led to the split of Ethereum into:
- ETH (Ethereum): The forked chain where funds were restored
- ETC (Ethereum Classic): The original chain preserving immutability
Lessons Learned
Always update state (effects) before making external calls (interactions). Check preconditions first. This ordering prevents reentrancy by ensuring state is already updated when a re-entrant call occurs.
function withdraw(uint amount) public {
// 1. CHECKS: verify conditions
require(balances[msg.sender] >= amount);
// 2. EFFECTS: update state FIRST
balances[msg.sender] -= amount;
// 3. INTERACTIONS: external call last
payable(msg.sender).transfer(amount);
}Additionally: use OpenZeppelin's ReentrancyGuard modifier on sensitive functions as a defense-in-depth measure.