【実務・中級編】 コントラクトの再入攻撃(Reentrancy)に対するChecks-Effects-Interactionsパターン – IoT・OT(制御システム) & ブロックチェーンセキュリティ防御ガイド

なぜ「再入攻撃(Reentrancy)」は死なないのか?――その本質と鉄壁の防御術

現場でコードを叩いていると、セキュリティのベストプラクティスを「教科書的なお作法」として捉えている奴が多い。だが、スマートコントラクト、特にイーサリアム系のEVM環境において、再入攻撃(Reentrancy)を甘く見ることは、銀行の金庫を開けっ放しにして「誰も入ってこないだろう」と祈るに等しい行為だ。

今日は、なぜ Checks-Effects-Interactions パターンが重要なのか、そしてなぜ ReentrancyGuard だけでは不十分なのか、その「泥臭い現実」を解説しよう。

—

1. そもそも「再入攻撃」で何が起きるのか

再入攻撃のロジックは極めてシンプルだ。攻撃者は、コントラクトの「残高を更新する前」に、コントラクトが外部(攻撃者のコントラクト)へ送金を行う隙を突く。

以下のコードを見てくれ。これが、世の中で最も典型的な「死ぬコード」のパターンだ。

// 脆弱な実装例
function withdraw(uint256 _amount) public {
    require(balances[msg.sender] >= _amount);

    // 1. 外部呼び出し(Interaction)
    (bool success, ) = msg.sender.call{value: _amount}("");
    require(success);

    // 2. 状態更新(Effects)← ここで攻撃者が再帰的に呼び出す!
    balances[msg.sender] -= _amount;
}

攻撃者が fallback() 関数を持つ悪意あるコントラクトからこれを叩くと、送金処理の最中に制御権が奪われ、balances が減算される前に何度も withdraw が実行される。結果、残高が無限に引き出されるというわけだ。

—

2. 鉄則:Checks-Effects-Interactions パターン

この攻撃を未然に防ぐための第一の防波堤が、Checks-Effects-Interactions パターンだ。シンプルに言えば、「外の世界と関わる前に、自分の中の計算を終わらせろ」ということだ。

修正後の実装はこうなる。

// セキュアな実装例
function withdraw(uint256 _amount) public {
    // 1. Checks: まず条件を確認
    require(balances[msg.sender] >= _amount, "残高不足");

    // 2. Effects: 先に状態を更新する
    balances[msg.sender] -= _amount;

    // 3. Interactions: 最後に外部送金を行う
    (bool success, ) = msg.sender.call{value: _amount}("");
    require(success, "送金失敗");
}

これだけで多くの再入攻撃は防げる。だが、複雑なトークン標準(ERC777など)や、複数のコントラクトが絡む処理では、これだけでは足りないこともある。そこで登場するのが ReentrancyGuard だ。

—

3. ReentrancyGuard による「二重の鍵」

OpenZeppelinの ReentrancyGuard は、関数実行中に別の呼び出しが入ることを物理的に防ぐ「排他制御」を行う。

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

contract SecureVault is ReentrancyGuard {
    // nonReentrant 修飾子を付けることで、関数実行中の再入を拒否する
    function withdraw(uint256 _amount) external nonReentrant {
        require(balances[msg.sender] >= _amount);
        
        balances[msg.sender] -= _amount;
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success);
    }
}

この nonReentrant 修飾子を付けると、関数実行中に内部でまた自分自身が呼ばれた際、コントラクト内の _status フラグがそれを検知して処理を強制終了させる。まさに「物理的な鍵」だ。

—

4. Webアプリエンジニアへの教訓

スマートコントラクトの話に限定せず、Webアプリ開発者にも伝えたいことがある。

君たちが書くAPIでも同じだ。例えば、決済処理を行う際に「DBの残高を更新する前」に「外部の決済ゲートウェイAPIを叩く」ような実装をしていないか?もしそのAPIが何らかの理由でタイムアウトしたり、二重送信されたりしたらどうなる?

「外部呼び出しは常に信頼できない(Untrusted)」という原則を忘れてはいけない。

インフラ層での防御(WAFの考え方)

Webサービスであれば、AWS WAFなどで「異常なリクエストレート」を検知するのはもちろん、アプリケーション側で「冪等性(Idempotency)」を担保することが重要だ。リクエストIDをDBのユニークキーとして持ち、同じ処理が2回走らないように制御する。これはスマートコントラクトの nonReentrant と同等の思想だ。

—

まとめ:防御の哲学

1. 信頼の最小化: 外部呼び出しは常に攻撃の起点となり得ると考えろ。
2. 状態更新を先に: 内部の状態(DBやストレージ)は、外部との通信が始まる前に確定させる。
3. 多層防御: Checks-Effects-Interactions を基本としつつ、重要な操作には ReentrancyGuard のような排他制御を重ねる。

セキュリティとは、完璧なコードを書くことではない。「どこが破られたらヤバいか」を理解し、その一点を徹底的に守り抜く執念だ。現場のコードで再入攻撃の匂いがしたら、即座に修正を入れること。それがプロのリサーチャーとしての最低限の流儀だ。

コメント

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