Reentrancy: 60억 달러를 잃게 한 The DAO의 취약점

1. 개요

Reentrancy는 Solidity의 가장 고전적인 취약점 중 하나이며, The DAO에서 약 360만 개의 ETH(2016년 기준 약 60억 USD)를 잃게 한 원인이었습니다. 이 글에서는 reentrancy의 본질을 설명하고, 실제 코드로 공격 시나리오를 재현한 후, 가장 일반적인 세 가지 방어 방법을 제시합니다. 이 글을 마친 후에는 직접 공격에 취약한 contract를 작성하고, 이를 활용한 공격용 contract를 만들고, 패치가 적용된 contract를 작성할 수 있을 것입니다. 더불어 Foundry 테스트로 이를 증명할 수 있습니다.

2. Reentrancy란 무엇인가

Reentrancy는 contract A가 외부(external call)로 contract B를 호출했을 때, B가 A로 다시 호출을 돌려보내는 상황입니다. 이는 A가 상태(state)를 업데이트하기 전에 발생합니다. 만약 A가 예전 상태에 기반해서 로직을 결정한다면, B는 그 간극을 이용해 동일한 작업(예: 돈 인출)을 여러 번 반복할 수 있습니다.

핵심 포인트: Solidity에서 call, send, 데이터를 포함한 ETH 전송 등은 모두 수신자의 fallback/receive 함수를 실행할 수 있습니다. 이것이 바로 재진입(reentrancy) 사이클이 시작되는 지점입니다.

3. The DAO: 무엇이 일어났는가

The DAO는 분산형 투자 펀드로, 토큰 홀더들이 splitDAO 함수를 통해 자신의 ETH를 인출할 수 있었습니다. 이 함수는 먼저 ETH를 전송한 후, 나중에 인출자의 잔액을 업데이트했습니다. 공격자는 ETH를 받을 때 splitDAO를 다시 호출하는 fallback 함수를 가진 contract를 만들었습니다. 잔액이 아직 차감되지 않았으므로, 공격자는 단 하나의 트랜잭션 안에서 여러 번 돈을 인출할 수 있었습니다.

결과: 360만 개의 ETH가 인출되었고, Ethereum 커뮤니티는 이를 되돌리기 위해 hard fork를 실시해야 했습니다. 이는 논쟁을 낳았고 (Ethereum Classic의 탄생으로 이어짐) 결국 Ethereum을 분열시키게 되었습니다.

4. 코드로 취약점 재현하기

4.1. 공격에 취약한 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");

        // 상태 업데이트 전에 ETH를 송금 -> 취약점!
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        balances[msg.sender] = 0;
    }
}

withdraw()는 msg.sender.call{value: amount}("")를 먼저 호출한 후, 나중에 balances[msg.sender] = 0을 실행합니다. 만약 msg.sender가 fallback을 가진 contract라면, call 실행 중에 다시 withdraw()를 호출할 수 있습니다. 이 시점에서 balances[msg.sender]는 아직 차감되지 않았습니다.

4.2. 공격용 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(); // balances[this]가 0으로 업데이트되기 전에 다시 호출
        }
    }
}

실행 흐름:

  1. attack()은 1 ETH를 VulnerableBank에 입금한 후, withdraw()를 호출합니다.
  2. withdraw()는 1 ETH를 ReentrancyAttacker로 전송하여 receive()를 실행합니다.
  3. receive() 안에서, 공격자는 bank에 여전히 ETH가 남아있다는 것을 확인하고 withdraw()를 다시 호출합니다. 하지만 balances[attacker]는 여전히 1 ether입니다 (아직 업데이트되지 않음).
  4. 이 사이클은 bank가 돈을 잃거나 gas가 떨어질 때까지 계속됩니다.
공격 전 Bank 잔액: 10 ether
공격 후 Bank 잔액:   0 ether
공격자 잔액:          11 ether

주의: 위의 코드는 설명용입니다. mainnet이나 공개 테스트넷에 배포하지 마세요 — “테스트 목적”이라도 절대로 하면 안 됩니다.

5. 왜 그 코드가 공격당했는가

근본적인 원인은 Checks-Effects-Interactions 원칙 위반입니다. 먼저 확인(check)하고, 상태를 변경(effect)하고, 그 다음에 외부와 상호작용(interaction)해야 합니다. 그런데 VulnerableBank는 effect와 interaction의 순서를 뒤집었습니다. ETH를 전송(interaction)한 후에 잔액을 차감(effect)했습니다.

더불어, call{value: amount}("")는 남은 모든 gas를 수신자에게 전달합니다 (transfer/send는 2300 gas로 제한). 그래서 공격자는 fallback/receive에서 복잡한 로직을 실행할 수 있는 충분한 gas를 가지고 있습니다.

6. 방어 방법

6.1. Checks-Effects-Interactions

가장 간단하고 저렴한 방법: 외부 호출 이전에 상태를 업데이트합니다.

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

    balances[msg.sender] = 0; // Effect 먼저

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

공격자가 receive()에서 withdraw()를 다시 호출하면, balances[msg.sender]는 이미 0이 되어 있으므로 require(amount > 0)에서 바로 revert됩니다. 재진입 사이클이 차단됩니다.

6.2. OpenZeppelin의 ReentrancyGuard

더 복잡한 로직이 있고, 명확한 보호 계층을 원하면 nonReentrant modifier를 사용하세요.

<!-- Hardhat/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는 내부적으로 _status 변수를 사용합니다. 함수가 실행 중일 때, 상태를 “lock”으로 설정합니다. 그 사이에 같은 contract의 다른 nonReentrant 함수가 호출되면, transaction은 revert됩니다.

주의: nonReentrant는 같은 contract 내에서 같은 modifier를 가진 함수들만 보호합니다. cross-contract reentrancy는 보호하지 못합니다 (다른 contract로 호출한 후, 그 contract가 nonReentrant 없는 함수를 다시 호출하는 경우).

6.3. Pull Payment Pattern

자동으로 ETH를 전송하는 대신, 사용자가 별도의 단계로 자신의 금액을 인출하도록 하세요. 크레딧 누적 로직과 송금 로직을 완전히 분리합니다:

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

이 패턴은 위험을 분산시킵니다. 즉, withdraw()가 재진입당하더라도, 크레딧 로직(creditWithdrawal)은 이미 다른 곳에서 실행되었고 영향을 받지 않습니다.

7. 공격과 패치를 증명하는 테스트 작성

Solidity의 경우, JUnit에 해당하는 테스트 도구는 Foundry (forge-std)입니다. 아래 테스트는 VulnerableBank가 공격으로 비워지는 것을 증명하고, SafeBank는 안전함을 보여줍니다.

// 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}();

        // 공격자가 SafeBank를 대상으로 시도 -> 1회 이상 인출할 수 없음
        vm.expectRevert();
        vm.prank(victim);
        safeBank.withdraw(); // 이미 모두 인출한 후 두 번째 호출은 amount = 0이므로 revert
    }
}

테스트 실행:

forge test -vv

예상 결과:

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

첫 번째 테스트는 bank가 0으로 비워지고 공격자가 11 ETH를 받는 것을 확인합니다 — 정확히 The DAO 시나리오입니다. 두 번째 테스트는 SafeBank(Checks-Effects-Interactions 적용)에서 두 번째 인출 시도가 revert되는 것을 확인합니다. 잔액이 이미 0이기 때문입니다.

8. 자주 하는 실수

  • 단순히 call을 transfer/send로 바꾸되 상태 업데이트 순서는 그대로: transfer는 2300 gas로 제한되어 단순한 reentrancy는 막지만, 근본적인 해결책이 아닙니다. 많은 정상적인 지갑이나 contract가 2300 gas보다 많이 필요로 합니다 (예: proxy contract). 결과적으로 정상적인 거래가 실패합니다.
  • Cross-function reentrancy 간과: 서로 다른 두 함수가 같은 상태 변수를 읽고 쓸 때, 한 함수만 고치면 다른 함수에서 교차 공격이 가능합니다.
  • Read-only reentrancy: view 함수가 아직 업데이트되지 않은 상태를 반환하고, 다른 contract가 그것을 읽어서 다른 트랜잭션 중간에 잘못된 값을 사용하는 경우 — 직접 송금 없어도 피해를 줄 수 있습니다.
  • nonReentrant만으로 충분하다고 생각: 여러 contract가 상호작용하는 시스템이라면, 전체 호출 흐름을 검토해야 합니다. 한 contract의 보호만으로는 부족합니다.

주의: 어떤 단일 패턴도 “완전히 안전”하다고 생각하면 안 됩니다. 항상 Checks-Effects-Interactions과 nonReentrant를 조합하여 외부 호출과 돈이 오가는 함수를 보호하세요.

9. 결론

The DAO 사건은 값비싼 교훈입니다. 한 가지 사소해 보이는 순서 문제 (돈 전송 후 기록 업데이트)가 전체 Ethereum 커뮤니티를 hard fork하게 만들었습니다. Checks-Effects-Interactions 원칙, nonReentrant modifier, pull payment pattern은 이 문제를 해결하기에 충분한 세 가지 기본 도구입니다. 외부 호출을 포함하는 돈 관련 함수를 작성할 때마다 스스로에게 물어보세요: “내 상태가 외부 호출 전에 이미 업데이트되었는가?”

soliditysmart-contractsecurityreentrancyblockchain