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

こんにちは!IoT・OTの現場からブロックチェーンの最前線まで、日々泥臭いセキュリティ調査に奔走しているリサーチャーの私です。

今回は、Web3開発の世界で何度もエンジニアたちの財布を泣かせてきた「スマートコントラクトの再入攻撃(Reentrancy)」と、それを綺麗に防ぎきるための「Checks-Effects-Interactionsパターン」について、一歩ずつ優しく紐解いていきたいと思います。

「難しそうな用語が出てきて不安だな……」なんて思っていませんか?大丈夫です。身近な防犯の仕組みに例えながら、一緒に紐解いていきましょう!

—

1. 家の鍵で例える「再入攻撃(Reentrancy)」の恐怖

まずは、攻撃者がどのようにスマートコントラクトの隙を突いてくるのか、私たちの日常生活によくあるシチュエーションでイメージしてみましょう。

想像してみてください。あなたは自宅の郵便受けから手紙を取り出すために、信頼している隣人に「留守の間に郵便物取っておいてね」と頼みました。
隣人があなたの家に行き、郵便受けを開けました。その瞬間、おかしなことが起きます。

隣人が手紙を取り出して「はい、どうぞ」とあなたに渡そうとするその隙に、あなたの家の裏口から見知らぬ泥棒が忍び込み、郵便受けを開けられた無防備な状態のまま、家の中にある金庫の鍵を何度も何度も開けさせようと割り込んできたのです。
しかも、あなたが「まだ1回しか手紙を渡していないから、金庫を開けるのは待って!」と帳簿に書き留める(状態を更新する)より早く、泥棒は「まだ終わってないよね?」と狂ったように連続で要求を繰り返します。

結果として、実際には1回分しか預けられていないはずなのに、金庫の中身がすべて空っぽになるまでお金が引き出されてしまう……これが、スマートコントラクトにおける「再入攻撃」のメカニズムです。

なぜブロックチェーンで起きるのか?

ブロックチェーンの世界では、あるコントラクトから別のコントラクトへ「ETH(暗号資産)」などの送金を行う際、宛先のコントラクトのプログラムが強制的に呼び出される仕組みになっています。

攻撃者は、この「送金された瞬間に自動で実行されるプログラム(いわゆる罠)」を仕掛けた悪意あるコントラクトを用意します。あなたのコントラクトがお金を送り出した瞬間、その処理が完了するよりも前に、攻撃者のプログラムがあなたのコントラクトへ「もう一度お金をちょうだい!」と何度も飛び込んで(再入して)くるのです。

—

2. 泥棒を防ぐ鉄則!「Checks-Effects-Interactionsパターン」とは?

この恐ろしい泥棒を防ぐために、私たち開発者が絶対に身につけておかなければならない設計原則が Checks-Effects-Interactionsパターン(チェック・効果・相互作用の順序を守る原則) です。

言葉は堅苦しいですが、考え方はとてもシンプルです。先ほどの郵便受けの例で言えば、「お金を渡す(外とのやり取り)よりも前に、手帳の残高を書き換えておく(自分の内部状態の更新)」 という順番を徹底することになります。

具体的には、以下の3つのステップをこの順番通りに厳守します。

1. Checks(確認): 「そもそもこの人は本当にお金を引き出す権利があるか?残高は足りているか?」を最初に確認する。
2. Effects(効果): 確認が取れたら、外にお金を動かす前に、自分の手元にある「この人の残高データ」をあらかじめゼロに書き換える。
3. Interactions(相互作用): 最後に、外の相手(別のコントラクトやユーザー)へ実際にお金を送る。

もしステップ2(Effects)で先に自分の残高をゼロにしておけば、たとえステップ3(Interactions)の最中に攻撃者が割り込んできて「もう一度お金をちょうだい!」と再入してきても、ステップ1(Checks)の段階で「あなたの残高はもうゼロです!」と即座に追い返すことができます。これぞ完璧な防犯対策ですね!

—

3. 実装コードで違いを見てみましょう

それでは、実際のSolidityコード(スマートコントラクトを書くためのプログラミング言語)を使って、危険な例と安全な例を見比べてみましょう。

【危険なコード例】(絶対に真似しないでください)

このコードでは、外への送金(call)が先に行われており、残高の書き込みが後回しになっています。

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

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

    // 預金機能
    function deposit() external payable {
        balances[msg.sender] += msg.DATA; // ※サンプル用の記述です
    }

    // 危険な引き出し機能
    function withdraw(uint256 _amount) external {
        require(balances[msg.sender] >= _amount, "残高不足です");

        // 【NG】外への送金を先に実行している(ここで攻撃者に割り込まれる!)
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "送金に失敗しました");

        // 【NG】残高の更新が送金の後になっている
        balances[msg.sender] -= _amount; 
    }
}

【安全なコード例(Checks-Effects-Interactionsの徹底)】

先ほどの順番をきれいに入れ替えた、安全な実装がこちらです。

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

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

    // 預金機能
    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    // 安全な引き出し機能
    function withdraw(uint256 _amount) external {
        // 1. Checks(条件の確認)
        require(balances[msg.sender] >= _amount, "残高不足です");

        // 2. Effects(外部へ送る前に、自分の内部状態を先に更新する)
        balances[msg.sender] -= _amount;

        // 3. Interactions(すべての内部処理が終わったあとに外へ送金する)
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "送金に失敗しました");
    }
}

どうでしょう? balances[msg.sender] -= _amount; の行を、外部送金の call よりも上に持ってくるだけで、再入攻撃の隙を完全に断ち切ることができるのです。

—

4. さらなる鉄壁の備え:ReentrancyGuard 修飾子

先ほどのChecks-Effects-Interactionsパターンだけでも十分強力ですが、実務の現場では「万が一のうっかりミス」を防ぐために、OpenZeppelinなどが提供している防衛用の盾、ReentrancyGuard という仕組み(修飾子)を組み合わせて使うのが業界の標準的なベストプラクティスです。

これは、関数に「鍵(ロック)」をかける仕組みだとイメージしてください。

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

// OpenZeppelinの標準的なReentrancyGuardをインポートする想定
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

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

    // 預金機能
    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    // 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 というのが、まさにその「防犯ヘッダー(修飾子)」の役割を果たしています。この修飾子がついた関数が実行されている最中は、たとえ攻撃者があの手この手で割り込もうとしても、「現在この部屋は使用中です!」とシステムが自動的にシャットアウトしてくれます。

—

まとめ

今回は、スマートコントラクトの代表的な脅威である「再入攻撃」と、それを防ぐための「Checks-Effects-Interactionsパターン」、そして「ReentrancyGuard」について解説しました。

  • 「外にお金を送る前に、自分の手元の記録(状態)を先に更新する」
  • 心配なときは nonReentrant の鍵をしっかりかける

この2つを日々の開発の習慣にするだけで、あなたの書くスマートコントラクトの安全性は劇的に跳ね上がります。
セキュリティの世界は奥が深いですが、一歩ずつ確実に対策を学んでいけば、決して恐ろしいものではありません。

それでは、次のセキュリティ解説記事でお会いしましょう!安全で楽しいWeb3ライフを!

コメント

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