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