【完全保存版】The DAO事件を通じて、Re-entrancy攻撃やCEIパターンを学ぼう!
こちらの記事は、Solidityの入門用の記事の続きとして書きました。
入門者用のため、基本的な用語の解説も載せておりますので、中級者以上の方などは、適宜、飛ばしながら読んでみてください。
なお、この記事は独立しており、前回の入門記事とは無関係なので、この記事から読むことができます。
0 はじめに
今回は、The DAO事件から、Reentrancy攻撃を学んでみたいと思います。
攻撃手法や脆弱性を知り、安全なコードを書けるようになることが目的です。
なお、今回のコードは、こちらの記事を参考に作りました。
また、私のこちらのGithubからも確認ができます。
なお、私のこのレポジトリは、脆弱性のあるものを集めていますので、そのまま本番環境のものに使用しないように、十分ご注意ください。
1 EtherStoreコントラクト(脆弱性あり)について
0 はじめに
はじめに、こちらのコントラクトには重大な脆弱性があります。(そのため、攻撃されます。)
誤って、このコードを実際のコントラクトとしてつくらないよう、十分ご留意ください。
1 コントラクトの概要
では、こちらの「EtherStore」コントラクトを確認してみましょう。
下のように、3つの関数があることが確認できます。
預け入れを行う関数と、引き出しを行う関数、残高を確認する関数です。
2 mappingについて
まずは、こちらのmappingについて学んでみましょう。
mappingは、下のように、どの名前の箱に、なにが入っているかなどを対応づけているものです。
今回は、まさに、誰がいくら持っているのかを紐づけるための役割になります。
3 payableについて
payableはEtherを受け取る関数につける必要があるマークです。
「onlyOwner」などと同じ、「修飾子」(modifier)と呼ばれるものです。
4 msg.senderについて
「msg.sender」はグローバル変数と呼ばれるもので、あらかじめ定義されているものです。
ここでは、関数の呼び出しを行うアドレスを表します。
5 +=について
こちらは、すでにあるものに足した値を入れるという意味です。
言葉にするとわかりにくいですが、balances[msg.sender]の値が5、msg.valueの値が2であれば、
balances[msg.sender]の値が7(5 + 2の結果)になることを表します。
6 msg.valueについて
msg.valueもグローバル変数であり、この関数に送られたEtherの量を表します。
つまり、deposit関数では、送られてきたEtherの量を、balances[msg.sender]に追加するということを行っています。
7 address(this)について
こちらのaddress(this)はこのコントラクトのアドレスを指します。
thisというキーワードがそのコントラクト自体を参照しています。
address関数に渡すことで、アドレスを取得しているという構成です。
つまり、このgetBalanceという関数は、このコントラクトが持っている残高を取得する関数です。
8 withdraw関数について
では、withdraw関数を見てみましょう。
下の部分では、balances[msg.sender]でそのアドレスの残高を取得し、requireで0より大きい時に先に進んでいます。
引き出そうとしているので、残高がないとダメですもんね。
9 call関数について
まず、msg.sender.callという部分で、関数の呼び出し先に対して、call関数を実行しています。
{value: bal}の部分で、balという量のEtherを送ります。
そして、("")の部分が空文字なので、何かを呼び出しているわけではなく、ただEtherを送っているだけという処理になります。
なお、(bool sent, )の部分はタプル分解と呼ばれる方法で結果を取得しています。
本来、2つの結果が返ってくるので、( A , B )という形になっているのですが、今回は「,」 の後が何もないですね。
これは2つ目の結果を使わないので、あえて抜いています。
「sent」には結果(trueかfalse)が返ってくるので、trueだった場合は、その下のrequireが通ります。
10 残高の処理について
最後に、引き出し終わったので、balances[msg.sender]の値を0にしています。
2 攻撃の流れを追ってみよう
では、Attackコントラクトからどのように攻撃が行われるのかを追ってみましょう。
1 attack関数について
このattack関数によって攻撃が行われます。
2 預け入れ処理について
まず、msg.valueが0.001 etherであることを確認しています。
msg.valueはその関数に送られるetherの量でしたので、この関数に、0.001 ether以上を送る必要があります。
そして、そのうちの0.001etherを用いて、「EtherStore」コントラクトのdeposit関数で、Etherを預けています。
3 Attackコントラクトからの引き出し処理について
その直後に、「EtherStore」コントラクトの「withdraw」関数で引き出しを行っています。
つまり、預けた額を引き出しているのですね。
変なことをしていますが、これの何がダメなのでしょう?
4 EtherStoreコントラクトでの引き出し処理について
では、呼び出された先の「withdraw」関数を見てみましょう。
すでに0.001Etherを預けているので、「require」は通過できます。
問題は、次の「call」関数です。
ここから、呼び出し先の、「fallback」関数に向います。
5 Attackコントラクトでのfallback関数について
ちなみに、fallback関数について詳しくは、こちらをご覧ください。
では、Attackコントラクトのfallback関数を見てみましょう。
まずは、EtherStoreコントラクトが0.001 ether以上持っているかを確認しています。
つまり、獲物がお金を持っているかを確かめています。
6 EtherStoreコントラクトでの引き出し処理について(ループ)
次に、また「EtherStore」コントラクトの「withdraw」関数を呼び出しています。
あれ?どういうことでしょうか?
つまり、こういうことをやっています。
関数の途中で、また一から戻しています。
これにより、「EtherStore」の残高が0.001 ether未満になるまで、このループを繰り返させられます。
call関数でEtherを送っていたので、ここでEtherがどんどん吸い取られてしまっていました。
こんな感じになっていたのですね。
なお、今回のサンプルコントラクトはこちらです。
3 修正コードについて(ReentrancyGuard)
先ほどのコントラクトを修正したものがこちらの「EtherStore2」コントラクトになります。
先ほどとの違いはこちらになります。
OpenZeppelinから「ReentrancyGuard」というコントラクトを継承し、「nonReentrant」という修飾子を付けています。
これは、その名の通り、「Reentrant(再入)」を禁止しています。
つまり、先ほどの下のような動きを封じています。
仕組みも簡単に見てみましょう。
下が、OpenZeppelinの「nonReentrant」のコードです。
「_;」の部分で「nonReentrant」が付いた関数が実行されます。
すなわち、関数の前後で、「_nonReentrantBefore()」「_nonReentrantAfter()」という処理が実行されます。
イメージはこんな感じです。
まずは、「_nonReentrantBefore()」について見てみましょう。
こちらは、ステータスが「_ENTERD」(入場済)であれば、トランザクションを中止し、処理を戻します。
そして、「_ENTERD」でなければ(「_NOT_ENTERD」)、先に進んで、ステータスを「_ENTERD」にします。
これにより、処理に入る前に、必ず「_ENTERD」になりました。
次が「_nonReentrantAfter()」についてです。
下のように、処理が終わったら、「_NOT_ENTERD」に戻されます。
処理が終わったため、このように再び入れるようになりました。
下のようなイメージになります。
つまり、処理中に、再び入ろうとすると(再入)、下のように、本処理には入れないようになっています。
4 CEIパターンの適用について
これですでに再入は防げましたが、現時点では、CEIパターンを満たしていません。
1 CEIパターンとは
CEI(Checks-Effects-Interactions)パターンは、スマートコントラクトにおける一般的なセキュリティベストプラクティスです。
このパターンでは、以下の順序でコードを実行します。
Checks: 全ての条件が満たされているかチェックします。
Effects: 状態変更を行います。
Interactions: 他のコントラクトとの相互作用を行います。
2 コードを見てみよう
では、実際のコードを見てみましょう。
下のように、「Checks」-「Interactions」-「Effects」の順になってしまっています。
3 コードを直してみよう
そのため、下のように、
「Checks」-「Effects」-「Interactions」の順にしました。
これにより、CEIパターンに沿ったコードになりました。
これで、より安全なコードになりました!
5 その他のケースについて
そのほかの脆弱性やエラーについても簡単に見てみましょう。
1 変更が反映されない
下のコードには不具合があります。
どこがおかしいでしょうか?
この部分が意図した動作と異なる挙動を生みます。
関数内で宣言した「addr」に反映しようとしても、ストレージには反映されません。
そのため、直接、ownerに値を代入する必要がありました。
2 ガスリミット超え
次に、下のケースも見てみましょう。
こちらは2の255乗回処理を行おうとしています。
これにより、ガスリミット(ガスの上限)を超えてしまうため、この関数は実行できません。
他にも、誤って、ループが無限に回ってしまうなどで、ガスリミットを超えてもエラーとなります。
3 ガスリミットを利用した攻撃
次はどうでしょう?
一見問題がなさそうです。(addUserが誰でもできるのは仕様とします。)
実は、今回は攻撃者によって、ユーザーを大量に作られてしまう可能性があります。
その場合、rewardUsers関数でたくさんのループが回ってしまい、ガスリミットを超えてしまう可能性があります。
このようなケースに備え、ループを回す際には、数を制限するなどが考えられます。
(ちなみに、ループで回しながらetherを送るのもあまり良くないとされています。)
以上です。