Reentrancy and Shared State
Reentry during an external call
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.
Reentrancy can occur whenever control passes to another contract before an operation's state changes are complete. Review the state visible to every function that can be called during that interval.
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;
}
}
A reentrancy guard rejects nested calls to protected functions while its lock is active. It does not protect unguarded entry points or replace correct state ordering and accounting.
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;
}
Review every entry point that can read or change the affected state. Some need a shared guard; others need different state ordering or restrictions. Also consider external readers that may observe an inconsistent value during a callback.
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 and Shared State
1 / 5What is a reentrancy attack?