All articles
IntermediateSecurity

Reentrancy: The Bug That Killed Ethereum (Almost)

The most infamous vulnerability in smart contract history. How calling external contracts before updating state drains millions.

August 22, 2025
5 min read
Reentrancy: The Bug That Killed Ethereum (Almost) meme

Dive Deeper with AI

Click → prompt copied → paste in AI chat

June 17, 2016.

Someone drains $60 million from The DAO. In one transaction.

The attacker didn't guess a password. Didn't break encryption. Didn't bribe anyone.

They just called a function. Over and over. Before the contract could update its records.

This is reentrancy. The most infamous bug in blockchain history.


What is reentrancy?

Imagine a bank ATM that works like this:

  1. Check your balance: $1,000
  2. Dispense cash: gives you $1,000
  3. Update balance: now $0

Sounds reasonable. But what if you could interrupt step 3?

  1. Check your balance: $1,000
  2. Dispense cash: gives you $1,000
  3. Before balance updates, you trigger another withdrawal
  4. Check your balance: still $1,000 (not updated yet!)
  5. Dispense cash: gives you another $1,000
  6. Repeat until bank is empty
  7. Only now does balance update: $0

That's reentrancy. Calling back into a contract before it finishes executing.


How it works in Solidity

Here's a vulnerable smart contract:

contract VulnerableBank {
    mapping(address => uint) public balances;

    function withdraw() public {
        uint amount = balances[msg.sender];

        // Step 1: Send ETH
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);

        // Step 2: Update balance
        balances[msg.sender] = 0;
    }
}

The problem? It sends ETH before updating the balance.

When you send ETH to a contract, that contract's receive() or fallback() function runs. The attacker uses that to call withdraw() again:

contract Attacker {
    VulnerableBank bank;

    function attack() public {
        bank.withdraw();
    }

    receive() external payable {
        // This runs when bank sends ETH
        // Balance not updated yet - withdraw again!
        if (address(bank).balance > 0) {
            bank.withdraw();
        }
    }
}

Each withdrawal triggers another withdrawal. The loop continues until the bank is empty.


The pattern

Every reentrancy attack follows this pattern:

1. Attacker calls function
2. Contract checks conditions ✓
3. Contract makes external call (sends ETH, calls another contract)
4. External call triggers attacker's code
5. Attacker's code calls back into the original function
6. Contract checks conditions again... still ✓ (state not updated)
7. Repeat until drained
8. State finally updates (too late)

The vulnerability exists whenever:

  • External call happens before state update
  • The called address is attacker-controlled
  • The function can be called recursively

The fix: Checks-Effects-Interactions

The solution is simple. Change the order:

contract SafeBank {
    mapping(address => uint) public balances;

    function withdraw() public {
        uint amount = balances[msg.sender];

        // FIRST: Update state (effects)
        balances[msg.sender] = 0;

        // THEN: Make external call (interactions)
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);
    }
}

Now even if the attacker calls back, their balance is already 0. Nothing to withdraw.

This pattern is called Checks-Effects-Interactions:

  1. Checks - verify conditions (require statements)
  2. Effects - update state (storage changes)
  3. Interactions - call external contracts

Always in that order. Never deviate.


Reentrancy guards

For extra safety, use a mutex (mutual exclusion lock):

contract GuardedBank {
    bool private locked;
    mapping(address => uint) public balances;

    modifier noReentrant() {
        require(!locked, "No reentrancy");
        locked = true;
        _;
        locked = false;
    }

    function withdraw() public noReentrant {
        uint amount = balances[msg.sender];
        balances[msg.sender] = 0;
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);
    }
}

If someone tries to reenter, the locked flag blocks them.

OpenZeppelin provides ReentrancyGuard - use it.


Types of reentrancy

Single-function reentrancy

The classic. Reentering the same function (like our example).

Cross-function reentrancy

Reentering a DIFFERENT function that shares state:

function withdraw() public {
    uint amount = balances[msg.sender];
    msg.sender.call{value: amount}("");
    balances[msg.sender] = 0;
}

function transfer(address to, uint amount) public {
    // This can be called during withdraw!
    // Balance not updated yet...
    balances[msg.sender] -= amount;
    balances[to] += amount;
}

Cross-contract reentrancy

Reentering a different contract that trusts the vulnerable one.

Protocol A calls Protocol B. Protocol B calls back to Protocol A through a third path.

This is why composability in DeFi is both powerful and dangerous.


Real-world victims

The DAO (2016) - $60M The attack that started it all. Led to Ethereum's hard fork and creation of Ethereum Classic.

Uniswap/Lendf.Me (2020) - $25M ERC-777 token hooks enabled reentrancy.

Cream Finance (2021) - $18.8M Cross-contract reentrancy through flash loan callbacks.

Curve (2023) - $70M+ Vyper compiler bug in reentrancy locks across multiple pools.

The list goes on. Reentrancy remains in top 3 DeFi vulnerability categories.


When you're at risk

Your contract might be vulnerable if:

  • You make external calls (.call(), .transfer(), .send())
  • You interact with unknown tokens (some have callbacks)
  • You use flash loans (callbacks are part of the design)
  • You integrate with external protocols

Basically: almost every non-trivial contract.


Audit checklist

  1. Find all external calls. Search for .call, .transfer, .send, interface calls.

  2. Check if state updates happen before calls. If not, potential vulnerability.

  3. Look for cross-function issues. Can another function access the same state mid-execution?

  4. Check inherited contracts. Parent contracts might have issues.

  5. Consider flash loan callbacks. If you support them, you support reentrancy vectors.


The uncomfortable truth

Reentrancy is a known bug. It's been known since 2016. Yet it keeps happening.

Why?

  • Code complexity increases
  • Composability creates unexpected paths
  • Developers assume "someone else" will catch it
  • Pressure to ship fast

The bug is simple. Preventing it requires discipline.

Every DeFi hack from reentrancy is a failure of process, not knowledge.


For a detailed breakdown of The DAO hack specifically, see our analysis of the attack that split Ethereum in two.


Further reading:

Liked this article? Follow me!

@t0tty3
#reentrancy#security#smart-contracts#vulnerabilities#the-dao

Dive Deeper with AI

Click → prompt copied → paste in AI chat