Allen Dev Blog

스마트 컨트랙트 사건/사고 사례
(1) MoonCatRescue

스마트 컨트랙트 사건/사고 사례<br>(1) MoonCatRescue

이더리움이 탄생한 이후 스마트 컨트랙트를 활용한 수많은 디앱(dApp) 이 탄생하게 되었습니다. 스마트 컨트랙트는 Solidity 등의 하이레벨 언어로 작성되어 컴파일되고, 이로 생성된 바이트코드가 블록체인에 올라가게 되며, 외부에서 호출을 하게되면 EVM 등 Virtual Machine 에 의해 실행됩니다.

스마트 컨트랙트도 코드에 의해 동작하기 때문에 스마트 컨트랙트를 작성할 때 개발자의 실수나 취약점이 존재할 수 있으며, 이로 인해 문제가 발생할 수 있습니다. 따라서 기업에서는 스마트 컨트랙트를 작성한 후 스마트 컨트랙트 코드에 문제가 없는지 오딧(Audit) 과정을 거치기도 합니다.

그럼에도 불구하고, 지금까지 수많은 디앱이 탄생하면서 많은 사건/사고들이 있었습니다. 스마트 컨트랙트를 잘못 작성해서 스마트 컨트랙트 내의 코인을 인출(withdraw)못하는 사건이 있는가하면, 플래시 론 등의 방법을 이용하여 시장을 조작하여 대량의 코인을 가져가는 해킹 방법(Oracle Manipulation)도 있습니다. 현재 이더리움과 이더리움 클래식으로 나눠진 이유도 이더리움 DAO 해킹 사건, 즉, 결국 스마트 컨트랙트의 취약점 때문이었습니다.

이번 포스트에서는 그 중에서 NFT 프로젝트의 사건 사고 중 하나인 MoonCatRescue 컨트랙트에 있었던 문제를 코드 레벨에서 살펴보겠습니다. 큰 사건사고는 아니지만 간단한 스마트 컨트랙트인만큼 분석하기 쉬운 편에 속하므로 스마트 컨트랙트 사건 사고 분석 첫 사례로 다루기 좋은 것 같습니다.

이 프로젝트의 스마트 컨트랙트에 있었던 문제는 디앱의 시스템이 무너질 정도의 크리티컬한 레벨의 문제점은 아니었으나 한가지 실수로 인하여 100.8ETH (2022.03.19 기준 약 3.5억) 정도의 금액이 스마트 컨트랙트에 영원히 인출하지 못한 채 묶이게 되었습니다.

스마트 컨트랙트 소스 구하기

스마트 컨트랙트는 컴파일되어 바이트코드로 블록체인에 올라갑니다. 이 바이트코드로도 분석할 수 있으며, 이런 방법을 리버스 엔지니어링(Reverse Engineering) 또는 더 줄여서 리버싱(Reversing) 이라고 합니다. Etherscan에서 스마트 컨트랙트의 주소를 검색하여 코드를 조회할 수 있습니다.

근데 위를 보면 알겠지만 바이트코드는 인간에게 전혀 친숙하지 않습니다. 이 코드는 과연 무엇을 하는 코드인가를 직관적으로 알수 없으며, 각 숫자 및 알파벳(16진수)마다 일일히 뭐하는 코드인지 분석해서 확인해야하므로 시간이 굉장히 오래 걸리는 방법입니다. 사실 이 방법은 전문성도 필요하고 엄청난 끈기도 필요한 최후의 방법으로 정말 Solidity 로 작성한 코드가 없을 때 사용하는 것이 좋습니다. 그럼 Solidity 로 작성한 코드는 어떻게 구할 수 있을까요?

운좋게도 사실 대부분의 디앱 프로젝트들은 소스 코드를 오픈 소스로 제공합니다. 오픈 소스는 Etherscan 또는 Github 에서 조회할 수 있습니다.

MoonCatRescue 프로젝트도 MoonCatRescue Github 을 검색하면, 다음과 같이 바로 뜨는 것을 확인할 수 있습니다. 이렇게 대부분의 디앱 프로젝트는 위와 같은 방법으로 Solidity 로 작성된 스마트 컨트랙트를 구할 수 있습니다.

하지만 Github 에 소스를 업로드한 것일 뿐 실제 블록체인에는 스마트 컨트랙트 코드가 다를 수 있습니다. 따라서 Github 보다는 먼저 Etherscan 을 살펴보는 것을 권장합니다.

스마트 컨트랙트를 올리게 되면 이더스캔에서는 바이트코드만 조회할 수 있지만 검증 과정을 거쳐서 Solidity 소스 코드를 조회할 수 있도록 할 수 있습니다. 작성자가 스마트 컨트랙트 Solidity 소스코드를 이더스캔에 제출하여 이를 컴파일했을 때 나온 바이트코드 값과 블록체인에 실제로 올라간 바이트코드 값이 일치하는지 판단하여 일치하면 이더스캔은 사이트 내에서 스마트 컨트랙트 솔리디티 코드를 아래와 같이 공개합니다.

이 소스코드는 컴파일해서 나온 바이트코드와 블록체인 내 배포된 스마트 컨트랙트 바이트코드가 일치된 것을 이더스캔이 보장해주었으니 이 코드를 분석하는게 가장 정확합니다!

소스코드 분석해보기

MoonCatRescue 는 2017년에 나온 프로젝트입니다. Non-Fungible Token(NFT) 프로젝트이지만 일반적인 NFT 프로젝트와 인터페이스가 다릅니다. 즉, ERC-721 이나 ERC-1155 의 표준을 따르지 않는데, ERC-721 은 2017년도에 제안되어 2018년 초에 승인된 프로젝트이기 때문으로 보입니다.

먼저, 컨트랙트가 생성될 때 실행되는 생성자 함수부터 봅시다. 생성자 함수는 컨트랙트가 생성될 때 한 번 자동으로 호출되는 함수입니다.

contract MoonCatRescue {
  ...
  address owner;
  ...

  uint256 public totalSupply = 25600;
  uint16 public remainingCats = 25600 - 256; // there will only ever be 25,000 cats
  uint16 public remainingGenesisCats = 256; // there can only be a maximum of 256 genesis cats
  uint16 public rescueIndex = 0;

  bytes5[25600] public rescueOrder;

  ...

  function MoonCatRescue() payable {
    owner = msg.sender;
    assert((remainingCats + remainingGenesisCats) == totalSupply);
    assert(rescueOrder.length == totalSupply);
    assert(rescueIndex == 0);
  }
}

생성자에서는 owner 에 msg.sender 값을 넣음으로써 컨트랙트를 생성한 지갑을 컨트랙트 오너로 지정합니다. 그리고, assert 로 변수 값이 제대로 설정되어있는지 체크합니다.

블록체인 내에서는 첫 번째 블록, 즉, 블록체인 내에서 처음으로 생성된 블록을 제네시스 블록이라고 합니다. 마찬가지로 변수명 remainingGenesisCats 를 보면 뭔가 처음에 디앱 내에서 생성되는 고양이 NFT 들이 있지 않을까 추측해볼 수 있습니다. 그리고, remainingGenesisCats 값은 256 이므로, 처음 생성되는 고양이의 숫자는 256개이겠네요!

그럼 이제 remainingGenesisCats 변수를 Ctrl + F 를 눌러서 어디에서 사용되는지 살펴봅시다.

addGenesisCatGroup 함수 내에서 remainingGenesisCats 변수가 사용되는 것을 볼 수 있습니다! 그럼 이 함수에 대해서 분석해봅시다!

  ...
  function addGenesisCatGroup() onlyOwner activeMode {
    require(remainingGenesisCats > 0);
    bytes5[16] memory newCatIds;
    uint256 price = (17 - (remainingGenesisCats / 16)) * 300000000000000000;
    for(uint8 i = 0; i < 16; i++) {

      uint16 genesisCatIndex = 256 - remainingGenesisCats;
      bytes5 genesisCatId = (bytes5(genesisCatIndex) << 24) | 0xff00000ca7;

      newCatIds[i] = genesisCatId;

      rescueOrder[rescueIndex] = genesisCatId;
      rescueIndex++;
      balanceOf[0x0]++;
      remainingGenesisCats--;

      adoptionOffers[genesisCatId] = AdoptionOffer(true, genesisCatId, owner, price, 0x0);
    }
    GenesisCatsAdded(newCatIds);
  }
  ...
  1. remainingGenesisCats 가 0 이 넘어가는지 체크하고, 0 이 되면 실행되지 못하도록 합니다. for 문에서 remainingGenesisCats 가 1씩 감소하고, 반복문을 통해 16번 실행되므로, 스마트 컨트랙트 함수가 호출될 때마다 remainingGenesisCats 는 16씩 감소합니다.

  2. remainingGenesisCats 는 초기값이 256입니다. 즉, 이 함수는 컨트랙트가 생성된 후에 최초 16번만 실행이 가능하고, 그 이후에는 값이 0이 되어버리므로 실행될 수 없습니다.

  3. for 문 안에서는 genesisCatIndexgenesisCatId 를 생성합니다. genesisCatIndex 는 0-255 까지 순서대로 값이 들어갈 것이고, genesisCatId 는 일부 연산을 통해 아이디 값을 생성하게 되는데 0xff00000ca7, 0xff01000ca7, 0xff02000ca7, …, 0xfffe000ca7, 0xffff000ca7 이렇게 총 256개의 Genesis Cat Id 가 생성될 것입니다. (함수 한 번에 16개 생성되므로 총 16번 호출을 통해 256개의 Genesis Cat 생성) 아이디 값은 중복이 되지 않게만 생성하면 되므로 아이디 값을 구할 때 왜 저런 연산 과정을 거칠까하는 의문은 크게 안가져도 될 것 같습니다.

  4. 생성된 고양이는 각각 순서에 따라서 rescueOrder 에 담기게 됩니다.

  5. balanceOf 값에서 0x00 의 값을 1 증가 시키는데, 지갑 주소 0x00…00는 아무도 소유하지 못한 주소를 의미합니다. 즉, 소유권 없는 지갑 0x00..00 의 고양이 개수를 1 증가 시킵니다.

  6. AdoptionOffer 를 생성하는데 이는 고양이를 시장에 내놓아서 팔겠다는 의미로 볼 수 있습니다. 가격은 함수 3번째 줄에서 설정되는데 가장 비싼 고양이(1-16번째 생성된 고양이)는 5.1ETH, 가장 싼 고양이(241-256번째 고양이)는 0.3ETH 가격에 분양가로 설정했습니다.

여기서 0x00..00 주소는 Null Address 라고도 하며, 소유권이 아무도 없는 주소입니다.

여기서 한가지 의문점이 들 수 있는데요. 제네시스 고양이 256개를 그냥 컨트랙트 생성 시 바로 발행하면 될텐데 왜 굳이 함수를 만들고, 16번씩 나눠서 트랜잭션을 16번 나눠서 생성하도록 만들었을까요? 이유는 가스비 제한 때문입니다. 트랜잭션 당 가스비 제한이 있기 때문에 컨트랙트 생성 시에 한꺼번에 생성하지 못하고 여러 번 함수를 호출함으로써 제네시스 고양이 256마리를 생성(minting) 했습니다.

제네시스 고양이들은 6번에 의해서 바로 분양 시장에서 팔리게 되는데 분양 시장에서 고양이를 구매하면 무슨 일이 벌어지는 지 살펴봅시다. 분양 시장에 내놓아진 고양이를 구매하는 함수는 acceptAdoptionOffer 를 통해서 제공하고 있는 것을 확인할 수 있는데요. 이 함수를 자세히 살펴봅시다.

먼저, AdoptionOffer 의 타입에 대해서 알아볼 필요가 있습니다.

  ...
  struct AdoptionOffer {
    bool exists;
    bytes5 catId;
    address seller;
    uint price;
    address onlyOfferTo;
  }
  ...
  /* accepts an adoption offer  */
  function acceptAdoptionOffer(bytes5 catId) payable {
    AdoptionOffer storage offer = adoptionOffers[catId];
    require(offer.exists);
    require(offer.onlyOfferTo == 0x0 || offer.onlyOfferTo == msg.sender);
    require(msg.value >= offer.price);
    if(msg.value > offer.price) {
      pendingWithdrawals[msg.sender] += (msg.value - offer.price); // if the submitted amount exceeds the price allow the buyer to withdraw the difference
    }
    transferCat(catId, catOwners[catId], msg.sender, offer.price);
  }
  ...
  1. 먼저 인자(parameter) 로 catId 를 받습니다. adoptionOffers 변수에서 해당 catId 의 값을 꺼냅니다.

  2. 1에서 꺼낸 값의 타입은 AdoptionOffer 입니다. 먼저 분양 시장에 해당 아이디의 고양이가 있는지 체크합니다.

  3. onlyOfferTo 가 0 이면 아무나 분양을 해갈 수 있다는 것이고, 0 이 아니면 해당 지갑만 분양할 수 있습니다.

  4. 함수를 호출할 때 분양가보다 높은 가격의 ETH 를 보내야 하며, 분양가보다 더 높은 ETH 를 보내면 분양가를 지불하고 남은 이더는 구매한 사람이 다시 가져갈 수 있도록 pendingWithdrawls 에 값을 더합니다.

  5. 마지막으로 transferCat 을 통해 고양이의 소유권을 이전합니다. (분양 완료)

간단히 정리하면 분양 가능한지 보고, 분양 금액에 맞게 고객이 ETH 를 전달했는지 체크하고, transferCat 를 통해 고양이를 이전합니다. transferCat 의 함수 내부를 한 번 살펴볼까요?

  function transferCat(bytes5 catId, address from, address to, uint price) private {
    catOwners[catId] = to;
    balanceOf[from]--;
    balanceOf[to]++;
    adoptionOffers[catId] = AdoptionOffer(false, catId, 0x0, 0, 0x0); // cancel any existing adoption offer when cat is transferred

    AdoptionRequest storage request = adoptionRequests[catId]; //if the recipient has a pending adoption request, cancel it
    if(request.requester == to) {
      pendingWithdrawals[to] += request.price;
      adoptionRequests[catId] = AdoptionRequest(false, catId, 0x0, 0);
    }

    pendingWithdrawals[from] += price;

    Transfer(from, to, 1);
    CatAdopted(catId, price, from, to);
  }
  1. catOwners 를 구매자 지갑 주소로 변경하여 소유권을 이전합니다. 또한 지갑에서 고양이 소유 개수를 업데이트합니다.

  2. 분양이 끝났으므로 분양 정보를 초기화합니다.

  3. AdoptionRequest 는 고양이를 가진 상대에게 분양하고 싶다고 요청을 보낼 때 금액과 함께 요청을 보내게 되는데 이 정보도 초기화합니다. (사건/사고가 일어난 부분이랑 관련되지 않으므로 코드를 다루진 않겠습니다. 궁금하시면 한 번 실제로 뜯어봐서 해석해보면 될 것 같습니다.)

  4. 마지막으로 pendingWithdrawalsfrom 주소의 값을 업데이트해서 분양해준 사람이 나중에 금액만큼 인출할 수 있도록 합니다. acceptAdoptionOffer 에서 transferCat 을 호출할 때 from 파라미터를 catOwners[catId] 으로 넘겼습니다. 따라서 catOwners[catId]고양이의 소유권 정보가 담겨있음을 확인할 수 있습니다.

소스코드의 문제점

자, 지금까지의 코드 해석 과정에서 이더리움이 스마트 컨트랙트에 영원히 묶일만한 부분이 보입니다. 처음 제네시스 고양이들을 생성하는 코드를 한 번 다시 봅시다. 저 제네시스 고양이들의 소유권은 누구한테 있을까요? 아무도 없으니 0x00..00 (Null Address) 에게 있습니다.

일단 분양 시장에 있는 고양이가 팔린 경우 이더리움이 스마트 컨트랙트로 옮겨집니다. 그리고, 나중에 스마트 컨트랙트에서 지갑이 자기의 지분만큼 인출할 수 있도록 코드가 작성되어야 하는데요. 이 역할을 하는 변수가 pendingWithdrawals 입니다. 나중에 스마트 컨트랙트에서 자기 지분의 이더를 인출하겠다 하면 저 변수를 참조하는거죠.

  function withdraw() {
    uint amount = pendingWithdrawals[msg.sender];
    pendingWithdrawals[msg.sender] = 0;
    msg.sender.transfer(amount);
  }

위의 코드가 인출할 때 호출하는 함수 withdraw 입니다. 이렇듯 스마트 컨트랙트 내에서 재화가 사용되는 경우 인출하는 함수가 무조건 필요합니다. 그렇지 않으면 코인이 영원히 스마트 컨트랙트 안에 묶이게 됩니다!

분명 이 스마트 컨트랙트는 고양이 구매, 그리고 구매 후 인출하는 함수가 모두 존재합니다. 그래서 일반적으로 사람들이 고양이를 분양 시장에 내놓고 팔고 하는 것은 이상이 없어보입니다.

하지만 제네시스 고양이의 경우 어떠할까요? 아마 눈치채신 분들도 계실 겁니다. 소유권이 없는 고양이를 분양 시장에 내놓았는데 그 고양이가 팔리면 누가 인출할 수 있도록 해야할까요? 거기에 대한 처리가 전혀 없습니다. 0x00..00 의 주소는 아무도 소유하지 못한 주소입니다. 제네시스 고양이는 소유권이 컨트랙트 생성자가 아니라 Null Address 이므로, 고양이를 구매할 때 Null Address 에게 지분이 가게 되고, Null Address 는 아무도 소유하지 않은 지갑이므로 아무도 인출하지 못합니다.

pendingWithdrawals 에서 Null Address 에 담긴 값이 얼마인지 확인해볼까요? 간단하게

이더스캔에서 확인해볼 수 있습니다.

이렇게 컨트랙트 내에서 Read Contract 를 통해 값을 조회해볼 수 있습니다.

조회해봤더니 100800000000000000000 라는 큰 값이 리턴됩니다. 일반적으로 이더리움에서 이더 값을 저장할 때는 10e18 (10의 18제곱) 을 곱해서 저장합니다. 이더리움은 소수점 자리 18자리까지 지원하는데요. 10의 18제곱을 곱해서 저장함으로써 소수점을 사용하지 않고 저장할 수 있습니다. 즉, 실제 이더리움 값은 저기에서 10의 18제곱을 나누면 됩니다!

그럼 100.8ETH 가 나오는 것을 확인할 수 있습니다.

해결 방법

안타깝게도 스마트 컨트랙트는 배포된 이후에 소스코드 수정이 불가능하므로 수정할 수 없습니다. 저 100.8ETH 를 다시 인출할 수 있는 방법은 없습니다. 대신 소스코드에서 어떤 점이 문제이었는지 확인해보고 다음부터는 배포할 때 코드를 어떻게 배포해야할지 반면교사 삼아서 다시 이런 실수가 반복되지 않도록 하면 되겠죠.

1. 제네시스 고양이를 생성할 때 소유권을 msg.sender 로 설정

addGenesisCatGroup 함수에서 제네시스 고양이들을 생성하게 되는데 여기서 소유권을 Null Address 가 아니라 msg.sender 로 설정함으로써 Contract Owner 로 소유권을 주는 방법입니다. 그럼 고양이가 팔리면 pendingWithdrawal 에서 Contract Owner 의 값을 증가시키기 때문에 컨트랙트에 이더를 인출할 수 있습니다.

2. Contract Owner 로 인출하는 함수 추가

또다른 방법은 pendingWithdrawals[0x00]Contract Owner 로 인출할 수 있게끔 함수를 추가하는 것인데요.

  function withdrawFromGenesisCats() onlyOwner {
    uint amount = pendingWithdrawals[0x0];
    pendingWithdrawals[0x0] = 0;
    msg.sender.transfer(amount);
  }

위와 같이 0x00 을 인출하는 함수를 추가하는 것입니다. onlyOwner modifier 가 있으므로 이 함수는 Contract Owner 만 호출할 수 있으며, 따라서 0x0 에 묶여있는 이더를 Contract Owner 로 인출할 수 있습니다.

결론

이번 포스트에서는 MoonCatRescue 컨트랙트에서 있었던 문제를 살펴보았습니다. 개발자의 실수인지 의도인지는 알 수 없으나 의도적으로 이더가 컨트랙트 안에 묶여서 영원이 인출 못하게 하진 않았을 것입니다. 그리고 이 때 당시에는 이더의 가치가 크진 않았기 때문에 그리고 디앱 유저들이 피해를 보는 문제는 아니었기에 큰 문제 없이 지나갔으리라 생각합니다.

하지만 한 두줄의 실수로 인하여 이런 의도하지 못한 문제가 발생할 수 있고, 코드는 수정될 수 없기에 스마트 컨트랙트 개발자는 경각심을 가지고, 모든 테스트 과정을 거쳐 이상이 없음을 확인하는 과정을 거쳐야 함을 항상 명심해야 할 것입니다.

스마트 컨트랙트 사건/사고 사례<br>(1) MoonCatRescue
Prev post

디파이 개발 (유니스왑 편)
(1) 유니스왑 기본 개념

Get in touch