Reentrancy Deep Dive
The Most Famous Bug in Crypto History
On June 17, 2016, an attacker exploited a reentrancy vulnerability in The DAO — a decentralized investment fund — and drained 3.6 million ETH ($60M at the time). The hack was so catastrophic that the Ethereum community voted to hard-fork the entire blockchain to reverse it, splitting the network into Ethereum and Ethereum Classic.
Nine years later, reentrancy remains one of the most common smart contract vulnerabilities. Every auditor needs to understand this attack inside out.
How Reentrancy Works
The vulnerability exploits the order in which a contract performs operations.
Vulnerable Code
contract VulnerableVault {
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");
// BUG: Sends ETH BEFORE updating the balance
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// This line runs too late — the attacker already re-entered
balances[msg.sender] = 0;
}
}
The Attack
contract Attacker {
VulnerableVault vault;
constructor(address _vault) {
vault = VulnerableVault(_vault);
}
function attack() external payable {
vault.deposit{value: 1 ether}();
vault.withdraw();
}
// This function fires automatically when the vault sends ETH
receive() external payable {
if (address(vault).balance >= 1 ether) {
vault.withdraw(); // Re-enter before balance is set to 0
}
}
}
The Execution Flow
1. Attacker calls withdraw()
2. Vault checks balance: 1 ETH ✓
3. Vault sends 1 ETH to Attacker
4. → Attacker's receive() fires
5. → Attacker calls withdraw() AGAIN
6. → Vault checks balance: still 1 ETH (not updated yet!) ✓
7. → Vault sends 1 ETH to Attacker
8. → Attacker's receive() fires again
9. → ... repeats until vault is empty
10. balances[attacker] = 0 ← finally runs, but vault is already drained
The Three Defenses
Defense 1: Checks-Effects-Interactions Pattern
Reorder the code so state is updated before any external call.
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance"); // CHECK
balances[msg.sender] = 0; // EFFECT (update state first)
(bool success, ) = msg.sender.call{value: amount}(""); // INTERACTION
require(success, "Transfer failed");
}
Now when the attacker re-enters, balances[msg.sender] is already 0, so the require fails.
Defense 2: Reentrancy Guard (Mutex)
Use a lock that prevents re-entry.
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SecureVault is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] = 0;
}
}
The nonReentrant modifier sets a boolean lock. Any reentrant call sees the lock is active and reverts. This works even if the Checks-Effects-Interactions order is wrong.
Defense 3: Pull Over Push
Instead of sending ETH to users, let them withdraw it themselves.
// Instead of: send ETH directly during the function
// Do: credit an internal balance, let users call a separate withdraw
function claimRewards() external {
uint256 reward = calculateReward(msg.sender);
pendingWithdrawals[msg.sender] += reward;
}
function withdraw() external nonReentrant {
uint256 amount = pendingWithdrawals[msg.sender];
pendingWithdrawals[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
Cross-Function Reentrancy
The attacker does not have to re-enter the same function. If withdraw() sends ETH before updating the balance, the attacker's fallback can call transfer() — a completely different function — that reads the still-inflated balance.
// During reentrancy window, balance is still 10 ETH
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount); // passes with stale balance
balances[msg.sender] -= amount;
balances[to] += amount;
}
This is why reentrancy guards should protect ALL functions that share mutable state, not just the one making external calls.
Key takeaways
- Reentrancy occurs when a contract makes an external call before updating its own state, allowing the recipient to call back in and exploit the stale state.
- The DAO hack ($60M, 2016) used this exact pattern and caused Ethereum to hard-fork.
- Defense in depth: use Checks-Effects-Interactions ordering AND a reentrancy guard AND the pull pattern when possible.
- Cross-function reentrancy targets different functions that share state — guards must cover all of them.
Quiz: Reentrancy Deep Dive
1 / 5What is a reentrancy attack?