Hashtag Web3 Logo

Reentrancy and Shared State

9 min
advanced

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.

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;
 }
}

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

What is a reentrancy attack?