JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Reentrancy: the bug that drained The DAO

In June 2016 a single ordering mistake let an attacker withdraw the same ETH over and over and siphon ~3.6 million ETH out of The DAO. This guide dissects exactly how reentrancy works, walks the attack opcode by intent, and shows the two fixes every contract should use.

The $60-million callback

On 30 April 2016 a project called The DAO opened a crowdsale and, in a few weeks, pulled in 12.7 million ETH — about $150 million at the time — making it the largest crowdfunding event in history. It was a smart contract that let token holders pool money and vote on which projects to fund: code, not a company, holding the keys. Then on 17 June 2016 the balance started falling. Someone was draining it, a chunk at a time, and nobody could stop the contract because nobody could stop the contract — that was the whole point.

By the time it stopped, roughly 3.6 million ETH (~$60M then) had been moved into an attacker-controlled "child DAO." The thief never broke any cryptography. They found one line in the wrong place. The fallout split Ethereum itself: to claw the funds back, the community executed a hard fork at block 1,920,000 on 20 July 2016, creating the chain we now call Ethereum (ETH). The minority who refused — "the code's result is the only truth" — kept the un-forked chain as Ethereum Classic (ETC). One bug rewrote the map of crypto. That bug was reentrancy.

The vulnerable pattern: pay first, bookkeep later

Here is the heart of the bug, distilled into a tiny bank. Read `withdraw` carefully and watch the order of operations.

// VULNERABLE — do NOT deploy
pragma solidity ^0.8.20;

contract Bank {
    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, "nothing to withdraw");

        // INTERACTION happens BEFORE the EFFECT:
        (bool ok, ) = msg.sender.call{value: amount}("");  // hands control to msg.sender
        require(ok, "send failed");

        balances[msg.sender] = 0;                          // bookkeeping updated too late
    }
}
The classic vulnerable withdraw: it sends the ETH, then zeroes the balance.

The trap is the single line `msg.sender.call{value: amount}("")`. In the EVM, sending ETH to an address with `call` is not a passive bank transfer — it is a message call that runs code at the destination. If `msg.sender` is a contract, the EVM invokes its receive/fallback function, handing the attacker the program counter while our `withdraw` is frozen mid-execution, with `balances[msg.sender]` still showing the full amount. We have paid out but not yet recorded it. The contract's invariant — "the sum of `balances` equals the ETH I hold" — is temporarily false, and the attacker gets to act precisely during that window.

Walking the attack

An attacker only needs a contract whose `receive` function calls `withdraw` again. Suppose the Bank already holds 10 ETH from honest users.

contract Drainer {
    Bank public bank;
    constructor(address bankAddr) { bank = Bank(bankAddr); }

    function pwn() external payable {
        bank.deposit{value: 1 ether}();   // become a depositor of record
        bank.withdraw();                  // first, innocent-looking withdrawal
    }

    // the EVM calls this every time the Bank sends us ETH
    receive() external payable {
        if (address(bank).balance >= 1 ether) {
            bank.withdraw();              // re-enter BEFORE our balance is zeroed
        }
    }
}
The attacker's contract: its receive() re-enters withdraw() instead of finishing.
  1. Deposit 1 ETH. Now `balances[Drainer] = 1` and the Bank holds 11 ETH (10 honest + 1).
  2. Call withdraw. The Bank reads `amount = 1`, passes the `require`, and does `call{value: 1 ether}` to the Drainer — before zeroing the balance.
  3. The Drainer's receive() fires. It sees the Bank still has ≥ 1 ETH and calls `withdraw()` again. The Bank reads `balances[Drainer]` — still `1`, never zeroed — passes `require`, and sends another 1 ETH.
  4. Recurse. Each new transfer re-fires receive(), which re-enters withdraw(). The stack of frozen `withdraw` frames grows, each one still waiting to set the balance to 0.
  5. Drain. The loop repeats until the Bank's balance drops below 1 ETH (or the call stack / gas runs out). The Drainer walks away with all 11 ETH having deposited only 1 — a net theft of 10 ETH. Only then do the frozen frames unwind and finally run `balances[Drainer] = 0`, far too late.

The DAO's real bug lived in `splitDAO`, which paid out a reward via an external call before zeroing the caller's token balance — structurally identical to our toy. The attacker simply recursed through that gap. The lesson is brutal: a single mis-ordered statement turned an unstoppable contract into an unstoppable theft. (Each later post-mortem of a reentrancy exploit tells the same story with a different victim.)

Fix #1: checks-effects-interactions

The cheapest, most fundamental fix changes nothing but the order of three lines. Structure every state-changing function as Checks → Effects → Interactions: validate inputs (checks), update your own storage (effects), and only then touch the outside world (interactions). If you zero the balance before the external call, re-entering buys the attacker nothing — the second `withdraw` reads `0` and reverts.

function withdraw() external {
    uint256 amount = balances[msg.sender];   // CHECK
    require(amount > 0, "nothing to withdraw");

    balances[msg.sender] = 0;                // EFFECT — update state FIRST

    (bool ok, ) = msg.sender.call{value: amount}("");  // INTERACTION — last
    require(ok, "send failed");
}
The same function, reordered. Re-entry now finds a zero balance and reverts.

Now trace the attack again: the Drainer's `receive` re-enters, but `balances[Drainer]` is already `0`, so `require(amount > 0)` fires an EVM revert and the inner call fails. Crucially, the outer withdraw still succeeds — the attacker just gets their honest 1 ETH and nothing more. The invariant is never false across an external call, because the books are closed before the door is opened.

Fix #2: a reentrancy guard (mutex)

A reentrancy guard is a one-bit lock that makes a function (or a whole group of functions) refuse to run if it is already running anywhere on the call stack. It is a Solidity modifier that sets a flag on entry and clears it on exit, reverting any reentrant call in between.

contract Bank {
    mapping(address => uint256) public balances;

    uint256 private _status = 1;          // 1 = unlocked, 2 = locked

    modifier nonReentrant() {
        require(_status == 1, "reentrant call");
        _status = 2;                      // close the door
        _;                                // run the function body
        _status = 1;                      // open it again on exit
    }

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "nothing to withdraw");
        balances[msg.sender] = 0;                       // still keep CEI!
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "send failed");
    }
}
A nonReentrant mutex. The reentrant withdraw() now reverts on require(_status == 1).

Now the Drainer's re-entry hits `require(_status == 1)` while `_status` is still `2`, and reverts. Apply the same `nonReentrant` flag to every externally-callable function that touches the shared balances, and cross-function reentrancy dies too: the lock is global to the contract, not per-function.

A third structural defense is the pull-payment pattern: instead of pushing ETH to users inside your logic, record what each is owed and let them `claim()` it in a separate, isolated transaction. The withdrawal function then does one external call as its very last act, with nothing left to corrupt — and a recipient who reverts can only brick their own withdrawal, not the whole contract.

Reentrancy in the wild today

The DAO was 2016, but reentrancy never died — it mutated. Three modern forms catch even experienced teams:

  1. Token-callback reentrancy. ERC-777 and ERC-721 call hooks on the receiver (`tokensReceived`, `onERC721Received`) during a transfer — a built-in external call where you might not expect one. The Lendf.me / imBTC hack (April 2020, ~$25M) and Cream Finance (Aug 2021, ~$18.8M) both abused ERC-777 hooks to re-enter lending logic.
  2. Cross-contract reentrancy. The re-entry comes back through a different contract that shares state with yours (e.g. a lending market and its collateral module). Fei / Rari Capital's Fuse pools (April 2022, ~$80M) fell to a cross-contract reentrancy in the borrow flow — CEI inside one contract was not enough.
  3. Read-only reentrancy. During a callback the victim's state is temporarily inconsistent; an attacker calls a *`view`* function (like a pool's `get_virtual_price`) that reads that broken state and feeds a wrong number to a third protocol relying on it as an oracle. No state is written by the view — yet money still moves elsewhere.

Spotting and stopping it

Reentrancy is one of the most findable bugs once you know its shape. An auditor's reflex is to grep for every external call and ask one question at each: *what state is stale at this exact line, and what could a malicious callee do with it?* Make this your checklist:

  1. Order every function as checks → effects → interactions, with no state writes after an external call. Treat this as non-negotiable.
  2. Add a `nonReentrant` guard to every externally-callable function that moves value or touches shared state — and apply the same lock across functions that share that state.
  3. Watch the hidden external calls: ERC-777/ERC-721 receiver hooks, arbitrary token transfers, and any user-supplied callback or address. Assume any token may call back.
  4. Run static analysis (Slither's `reentrancy-eth` / `reentrancy-no-eth` detectors) and write property tests with a Foundry attacker contract; use invariant testing and fuzzing to assert "total balances == ETH held" can never break.

Reentrancy is the canonical example of why "the code ran exactly as written" is no comfort — the code was the vulnerability. The next guides in this rung tackle the other classic classes (arithmetic, access control, oracle/flash-loan), and the rung closes by showing how a professional audit weaves all of this into defense in depth. Master reentrancy first: if you can see the context switch hiding inside an innocent `call`, you can already think like an attacker.