Smart Contract 취약점 [1] Re-entrancy

최근 Smart Contract에 관심이 생겨서 공부를 시작하고 있다.

그 중, https://dasp.co/에 나와 있는 Smart Contract 취약점 10개에 대해 한번 정리하면서 공부하고자 한다.

 

Re-entrancy

Re-entrancy는이더리움에서 발생하는 취약점 중 가장 널리 알려진 취약점이다.

간단히 말해, 외부 컨트랙트에서 취약한 함수를 호출하고 그 실행과정 내에서 다시 취약한 함수를 호출하는 현상이다. 

 

재귀 함수를 떠올린다면 쉽게 이해할 수 있을 것이다.

 

예시를 한번 살펴보자

function withdraw(uint _amount) {
	require(balances[msg.sender] >= _amount);
	msg.sender.call.value(_amount)();
	balances[msg.sender] -= _amount;
}

위 코드를 보면 로직은 상당히 단순하다.

  1. amount라는 인자와 함께 withdraw() 함수를 호출
  2. 호출한 사람이 amount보다 많은 돈이 있는지 확인
  3. amount 만큼 sender에게 ETH 전송
  4. 보낸 만큼 balance에서는 삭감

 

그런데, 만약 sender가 malicious contract라면 많은 행위가 가능하다.

Malicious Contract의 Fallback 함수에 "돈을 받고 withdraw 함수를 다시 호출해"라는 코드가 있다면??

그렇다면 재귀함수처럼 계속 withdraw() 함수가 실행되고, balances[msg.sender] -= amount 코드는 실행되지 않기 때문에 무한정 돈을 빼내는 것이 가능해진다.


Re-entrancy 종류

이러한 Re-entrancy 공격도 점점 발전하면서 크게 2가지 형태로 분류되고 있다.

 

1. 단일 함수에서의 Re-entrancy

Re-entrancy 공격이 처음 발생했을 때는 위에서 제시한 예시처럼 "단일 함수를 반복 호출"하는 형태로 이루어졌다.

위와 다른 예시를 하나 더 살펴보도록 하자.

// INSECURE
mapping (address => uint) private userBalances;

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    (bool success, ) = msg.sender.call.value(amountToWithdraw)("");
    require(success);
    userBalances[msg.sender] = 0;
}

만약 앞서 제시했던 코드에서 Re-entrancy 공격을 이해했다면, 형태만 다를 뿐 동일한 공격이 가능한 코드임을 짐작할 수 있다.

 

취약한 withdrawBalance 함수가 있는 contract를 공격하는 시나리오는 아래와 같다.

  1. Fallback 함수에서 돈을 수령한 후 withdrawBalance 함수를 호출하는 malicious contract를 준비한다.
  2. malicious contract에서 해당 취약한 contract에 돈을 일부 넣어 userBalances 값이 존재하게끔 세팅한다.
  3. malicious contract에서 withdrawBalance 함수를 호출한다.
  4. 그러면 userBalances[msg.sender] = 0 코드가 실행되기 전에 내부에서 반복적으로 withdrawBalance 함수가 실행되면서 처음 넣었던 양의 돈을 반복적으로 받을 수 있게 된다.

 

해당 공격을 막기 위해 코드를 수정하는 방법은 간단하다.

userBalances[msg.sender] = 0 코드를 먼저 실행한 후 돈을 송금한다면 재귀적으로 함수가 호출되더라도 amountToWithdraw가 0으로 세팅되어 돈을 반복적으로 가져가는 것은 불가능할 것이다.

 

 

2. 여러 함수에서의 Re-entrancy

기존에는 하나의 함수를 반복적으로 호출했다면, 이후에는 다른 두 함수를 호출하는 Re-entrancy 공격이 등장했다.

예시 코드를 살펴보자

// INSECURE
mapping (address => uint) private userBalances;

function transfer(address to, uint amount) {
    if (userBalances[msg.sender] >= amount) {
       userBalances[to] += amount;
       userBalances[msg.sender] -= amount;
    }
}

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    (bool success, ) = msg.sender.call.value(amountToWithdraw)("");
    require(success);
    userBalances[msg.sender] = 0;
}

앞서 제시한 두 Re-entrancy 공격을 이해한 상태라면, 해당 코드에서의 공격 또한 바로 인지할 수 있을 것이다. 

withdrawBalance 내부의 msg.sender.call.value함수를 호출할 때, transfer함수가 호출되게 한다면 돈을 인출한 후 그 돈을 다른 사람에게 보내게 되면서 돈을 2배로 늘리는 것이 가능해진다.

 

위의 withdrawBalance 함수를 안전하게 변경하는 방법은, userBalances[msg.sender] = 0 코드를 msg.sender.call.value(amountToWithdraw)를 호출하기 전에 실행하면 된다.


Re-entrancy 취약점을 방지하는 법

1. Internal Work 이후 External Work

해당 방법은 위에서 여러번 언급했던 취약점 방지 방법이다. 만약 돈을 송금하는 로직을 구현해야 한다면, Balance를 변화시킨 후에 돈을 송금하는 함수를 호출한다면 Re-entrancy의 악용을 방지할 수 있다.

 

하지만, Re-entrancy 공격은 위에서 언급한 것처럼 단일 함수 내에서만 발생하는 취약점이 아니다.

그러므로 단순히 external function을 호출하는 함수 뿐 아니라, external function을 호출하는 함수를 호출하는 함수를 구현할 때에도 주의하여야 한다.

 

예시를 한번 살펴보자.

// INSECURE
mapping (address => uint) private userBalances;
mapping (address => bool) private claimedBonus;
mapping (address => uint) private rewardsForA;

function withdrawReward(address recipient) public {
    uint amountToWithdraw = rewardsForA[recipient];
    rewardsForA[recipient] = 0;
    (bool success, ) = recipient.call.value(amountToWithdraw)("");
    require(success);
}

function getFirstWithdrawalBonus(address recipient) public {
    require(!claimedBonus[recipient]);

    rewardsForA[recipient] += 100;
    withdrawReward(recipient); 
    claimedBonus[recipient] = true;
}

withdrawReward함수를 살펴보면, 내부에 recipent.call.value를 호출하는(external function 호출) 코드가 있다. 그런데 해당 부분만 본다면 rewardsForA[recipent] = 0 코드, 즉 Internal Work를 먼저 수행한 이후에 External Work를 수행하게끔 되어 있다. 즉, Secure하게 구현되어 있다는 것이다.

 

그러나, 취약점은 getFirstWithdrawlBonus 함수에서 발생한다.

이는 withdrawReward(recipent) 코드와 climedBonus[recipent] = true 코드의 위치 때문에 발생한다.

getFirstWithdrawlBonus 함수에서 withdrawReward 함수를 호출하고, withdrawReward 함수의 recipent.call.value 부분에서 re-entrancy를 이용하여 getFirstWithdrawalBonus 함수를 다시 호출한다면, 아직 require(!claimedBonus[recipent]) 부분을 만족시키기 때문에 다시 Bonus를 받게 된다.

즉, 계속해서 100이라는 양의 rewards를 받을 수 있게 된다.

 

이를 해결하려면, claimedBonus[recipent] = true 를 실행한 이후에 withdrawReward(recipent) 함수를 호출하면 된다.

 

이처럼 단순히 external function를 사용하는 함수만 주의해야하는 것이 아니라, 재귀적으로 보았을 때 external function를 사용한다면, 그 모든 함수에서 re-entrancy 취약점을 주의해야 한다.

 

2. Mutex 사용

다른 프로그래밍 언어를 접해보았다면, Mutex 혹은 lock에 익숙할 것이다. 실제로 race-condition을 방지하기 위해 주로 사용하는데, re-entrancy 또한 비슷한 계열의 취약점이기 때문에 mutex를 통해 공격을 방지할 수 있다.

 

예시를 한번 살펴보자.

mapping (address => uint) private balances;
bool private lockBalances;

function deposit() payable public returns (bool) {
    require(!lockBalances);
    lockBalances = true;
    balances[msg.sender] += msg.value;
    lockBalances = false;
    return true;
}

function withdraw(uint amount) payable public returns (bool) {
    require(!lockBalances && amount > 0 && balances[msg.sender] >= amount);
    lockBalances = true;

    (bool success, ) = msg.sender.call.value(amount)("");

    if (success) { // Normally insecure, but the mutex saves it
      balances[msg.sender] -= amount;
    }

    lockBalances = false;
    return true;
}

위 코드를 보면 전역으로 lockBalances라는 bool 값이 있는 것을 볼 수 있다.

withdrawdeposit 함수의 시작과 끝 부분을 보면 lockBalances 값을 true / false로 세팅한다.

또, 두 함수의 시작 부분에 require(!lockBalances)가 존재하는데, 이는 함수 하나가 종료되기 전에 다른 함수가 내부적으로 호출되는 행위 자체를 차단해 버린다.

 

하지만 mutex를 사용할 때는 정말 주의해서 코드를 작성해야 한다.

아래는 mutex를 위험하게 사용하는 예시이다.

contract StateHolder {
    uint private n;
    address private lockHolder;

    function getLock() {
        require(lockHolder == address(0));
        lockHolder = msg.sender;
    }

    function releaseLock() {
        require(msg.sender == lockHolder);
        lockHolder = address(0);
    }

    function set(uint newState) {
        require(msg.sender == lockHolder);
        n = newState;
    }
}

만약 위의 상황에서 getLock 함수를 호출한 후에 releaseLock 함수를 호출하지 않게 된다면, 해당 contract는 더 이상 동작할 수 없는 LOCK에 걸릴 수도 있다.

결과적으로 mutex를 이용하여 re-entrancy를 막으려고 한다면, "잘 구현"해서 사용해야 한다. 잘못 구현된 코드 함수 1개 때문에 contract 자체가 더이상 작동하지 을 수도 있기 때문이다.


https://consensys.github.io/를 보면 단순히 취약점에 대한 설명만 적혀있는 것이 아니라 개발 시 주의할만한 부분들을 잘 정리해두었다. 방어하는 방법을 잘 안다면, 취약점 또한 잘 찾을 수 있기 때문에 dasp.co에 제시된 10개의 취약점을 정리한 후에 시간이 허락한다면 해당 부분도 함께 공부하면서 정리할 예정이다.

 

참고

https://dasp.co/#item-1

https://consensys.github.io/smart-contract-best-practices/attacks/reentrancy/

 

  Comments,     Trackbacks