ReentrancyGuardがない場合の脆弱性を体験してみよう!
今回は、こちらの記事を参考に、ReentrancyGuardを実演しております。
まずはEtherを格納する、脆弱性のあるコントラクトを用意しました。
こちらになります。
なお、コードはGMOグループの記事にあったものを使わせていただいております。
まずはこのコントラクトにAさんが 0.05 etherを格納します。
こちらは通常の処理です。
下のように、確認することができました。
では、次に攻撃用のコントラクトを確認します。
こちらのGMOグループさんの記事のコードを準用しています。
(価格を1 Etherの部分を、0.01 Etherに変更しています。)
下のように、0.01 Etherを入力して、関数を実行します。
すると、下のように、Etherを格納している、脆弱性のあるコントラクトの残高が全てなくなってしまいました。
ちなみに、攻撃用のコントラクトを確認すると、盗んだ分が反映されていることがわかります。
では、なぜこんなことが起きているのでしょう。
コードを読みながら確認していきます。
こちらが攻撃用のコントラクトです。
contract Attack {
EtherStore public etherStore;
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
fallback() external payable {
if (address(etherStore).balance >= 0.01 ether) {
etherStore.withdraw();
}
}
function attack() external payable {
require(msg.value >= 0.01 ether);
etherStore.deposit{value: 0.01 ether}();
etherStore.withdraw();
}
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
「attack」という処理を行った時の流れを辿っていきます。
まず、0.01 Etherが渡されたことを確認します。
その上で、「EtherStore」 コントラクトの「deposit」関数に「0.01 Ether」を渡して実行します。
次に、「EtherStore」コントラクトの「deposit」関数を確認します。
balancesというmappingに渡された、0.01 Etherが加わりました。
つまり、
balances[攻撃者B] = 0 だった場合、
balances[攻撃者B] = 0.01 Ether
になりました。
では、これで「deposit」の処理が終わったので、「attack」関数の続きに行きます。
「EtherStore」コントラクトの「withdraw」関数を実行しています。
つまり、見た目上は、etherを預けて、すぐに引き出す処理を実行しています。
では、「EtherStore」コントラクトの「withdraw」関数を確認します。
そもそもdepositされていないと引き出せないので、残高があるかを確認します。
balances[攻撃者B] = 0.01 Ether
だったので、行けるはずですが、実はここがミソになります。(後ほどまた登場します。)
次に、「call」関数が実行されます。
なお、ここでの「msg.sender」とは呼び出しを行った、「Attack」コントラクトになります。
なお、call関数についての説明はこちらになります。
他のコントラクトとのやり取りを行い、fallback関数を通じてEtherを送付することが推奨されている方法です。
https://solidity-by-example.org/call/
そして、こちらを見ると、「EtherStore」コントラクトに0.01Ether以上あれば、「EtherStore」コントラクトの「withdraw」関数をもう一度実行する処理がされています。
ここが問題箇所です。
「withdraw」関数を確認すると、requireという前提条件のチェックがありますが、「balances[msg.sender]」はまだ何も変更されていないので、ここが通ってしまいます。
これにより、0.01Ether以上でなくなるまでこの処理が最後まで続いてしまいます。
この結果、ほぼ全てが奪われてしまいました。
そもそもの問題を考えてみましょう。
こちらのwithdraw関数に何度も入れてしまうこと(re-entrant)が問題でした。
そのため、再入不可にすることが必要です。
では、こちらのReEntrancyGuardコントラクトを作り、継承していきます。
「noReentrant」という修飾子を使っていくことになります。
なお、実際には、「Openzeppelin」の「ReentrancyGuard」を使うことが多いと思います。
修飾子をつけることによって、ここで処理が行われます。
つまり、withdraw関数がここで使われるイメージです。
では、具体的に見てみましょう。
一回目に入った時は、「locked」が「false」なので、requireを通過します。
そして、「locked」が「true」になり、処理が始まります。
そして、処理の途中で、再入の攻撃を受けたとします。
しかし、「locked」がtrueになっているため、requireに阻まれ、中に入ることができません。
これによって、再入を防ぐことができました。
このように、実行しようとすると、エラーを出してくれました。
今回はReentrancyGuardについて説明をしました。
最後までありがとうございました!
Discussion