In June 2016, an attacker exploited a reentrancy vulnerability in The DAO — one of the first major Ethereum DAOs — and drained 3.6 million ETH worth $60 million at the time. This single hack led to the Ethereum hard fork that created Ethereum Classic.

Eight years later, reentrancy attacks are still one of the most common smart contract vulnerabilities. Let's break down exactly how they work.

What is a Reentrancy Attack?

A reentrancy attack happens when a malicious contract calls back into the victim contract before the first execution finishes. If the victim contract sends ETH to an external address before updating its state, the attacker can re-enter the function and drain funds repeatedly.

Think of it like a bank ATM bug — you withdraw $100, and before your balance updates, you withdraw another $100, and another, until the ATM is empty.

The Vulnerable Pattern

❌ Vulnerable Code
function withdraw() public { uint256 amount = balances[msg.sender]; require(amount > 0, "No balance"); // DANGER: External call BEFORE state update (bool success, ) = msg.sender.call{value: amount}(""); require(success); balances[msg.sender] = 0; // Too late! Attacker already re-entered }

When msg.sender.call is executed, if msg.sender is a malicious contract, its receive() or fallback() function runs. That function calls withdraw() again — and since balances[msg.sender] hasn't been set to 0 yet, it passes the check and drains more funds.

The Attack Contract

❌ Attacker Contract
contract Attacker { VulnerableBank public target; constructor(address _target) { target = VulnerableBank(_target); } function attack() public payable { target.deposit{value: msg.value}(); target.withdraw(); } // Called every time ETH is received receive() external payable { if (address(target).balance > 0) { target.withdraw(); // Re-enter! } } }
⚠️ How it plays out: Attacker deposits 1 ETH → calls withdraw() → contract sends 1 ETH → attacker's receive() fires → calls withdraw() again → contract sends another 1 ETH (balance still shows 1!) → repeats until contract is empty.

The Fix: Checks-Effects-Interactions Pattern

The golden rule: always update state BEFORE making external calls. This is called the Checks-Effects-Interactions (CEI) pattern.

✅ Fixed Code (CEI Pattern)
function withdraw() public { uint256 amount = balances[msg.sender]; require(amount > 0, "No balance"); // SAFE: Update state FIRST balances[msg.sender] = 0; // Then make the external call (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed"); }

Extra Protection: ReentrancyGuard

For extra safety, use OpenZeppelin's ReentrancyGuard. It adds a mutex lock that prevents re-entering any function marked with nonReentrant.

✅ Using ReentrancyGuard
import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; contract SafeBank is ReentrancyGuard { mapping(address => uint256) public balances; function withdraw() public nonReentrant { uint256 amount = balances[msg.sender]; require(amount > 0, "No balance"); balances[msg.sender] = 0; (bool success, ) = msg.sender.call{value: amount}(""); require(success); } }

Quick Checklist

Before deploying any contract that sends ETH, ask yourself:

✅ Do I update state BEFORE external calls?
✅ Do I use ReentrancyGuard on sensitive functions?
✅ Have I tested with a malicious contract that re-enters?
✅ Did I run an automated audit?

Check your contract for reentrancy now
AuditAI scans for reentrancy and 20+ other vulnerabilities instantly. Free forever.
⬡ Run Free Audit