Ethernaut完走の感想 その4
はじめに
この記事はEthernaut完走の感想 その3の続きです。ネタバレがあるのでご注意ください。個人的にはかなり難しかったです。
24. Puzzle Wallet
PuzzleProxyのadminを奪取するのが課題です。その名の通りProxyモデルを採用したコントラクトで、実装はPuzzleWallet です。例えば、PuzzleProxyに対してexecuteをcallすると、PuzzleProxyのfallbackからPuzzleWalletのexcuteが呼ばれるという挙動になります。Proxyモデルでは実装のコントラクトがdelegatecallで呼ばれるので、スロットの上書きテクニックで攻略できそうです。
ちなみに、Proxyに関する実装は以下のコードです。
まず、PuzzleWalletのownerを奪取しに行きます。これはPuzzleProxyのpendingAdminと同じスロットなので、PuzzuleProxy側でpendingAdminを自身のアドレスに設定すればよいです。幸いproposeNewAdmin関数は誰でも実行できるのでここはすんなりクリアできます。ownerを奪取するとPuzzleWalletのaddToWhitelistが実行できるようになるので自身のアドレスを追加しておきましょう。
さて、adminを奪取するためにはmaxBalanceに自身のアドレスを書き込む必要があります。setMaxBalanceを使うとmaxBalanceを変更できますが、すでにコントラクトのbalanceが0.001ETHなのでrequire(address(this).balance == 0, "Contract balance is not 0");で弾かれてしまします。どうにかしてコントラクトのbalanceをゼロにしたいです。
そこでmulticallを利用します。multicallはcalldataを関数セレクタの配列とみなして逐次実行してくれる便利関数です。multicallを{value: 0.001ether}で呼び出し、その中で2回depositを呼ぶと、「コントラクトの残高が0.002ETH、balances[自身のアドレス]が0.002ETH」という状況が作れます。こうなればexecuteで0.002ETHすべて抜き出すことができますね!
正確にはmulticallの中でdepositCalledによって呼び出し回数を一回に制限されているので、以下のように二回目はmulticallをかませる必要があります。
muticall
|- deposit
|- muticall
|- deposit
25. Motorbike
これもProxyモデルです。upgradeToAndCallで新しい実装に載せ替えることができます。さらに、_upgradeToAndCallで新しい実装上で任意の処理ができるようになっています。そこで、selfdestructを実行する関数を実装した攻撃用コントラクトをデプロイしておき、_upgradeToAndCallでその関数が実行されるようにすればOKです。ちょっと不思議な挙動ですがdelgatecallで呼ばれたselfdestructは呼び出し元のコントラクト(ここではEngine)をdestructします。詳細はこの記事をご参照ください。
If the contract can be made to delegatecall into a malicious contract that contains a selfdestruct, then the calling contract will be destroyed.
26. DoubleEntryPoint
これが難易度2だとは到底思えません、コード量も多いですし…。
最初コードを読んだとき、何が問題なのかさっぱりわかりませんでした。解説記事を読みながら理解しました。
まず、CryptoVaultがこのレベルのでポイントとなるコントラクトです。これはunderlyingに指定されたERC-20トークンを管理するための金庫です。本来であれば入出庫するためのコードが色々あるはずですが、問題の本質には関係ないため/* */というコメントで代用されているようです。そして、このコントラクトはsweepToken関数を持っています。これは、関係ないERC-20トークンがこの金庫に送られた場合に、sweptTokensRecipientに送ってサルベージするための関数です。間違えた宛先にトークン送ってしまうことはよくあるので、そういったときの救済手段ですね。
instanceとして見えているコントラクトはDoubleEntryPointです。デプロイ直後の状態は以下のとおりです。
-
delegatedFrom:LegacyTokenのインスタンス-
LegacyTokenのdelegate:0
-
-
cryptoVault:CryptoVaultのインスタンス-
underlying:DoubleEntryPointのインスタンス
-
さて、LegacyTokenのdelegateにDoubleEntryPointをセットし、さらにLegacyTokenをCryptoVaultに入れたケースを想定します。この状態でseepTokenを実行すると、LegacyTokenのtransferが呼ばれますが、その中でdelegate.delegateTransfer(to, value, msg.sender);が実行されます。これはDoubleEntryPointのdelegateTransferです。ということでCryptoVault的にはunderlyingトークンのtransferを呼ぶつもりはなかったのに、実行されてしまっていることになります。これはマズイですね。
このような複雑な依存関係で予期しない挙動を検出するためのコントラクトがFortaです。このレベルの課題はForta用のDetectionBotを実装し、上記のような状況を検出してtransferをfailさせることです。実装の詳細は解説サイトをご参照ください。
27. Good Samaritan
日本語でいうと「善きサマリア人」ですね。困った人を助ける慈悲深い人、だそうです。たしかにこのコントラクトもrequestDonationで10コインくれます。このレベルの課題はこの慈悲深い人が持っている100000コインすべてを奪いなさい、というなんともひどい内容です。でも課題なので仕方ない、やりましょう。
ここでポイントになるのはrequestDonationで実行される以下のコードです。このコードは内部処理でrevertが発生した際に残高全てをmsg.senderに送るというものです。
if (keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)) {
// send the coins left
wallet.transferRemainder(msg.sender);
return false;
}
requestDonationから処理を追っていくとCoin#transferでINotifyable(dest_).notify(amount_);という処理が走っています。これは呼び出し元がINotifyableを実装している場合、通知をしてくれるという機能です。この親切機能を悪用します。具体的には攻撃用のコントラクトでnotify関数を実装し、その中でrevertを実行します。そのコントラクトからrequestDonationを呼べば、めでたく↑の残高精算処理を行ってくれます。
28. Gatekeeper Three
久しぶりのGatekeeperです。gateTwoでallowEntranceがtrueでないといけません。getAllowanceで正しいパスワードを提示できればよいですが、これはSimpleTrickのスロット2をgetStorageAtで覗き見れば一発ですね。
29. Switch
これも難しくて解説サイトを見ました。予備知識としてcalldataにパラメータの動的配列が格納される際にどのようにエンコードされるかを知っている必要がありました。解説サイトによるとcalldataは以下のようになっています。
- 関数セレクタ: 4 byte
- オフセット: 32byte
- 配列長: 32byte
- 配列のデータ
さて、switchOnの状態を変更するには以下の関数を呼ぶ必要があります。このとき、_dataをturnSwitchOn()のセレクタとしておけば実行してくれそうです。
function flipSwitch(bytes memory _data) public onlyOff {
(bool success, ) = address(this).call(_data);
require(success, "call failed :(");
}
modifierのコードを見ると以下のような部分があります。これはcalldataの68byte目から4byteデータを取得し、セレクタがturnSwitchOff()に一致するかどうかを確認しています。つまり、turnSwitchOn()を指定していると弾かれてしまいます。
assembly {
calldatacopy(selector, 68, 4) // grab function selector from calldata
}
require(
selector[0] == offSelector,
"Can only call the turnOffSwitch function"
);
しかし、以下のようなcalldataで呼び出せば、modifierはパスしつつcallでturnSwitchOn()が実行されます。オフセットが0x06なのでcallではturnSwitchOn()が認識されるという理屈です。
- 関数セレクタ: flipSwitch()
- オフセット: 0x06
- 配列長: 0x4
- 配列のデータ: turnSwitchOff()
- 配列長: 0x4
- 配列のデータ: turnSwitchOn()
感想
GWで解きましたが、たくさんあって結構疲れました。ただ、知っていることと実際にやってみることではぜんぜん違うので、ゲームとして体験できてとても勉強になりました。また、人が書いたコードを一生懸命読むというのもSolidityの理解を深める一助になったと思います。
前半だけでもメジャーな脆弱性は体験できるので、ぜひチャレンジしてみてください!それでは、良いSolidityライフを!
Discussion