ReentrancyGuardがない場合の脆弱性を体験してみよう!

2023/03/27に公開

今回は、こちらの記事を参考に、ReentrancyGuardを実演しております。
https://recruit.gmo.jp/engineer/jisedai/blog/uniswap-reentrancy/

まずはEtherを格納する、脆弱性のあるコントラクトを用意しました。

https://mumbai.polygonscan.com/address/0x8708f54588C0b601E058e884Ed7AC4f2f6d608a0#code

こちらになります。

なお、コードはGMOグループの記事にあったものを使わせていただいております。
https://recruit.gmo.jp/engineer/jisedai/blog/uniswap-reentrancy/

まずはこのコントラクトに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」を使うことが多いと思います。

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/security/ReentrancyGuard.sol

修飾子をつけることによって、ここで処理が行われます。

つまり、withdraw関数がここで使われるイメージです。

では、具体的に見てみましょう。

一回目に入った時は、「locked」が「false」なので、requireを通過します。

そして、「locked」が「true」になり、処理が始まります。

そして、処理の途中で、再入の攻撃を受けたとします。

しかし、「locked」がtrueになっているため、requireに阻まれ、中に入ることができません。

これによって、再入を防ぐことができました。

このように、実行しようとすると、エラーを出してくれました。

今回はReentrancyGuardについて説明をしました。

最後までありがとうございました!

Discussion