[Ethernaut] 3. CoinFlip

Ethernaut · 4/36
  1. 0. Hello Ethernaut
  2. 1. Fallback
  3. 2. Fallout
  4. 3. CoinFlip
  5. 4. Telephone
  6. 5. Token
  7. 6. Delegation
  8. 7. Force
  9. 8. Vault
  10. 9. King
  11. 10. Re-entrancy
  12. 11. Elevator
  13. 12. Privacy
  14. 13. Gatekeeper One
  15. 14. Gatekeeper Two
  16. 15. Naught Coin
  17. 16. Preservation
  18. 17. Recovery
  19. 18. MagicNumber
  20. 19. Alien Codex
  21. 20. Denial
  22. 21. Shop
  23. 22. Dex
  24. 23. Dex Two
  25. 24. Puzzle Wallet
  26. 25. Motorbike
  27. 26. DoubleEntryPoint
  28. 27. Good Samaritan
  29. 28. Gatekeeper Three
  30. 29. Switch
  31. 30. HigherOrder
  32. 31. Stake
  33. 32. Impersonator
  34. 33. Magic Animal Carousel
  35. 34. Bet House
  36. 35. Elliptic Token

1. 문제

초능력(?)을 사용해서 10번 연속으로 코인 뒤집기의 결과를 맞춰라.

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

contract CoinFlip {

  uint256 public consecutiveWins;
  uint256 lastHash;
  uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

  constructor() {
    consecutiveWins = 0;
  }

  function flip(bool _guess) public returns (bool) {
    uint256 blockValue = uint256(blockhash(block.number - 1));

    if (lastHash == blockValue) {
      revert();
    }

    lastHash = blockValue;
    uint256 coinFlip = blockValue / FACTOR;
    bool side = coinFlip == 1 ? true : false;

    if (side == _guess) {
      consecutiveWins++;
      return true;
    } else {
      consecutiveWins = 0;
      return false;
    }
  }
}

2. 초능력이 없는데 어떡하죠?

문제를 해결하는 데는 다음의 선택지가 있습니다.

  1.  순수 피지컬로 찍어 누른다.
  2. 취약점을 공격한다.

 당연히 1번은 트랜잭션을 몇 번을 실행해야 될지 예상도 안되기 때문에 2번을 선택하는 것이 맞습니다. 그런데 취약점이라고 할만한 게 안 보이는데 어떻게 해야 할까요?

 코인 던지기 결과를 계산하는 방법은 다음과 같습니다.

  1. 이전 블록의 해시값을 가져와 uint256 타입으로 변환하여 blockValue에 할당한다.
  2. blockValue를 FACTOR로 나눈 몫을 coinFlip에 할당한다.
uint256 blockValue = uint256(blockhash(block.number - 1));
uint256 coinFlip = blockValue / FACTOR;

 이렇게 만들어진 값이 정말로 ‘예측’이 어려울까요? 사이킥 파워가 있어야만 할까요?

아니 해시값이 ‘랜덤’하니까 coinFlip 값도 랜덤 한 거 아닌가요?

 제가 랜덤하고 아니고 답을 내려드리기는 어렵지만, 한 가지 확실한 것은 coinFlip 값은 랜덤 하지 않다는 것입니다! 오히려 예측하지 않고도 값을 통제할 수 있다는 치명적인 문제가 있죠.


3. block.number 얘가 문제임

 block.xxx 또는 msg.xxx 등의 변수는 어떤 컨트랙트에서도 접근이 가능한 전역 변수입니다. 예를 들어, block.number는 현재 실행된 트랜잭션이 포함된 블록의 번호, msg.sender는 트랜잭션의 발신자입니다. 문제는 이러한 값들이 어느 정도 통제가 가능하다는 것입니다.

 첫 번째로, 트랜잭션이 포함될 블록의 번호를 제어할 수 있습니다. 이 부분은 트랜잭션의 가스비를 조절하거나 트랜잭션을 전송하는 시점 등을 제어해야 하므로 다소 어려운 방법이기는 하나, 블록의 번호를 제어할 수 있기 때문에 coinFlip 값을 충분히 예측할 수 있습니다.

 두 번째는 CoinFlip 컨트랙트를 공격하는 새로운 컨트랙트를 작성하여 배포하고 실행하는 것입니다. 예를 들어, Attack이라는 이름의 컨트랙트의 attack 함수를 호출하면 코인 뒤집기 결과를 계산하여 함수 내부에서 CoinFlip 컨트랙트의 flip 함수를 호출한다고 봅시다.

attack()이 flip()을 호출하는 두 컨트랙트
attack()이 flip()을 호출하는 두 컨트랙트

 이렇게 호출된 함수의 결과는 하나의 트랜잭션에 담기게 됩니다.

두 호출을 감싸는 하나의 트랜잭션
두 호출을 감싸는 하나의 트랜잭션

 그리고 이 트랜잭션은 하나의 블록에 담기게 됩니다.

그 트랜잭션을 담은 하나의 블록
그 트랜잭션을 담은 하나의 블록

 하나의 블록 안에 있는 트랜잭션에서 함수 호출이 처리되었다는 것은 곧 attack 함수에서 불러온 block.number와 flip 함수에서 불러온 block.number의 값이 동일하다는 것을 의미합니다.

같은 블록에서 읽으니 양쪽 block.number가 같다
같은 블록에서 읽으니 양쪽 block.number가 같다

 따라서 flip 함수에서 사용된 계산식을 attack 함수에서 동일하게 사용하여 코인 뒤집기의 결과를 계산할 수 있습니다.


4. 공격 컨트랙트 작성하고 실행하기

4-1. Remix IDE에서 배포된 CoinFlip 컨트랙트 불러오기

 먼저 CoinFlip 컨트랙트를 붙여 넣고 메타마스크 또는 월렛커넥트를 선택하여 지갑을 연결해 줍니다. 이때 Ethernaut에서 인스턴스를 생성할 때 사용한 네트워크와 동일한 네트워크를 선택해 줘야 합니다.

Remix ENVIRONMENT에서 Injected Provider 고르기
Remix ENVIRONMENT에서 Injected Provider 고르기

 그리고 배포된 인스턴스의 주소를 붙여 넣고 At Address 버튼을 눌러 배포된 인스턴스를 불러옵니다.

At Address로 불러온 CoinFlip 인스턴스
At Address로 불러온 CoinFlip 인스턴스

4-2. 공격 컨트랙트 작성하고 배포하기

contract Attack {
    address public coinFlipAddress;
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

    constructor(address _coinFlipAddress) {
        coinFlipAddress = _coinFlipAddress;
    }

    function attack() public {
        uint256 blockValue = uint256(blockhash(block.number - 1));

        uint256 coinFlip = blockValue / FACTOR;
        bool side = coinFlip == 1 ? true : false;

        CoinFlip coinFlipContract = CoinFlip(coinFlipAddress);
        coinFlipContract.flip(side);
    }
}

 이 컨트랙트는 우선 생성자 함수로 CoinFlip 컨트랙트의 주소를 받습니다. 그리고 attack 함수를 호출하면 CoinFlip 컨트랙트의 계산식과 동일한 식을 사용하여 코인 뒤집기 결과를 계산하여 CoinFlip 컨트랙트의  flip 함수를 호출합니다. 이때 반복문을 사용하여 flip 함수를 여러 번 호출할 수도 있어 보이지만, CoinFlip 컨트랙트에서 lasthash 값을 사용하여 동일한 블록에서 실행한 트랜잭션이 revert 되도록 방어진을 구축해 놓았기 때문에 10번을 단순 노가다로 호출을 해야 한다는 번거로움이 있습니다.

생성자에 CoinFlip 주소를 넣고 Deploy 서명
생성자에 CoinFlip 주소를 넣고 Deploy 서명

 일단 CoinFlip 컨트랙트의 주소를 집어넣고 배포를 합니다.

4-2. 공격 컨트랙트 작성하고 배포하기

배포된 Attack 컨트랙트와 attack 버튼
배포된 Attack 컨트랙트와 attack 버튼

4-3. 공격!!!

 이제 attack 함수를 10번 호출하면 됩니다.

 한 번…

attack()을 한 번 호출하는 트랜잭션 서명
attack()을 한 번 호출하는 트랜잭션 서명

 consecutiveWins 값이 1이 된 것을 확인할 수 있습니다.

첫 호출 뒤에 읽은 consecutiveWins 값
첫 호출 뒤에 읽은 consecutiveWins 값

 열 번… success. 중간중간 실패하는 트랜잭션은 블록 해시값에서 걸려서 그런 겁니다.

열 번째 호출 뒤의 consecutiveWins와 트랜잭션 로그
열 번째 호출 뒤의 consecutiveWins와 트랜잭션 로그

 자~ 제출 드가자~

레벨 통과 메시지로 가득한 콘솔
레벨 통과 메시지로 가득한 콘솔

5. 결론

전역 변수를 사용해서 랜덤한 값을 생성할 수 없다. Chainlink VRF를 사용하자.