Smart Contract 취약점 [2] Access Control

Access Control

Access Control 취약점은 Smart Contract에서만 발생하는 취약점이 아니라, 일반적인 프로그램에서 나타나는 취약점이다. (OWASP TOP 10에도 등장하는 취약점)

 

해당 취약점은, 잘못된 접근 제어를 통해 공격자가 contract의 소유자가 되는 경우라고 보면 된다.

여러 경우가 있겠지만, 일반적으로 공격자가 외부에 공개된 함수 호출을 통해 contract의 소유자가 되곤 한다. 

 

정말 간단한 예시로 아래 코드의 경우를 생각할 수도 있다.

function initContract() public {
	owner = msg.sender;
}

 

아래 코드를 보면, contract의 owner를 msg.sender로 세팅하는데, 이를 public function으로 정의해둔 상황이다. 이렇게 되면 누구나 해당 contract의 소유자가 되는 것이 가능하다.

 

 

dasp에서는 Access Control 취약점이 발생하는 경우로 아래 3가지 예시를 들고 있다.

  • contract가 tx.origin으로 사용자를 검증할 때
  • 긴 require 로직을 다룰 때
  • proxy library 및 proxy contract에서 delegatecall을 잘못 사용할 때

 

해당 취약점의 자세한 예시는 실제 있었던 두 사례를 바탕으로 살펴보자

  • Parity Multi-sig Bug
  • Rubixi

1. Parity Multi-sig Bug

우선, Parity Multi-sig 사건의 원인을 이해하기 위해서는 몇몇 기본 지식들이 필요하므로 이들에 대해 먼저 살펴보고 가도록 하자.

 

Multisig Wallet이란?

트랜잭션을 할 때 "특정 수"의 사람이 동의를 해야 트랜잭션을 실행시키는 것

 

Library Smart Contract란?

Smart Contract를 정의할 때는 일반적인 Contract가 아닌, Library Contract로 정의할 수 있다.

이는 다른 프로그래밍 언어에서의 Library와 같은 용도이며, 동일한 코드를 여러 번 반복하지 않기 위함이다.

 

실제로 Parity Multi-sig Wallet은 크게 2가지 Contract가 존재하는데 사용자들의 지갑의 역할을 하는 Wallet과 그 Wallet에서 사용할 수 있게끔 본질적인 기능들을 구현해둔 WalletLibrary가 있다.

 

여러 사용자가 Wallet을 만들더라도 그 본질적인 내용은 동일할 것이기 때문에, 가운데 WalletLibrary라는 큰 서버를 하나 두고 모든 Wallet은 해당 Library를 참조하면서 실행한다고 보면 된다.

 

Delegate Call이란?

Library Smart Contract를 사용할 때는 Delegate Call에 대한 이해가 필요하다.

간단하게 말하면, Library Smart Contract 내부에 존재하는 함수를 호출하려고할 때, msg.sender 및 msg.value의 값을 그대로 유지한 채 코드 흐름만 Library에서 가져온다고 생각하면 된다.

 

간단하게 예를 들어 보자.

사용자(A)와 Wallet(B)와 WalletLibrary(C)가 있다고 가정하자. 그러고 A가 B에 존재하는 함수를 호출한다면 msg.sender는 자동적으로 A의 정보로 세팅될 것이다.

그런데, B의 내부적으로 C를 호출한다고 하면, 그러면 msg.sender가 자동적으로 B의 정보로 세팅되게 된다.

 

하지만 의도된 바로는 Library의 함수를 호출할 때는 msg.sender 정보가 변화하지 않아야 한다.

이럴 때 사용하는 것이 바로 Delegate Call이다.

 

이제 예시 코드를 보면 바로 이해가 될 것이다.

pragma solidity ^0.4.23;

contract CalleeContract {
    uint256 public n;
    
    function callMe(uint256 _n) payable public {
        n = _n;
        emit MsgValue(msg.value);
        emit MsgSender(msg.sender);
    }
    
    event MsgValue(uint256 value);
    event MsgSender(address sender);
}

contract CallerContract {
    uint256 public n;
    function delegatecallCalleeContract(address _calleeContract, uint256 _n) payable public {
        _calleeContract.delegatecall(bytes4(keccak256("callMe(uint256)")), _n);
    }
    
    function callCalleeContract(address _calleeContract, uint256 _n) payable public {
        _calleeContract.call(bytes4(keccak256("callMe(uint256)")), _n);
    }
}
//https://gist.github.com/jasonkim-tech/d391d02ef5b3d2554e1131fd57ca5425#file-delegatecall-sol

위 코드의 CallerContract에는 2개의 함수 ( delegatecallCalleeContract / callCalleeContract )가 존재한다.

 

여기서 delegatecallCalleeContract함수를 호출한 경우는 callMe함수 내에서도 기존 함수 호출자의 정보(msg.sender /  msg.value)가 유지된다.

하지만, callCalleeContract 함수의 경우, msg.senderCallerContract의 주소로 설정될 것이며, msg.value도 따로 넘겨주지 않기 때문에 0으로 설정될 것이다.


Parity Multi-sig Bug #1

이제 Parity Multi-sig Bug에 대해 살펴보도록 하자.

Parity Multi-sig 사건은 총 2번 발생하였는데, 그 중 첫번째는 init 함수의 접근 제어를 제대로 하지 않아서 발생하였다.

지갑의 주인을 지정하기 위한 init 함수는 사실상 contract가 만들어질 때 1번만 호출되면 되기 때문에 굳이 외부에서 호출할 수 있도록 할 필요가 없다.

 

그런데 아래 코드를 보면 initWallet 함수가 외부에서도 호출할 수 있도록 되어 있어서, 임의의 공격자가 해당 multi-sig wallet의 주인이 될 수 있는 것이다.

즉, 공격자는 initWallet 함수 호출을 통해 Wallet의 owner가 된 후, 해당 Wallet에 있는 모든 돈을 탈취하는데에 성공하였다.

  function initMultiowned(address[] _owners, uint _required) {
    m_numOwners = _owners.length + 1;
    m_owners[1] = uint(msg.sender);
    m_ownerIndex[uint(msg.sender)] = 1;
    for (uint i = 0; i < _owners.length; ++i)
    {
      m_owners[2 + i] = uint(_owners[i]);
      m_ownerIndex[uint(_owners[i])] = 2 + i;
    }
    m_required = _required;
  }
 
    function initWallet(address[] _owners, uint _required, uint _daylimit) {
    initDaylimit(_daylimit);
    initMultiowned(_owners, _required);
  }


  //https://github.com/openethereum/parity-ethereum/blob/4d08e7b0aec46443bf26547b17d10cb302672835/js/src/contracts/snippets/enhanced-wallet.sol#L216

 

Parity Multi-sig Bug #2

2번째 Bug는 공격자가 돈을 탈취한 것이 아니라, 모든 지갑 자체가 동결되어서 더이상 사용할 수 없게된 사건이다.

 

아래 코드는 WalletLibrary의 initWallet 함수이다. 앞서 소개한 Parity Multi-sig #1을 패치하기 위해 initWallet 함수에 only_uninitialized 코드가 추가된 것을 볼 수 있다.

 

  // constructor - just pass on the owner array to the multiowned and
  // the limit to daylimit
  function initWallet(address[] _owners, uint _required, uint _daylimit) only_uninitialized {
    initDaylimit(_daylimit);
    initMultiowned(_owners, _required);
  }

//https://github.com/openethereum/parity-ethereum/blob/b640df8fbb964da7538eef268dffc125b081a82f/js/src/contracts/snippets/enhanced-wallet.sol

 

앞서 소개한 Parity Multi-sig Bug #1을 패치한 후에, 패치된 WalletLibrary를 다시 배포하였다.

(Jul-20–2017 04:39:46 PM +UTC)

그 후 이제 많은 사람들이 지갑을 생성하여 사용하고 있었다.

그런데, 여기서 Parity는 정말 상상할 수 없는 실수를 하였는데, 바로 WalletLibrary Contract의 initWallet 함수 호출을 하지 않아서 WalletLibrary의 owner가 존재하지 않는 상황이었다.

 

일반적으로 WalletLibrary의 initWallet함수는, 각 Wallet에서의 DelegateCall을 통해서 호출하여 Wallet의 owner를 지정하기 위해 사용된다. 그런데 initWallet함수는 DelegateCall만으로 호출될 수 있는 것이 아닌, 직접적으로 호출할 수 있는 함수이다.

 

즉, 누군가가 WalletLibrary의 initWallet 함수를 호출하게 되면 WalletLibrary의 owner가 되는 상황이었다.

 

약 4개월 뒤 (Nov-06–2017 02:33:47 PM +UTC), 이를 알아챈 누군가가 WalletLbirary의 initWallet 함수를 호출하면서 WalletLibrary의 owner가 된다. 그런데, Library의 owner가 된다고 해서 다른 사람들의 모든 Wallet의 주인이 되는 것은 아니기 때문에 돈을 모두 빼앗아 갈 위험은 없는 상황이다.

 

그런데 중요한 포인트는, WalletLibrary에 구현되어 있는 하나의 함수였다.

바로 아래의 kill 함수이다.

  function kill(address _to) onlymanyowners(sha3(msg.data)) external {
    suicide(_to);
  }

 

해당 함수를 호출하게 되면, WalletLibrary Contract가 suicide하게 되고, 더이상 사용할 수 없게 된다.

앞서 언급했던 것처럼 Parity Multi-sig Wallet은 모든 Wallet이 WalletLibrary에 접근하면서 내부 로직이 실행되는데, WalletLibrary가 사라져 버리면서 모든 Wallet 또한 사용할 수 없는 상태에 빠지게 된다.

 

WalletLibrary의 owner가 된 사람은 약 1시간 후(Nov-06–2017 03:25:21 PM +UTC), kill 함수를 호출하게 되고 이 때부터 현재까지 해당 Wallet은 돈을 넣을 수도, 뺄 수도, 보낼 수도 없는 상태에 놓여 있다.


Rubixi

Rubixi는 Ponzi Game에 사용되는 하나의 Smart Contract 였다. 

해당 Contract의 코드는 상당히 길지만, 일부만 들고와서 살펴보자.

contract Rubixi {

        //Declare variables for storage critical to contract
        uint private balance = 0;
        uint private collectedFees = 0;
        uint private feePercent = 10;
        uint private pyramidMultiplier = 300;
        uint private payoutOrder = 0;

        address private creator;

        //Sets creator
        function DynamicPyramid() {
                creator = msg.sender;
        }
//https://etherscan.io/address/0xe82719202e5965Cf5D9B6673B7503a3b92DE20be#code

아마 위 코드를 보았을 때, Solidity에 대해 지식이 있는 사람은 어떤 취약점이 존재하는지 단번에 파악할 수 있다. 바로 Constructor 이름을 잘못 지정한 경우이다. 실제로 Constructor를 정의할 때 contract 명과 동일하게 함수를 만들어서 정의하는 방법도 있다.

그런데, Rubixi에서는 Constructor이름을 DynamicPyramid로 하여서 누구나 해당 함수를 호출하여 creator, 즉 contract의 owner가 될 수 있는 상황이었다.

 

결과적으로, 특정 공격자가 해당 취약점을 이용해서 Contract 내부의 모든 돈을 가져가버리면서 contract의 돈이 모두 사라지게 되었다.


위에서 언급한 것 뿐만 아니라, tx.origin과 관련된 취약점을 이용한 SCTF 2018 - BankRobber 문제에서도 Access Control 관련 취약점이 발생하는데, 이에 대해서는 이후 추가적으로 다루도록 하겠다.

 

 


참고

https://medium.com/haechi-audit-kr/parity-multisig-wallet-%EB%8F%99%EA%B2%B0%EA%B3%BC-eip999-4dc303cd8e27

https://hackingdistributed.com/2017/07/22/deep-dive-parity-bug/

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

 

  Comments,     Trackbacks