[Solidityのはじめ方] Part17: 再入可能性攻撃とguard check

Solidity

はじめに

Solidityでスマートコントラクトを開発する上で、セキュリティは最も重要な要素の一つです。特に「再入可能性攻撃(Reentrancy Attack)」は、過去に大きな被害を出したこともある、非常に危険な脆弱性として知られています。

このブログ記事では、Solidity初学者の皆さんに向けて、再入可能性攻撃がどのように機能するのか、そしてその主要な対策である「Guard Check」(特にChecks-Effects-Interactionsパターン)について分かりやすく解説します。安全なスマートコントラクト開発の第一歩を踏み出しましょう!

再入可能性攻撃(Reentrancy Attack)とは?🤔

再入可能性攻撃は、あるコントラクト(被害者コントラクト)が別のコントラクト(攻撃者コントラクト)を呼び出した際に、その処理が完了する前に、攻撃者コントラクトが被害者コントラクトの関数を再度呼び出す(再入する)ことで発生します。

特にEtherの送金処理が絡む場合に問題となりやすいです。典型的な攻撃の流れは以下のようになります。

  1. 攻撃者コントラクトが、被害者コントラクトの出金関数(例: `withdraw`)を呼び出します。
  2. 被害者コントラクトは、攻撃者の残高を確認し、Etherを送金しようとします。
  3. Etherを受け取った攻撃者コントラクトのfallback関数(またはreceive関数)が実行されます。
  4. このfallback関数内で、攻撃者コントラクトは再び被害者コントラクトの`withdraw`関数を呼び出します。
  5. 被害者コントラクトは、まだ最初の送金処理による残高の更新が完了していないため、再度残高チェックをパスしてしまい、再びEtherを送金してしまいます。
  6. これが繰り返され、被害者コントラクトの資金が不正に引き出されてしまいます。

この攻撃が成功する主な原因は、状態(例: ユーザーの残高)を更新する前に外部コントラクトへのコール(Ether送金など)を行ってしまう点にあります。

脆弱なコード例

以下は、再入可能性攻撃に対して脆弱な簡単なコントラクトの例です。


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract VulnerableEtherStore {
    mapping(address => uint256) public balances;

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() public {
        uint256 balance = balances[msg.sender];
        require(balance > 0, "Insufficient balance");

        // 危険: 残高を更新する前に外部コール (Ether送金) を行っている
        (bool sent, ) = msg.sender.call{value: balance}("");
        require(sent, "Failed to send Ether");

        // この行が実行される前に再入される可能性がある
        balances[msg.sender] = 0;
    }

    // コントラクトの残高確認用
    function getBalance() public view returns (uint256) {
        return address(this).balance;
    }
}
    

このwithdraw関数では、msg.sender.call{value: balance}("")でEtherを送金したに、balances[msg.sender] = 0;で残高を更新しています。攻撃者は送金を受け取ったタイミングで再度withdraw関数を呼び出すことで、残高が0になる前に何度も資金を引き出すことが可能です。

歴史的に有名なThe DAO事件では、この種の脆弱性が悪用され、当時の価値で約6000万ドル相当のEtherが不正に引き出されました。

対策:Checks-Effects-Interactions パターン🛡️

再入可能性攻撃を防ぐための最も基本的かつ重要な設計パターンが「Checks-Effects-Interactions (CEI)」パターンです。これは、関数内の処理を以下の順序で実行するという考え方です。

  1. Checks(チェック): 関数の実行条件(入力値の検証、権限確認、残高確認など)を最初にすべてチェックします。
  2. Effects(エフェクト): コントラクトの状態変数への変更(残高の増減、所有者の変更など)を次に行います。
  3. Interactions(インタラクション): 外部コントラクトの呼び出しやEtherの送信など、外部とのやり取りを最後に行います。

この順番を守ることで、外部コントラクトを呼び出すにコントラクト内部の状態変更が完了するため、もし外部コントラクトから再入されても、既に更新された状態に基づいてチェックが行われ、不正な操作を防ぐことができます。

修正されたコード例 (CEIパターン適用)


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SecureEtherStore {
    mapping(address => uint256) public balances;

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 _amount) public {
        // 1. Checks (チェック)
        uint256 balance = balances[msg.sender];
        require(balance >= _amount, "Insufficient balance");
        require(_amount > 0, "Amount must be positive");

        // 2. Effects (エフェクト) - 状態変更を先に行う
        balances[msg.sender] = balance - _amount;

        // 3. Interactions (インタラクション) - 外部コールは最後
        (bool sent, ) = msg.sender.call{value: _amount}("");
        require(sent, "Failed to send Ether");

        // イベントを発行 (推奨)
        emit Withdrawal(msg.sender, _amount);
    }

    event Withdrawal(address indexed user, uint256 amount);

    // コントラクトの残高確認用
    function getBalance() public view returns (uint256) {
        return address(this).balance;
    }
}
    

修正後のwithdraw関数では、まずrequire文で残高と要求額をチェック(Checks)し、次にbalances[msg.sender] = balance - _amount;で残高を更新(Effects)、そして最後にmsg.sender.call{value: _amount}("")でEtherを送金(Interactions)しています。この順序により、再入攻撃は効果的に防がれます。

`nonReentrant` 修飾子の利用 (OpenZeppelin) ✨

Checks-Effects-Interactions パターンを手動で実装する代わりに、より安全で簡単な方法として、OpenZeppelin Contracts ライブラリが提供する ReentrancyGuardnonReentrant 修飾子を利用する方法があります。

ReentrancyGuard を継承し、保護したい関数に nonReentrant 修飾子を付与するだけで、その関数への再入を防ぐことができます。これは内部的にロック(ミューテックス)のような仕組みを使って実現されています。

`nonReentrant` 修飾子を使ったコード例


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SecureEtherStoreWithModifier is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    // nonReentrant 修飾子を付与
    function withdraw(uint256 _amount) public nonReentrant {
        // Checks と Effects は依然として Interactions の前が望ましいが、
        // nonReentrant があれば、仮に順序が逆でも再入は防がれる。
        uint256 balance = balances[msg.sender];
        require(balance >= _amount, "Insufficient balance");
        require(_amount > 0, "Amount must be positive");

        balances[msg.sender] = balance - _amount;

        (bool sent, ) = msg.sender.call{value: _amount}("");
        require(sent, "Failed to send Ether");

        emit Withdrawal(msg.sender, _amount);
    }

    event Withdrawal(address indexed user, uint256 amount);

    function getBalance() public view returns (uint256) {
        return address(this).balance;
    }
}
    

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; でライブラリをインポートし、contract SecureEtherStoreWithModifier is ReentrancyGuard で継承、そして function withdraw(uint256 _amount) public nonReentrant のように修飾子を追加するだけです。

注意点: nonReentrant 修飾子は非常に便利ですが、Checks-Effects-Interactions パターンは依然としてコードの可読性と安全性を高めるための良い習慣です。可能であれば両方を意識することをお勧めします。また、nonReentrant 修飾子が付いた関数同士を直接呼び出すことはできない点にも注意が必要です。

まとめ

再入可能性攻撃は、スマートコントラクトにおける深刻な脅威ですが、適切な対策を講じることで防ぐことができます。

  • Checks-Effects-Interactions (CEI) パターンを常に意識し、状態変更を外部コールより先に行う。
  • OpenZeppelin の nonReentrant 修飾子を利用して、より堅牢な保護を実装する。

これらの対策を理解し、実践することで、より安全なスマートコントラクトを開発することができます。セキュリティは継続的な学習が重要ですので、今後も最新の情報に注意していきましょう!💪

参考情報

コメント

タイトルとURLをコピーしました