Hashtag Web3 Logo

Reentrancy Deep Dive

9 min
advanced

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.

Reentrancy Attack Flow Attacker 1. withdraw() 4. receive() fires 5. withdraw() again! ↻ loop until drained 3. send ETH Vulnerable Vault 2. check balance ✓ send ETH to caller balance = 0 ↑ runs TOO LATE Fix: CEI Pattern 1. Check: require(bal > 0) 2. Effect: balance = 0 ← FIRST 3. Interact: send ETH Reentry sees balance = 0, reverts ✓

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 / 5

What is a reentrancy attack?