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

こんにちは!セキュリティリサーチャーの視点から、今回はブロックチェーンの世界で最も有名にして、最も恐ろしい脆弱性の一つである「再入攻撃(Reentrancy)」と、その鉄壁の防御策である「Checks-Effects-Interactionsパターン」についてお話しします。

「ブロックチェーンやスマートコントラクトって難しそう……」と感じている新人エンジニアの方もご安心ください。まずは身近な「防犯」の例えから、一歩ずつ優しく紐解いていきましょう!

—

1. 家の鍵を閉める順番、間違えていませんか?

突然ですが、あなたが自宅の玄関から郵便物を取り出しに行くと想像してください。ここで、防犯意識の低い行動をとってしまったとします。

1. ドアを開けて、外のポストに向かう。
2. まだドアを完全に閉めていない(鍵もかけていない)状態のまま、郵便物を仕分け始める。
3. その隙に、近くをうろついていた泥棒が「あ、ドアが開けっぱなしだ!」と、あなたが中に戻る前に家へ侵入してしまう。

……恐ろしい光景ですよね。現実の世界では、私たちは「ドアを閉めて、しっかり鍵をかけてから」別の作業に移ります。

しかし、スマートコントラクト(ブロックチェーン上で動くプログラム)の世界では、この「鍵を閉める順番」をうっかり間違えてしまい、資産をごっそり盗まれてしまう事件が後を絶ちません。これが再入攻撃(Reentrancy)の正体です。

—

2. スマートコントラクトにおける「再入攻撃」のメカニズム

イーサリアムなどのブロックチェーン上では、スマートコントラクト同士がお金を送り合うことができます。例えば、ユーザーが預けた預金を引き出す(Withdraw)機能を考えてみましょう。

脆弱性のあるコードは、だいたい次のような仕組みになっています。

// 【危ない例】絶対に真似してはいけないコードです
mapping(address => uint256) public balances;

function withdraw(uint256 _amount) public {
    // 1. 残高があるか確認する
    require(balances[msg.sender] >= _amount);

    // 2. 【危険】確認が終わらないうちに、先にお金を送ってしまう!
    (bool sent, ) = msg.sender.call{value: _amount}("");
    require(sent, "Failed to send Ether");

    // 3. 【遅すぎる】お金を送った「後」で、残高をゼロにする
    balances[msg.sender] = 0;
}

一見すると、「残高を確認して、送金して、残高をゼロにするんだから問題ないのでは?」と思えますよね。ここに、ブロックチェーンならではの大きな罠があります。

もし、お金を受け取る側(msg.sender)が、悪意あるプログラム(スマートコントラクト)だったらどうなるでしょう?

お金が送られてきた瞬間、その悪意あるプログラムは、自分で受け取った処理(receive関数など)の中で、「もう一回 withdraw() を呼び出す」というコードを実行します。

タイムラインで追ってみましょう。
1. 悪意あるユーザーが withdraw() を呼ぶ。
2. 残高チェックをクリアする。
3. お金が送られてくる。
4. お金を受け取った悪意あるプログラムが、残高が書き換わる(ステップ4の処理)より前に、もう一度 withdraw() を呼び出す!
5. ステップ4がまだ実行されていないため、コントラクトは「まだこの人のお金は残っている!」と勘違いし、再びお金を送ってしまう。

これが、泥棒がドアを閉められる前に何度も家に侵入してくる「再入攻撃」のループです。ループが回り続けると、コントラクトの中にあるETH(暗号資産)がすべて底をつくまで引き出されてしまいます。

—

3. 鉄壁の防御!「Checks-Effects-Interactions」パターン

この恐ろしい攻撃を防ぐための黄金律が、「Checks-Effects-Interactions(チェック・効果・相互作用)」という設計原則です。

考え方はとてもシンプルで、現実の防犯と同じです。

1. Checks(チェック): 条件が満たされているか、最初に入念に確認する(例:残高はあるか?)。
2. Effects(効果): コントラクト内の「状態(残高など)」を、外部にお金を動かす前にすべて更新する(例:残高を先にゼロにする)。
3. Interactions(相互作用): 最後に、外部のコントラクトへお金を送るなどの「外部呼び出し」を行う。

先ほどの危ないコードを、この原則に従って書き直してみましょう。

// 【安全な例】Checks-Effects-Interactionsパターンを徹底したコード
mapping(address => uint256) public balances;

function withdraw(uint256 _amount) public {
    // 1. 【Checks】条件の確認
    require(balances[msg.sender] >= _amount, "Insufficient balance");

    // 2. 【Effects】外部へ送金する「前」に、自分の台帳(状態)を更新する!
    balances[msg.sender] = balances[msg.sender] - _amount;

    // 3. 【Interactions】すべての状態更新が終わった「後」で、お金を送る
    (bool sent, ) = msg.sender.call{value: _amount}("");
    require(sent, "Failed to send Ether");
}

この順番に変えるだけで、万が一相手が「もう一回 withdraw() を呼ぼう!」と悪巧みしても、ステップ2ですでに残高が 0 に書き換わっているため、ステップ1のチェックで必ず弾かれます。泥棒が入ろうとした瞬間には、すでに頑丈な内鍵が閉まっている状態というわけです。

—

4. さらに安心を重ねる:ReentrancyGuard の活用

Checks-Effects-Interactionsパターンは基本中の基本ですが、複雑なコントラクトを開発していると、うっかり順番を間違えてしまうヒューマンエラーが起こるかもしれません。

そこで、業界標準の開発フレームワーク(OpenZeppelinなど)では、ReentrancyGuard という「二重ロック用の修飾子(Modifier)」が用意されています。

使い方はとても簡単です。コントラクトに鍵(ガード)を設置して、関数に nonReentrant という合言葉をつけるだけです。

// OpenZeppelinのReentrancyGuardをインポートする前提のサンプルコード
import "@openzeppelin/contracts/security/ReentrancyGuard.h";

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

    // nonReentrant 修飾子をつけるだけで、関数実行中の「再入」を完全にブロックします
    function withdraw(uint256 _amount) public nonReentrant {
        require(balances[msg.sender] >= _amount, "Insufficient balance");

        // 状態の更新
        balances[msg.sender] = balances[msg.sender] - _amount;

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

この nonReentrant をつけておくと、関数が実行されている最中に同じ関数が再び呼び出された場合、自動的に処理が中断(リバート)されます。二重の備えをしておけば、より安心ですね!

—

まとめ

今回は、再入攻撃の仕組みと、それを防ぐための「Checks-Effects-Interactionsパターン」について解説しました。

  • 外部にお金を送る(あるいは外部コントラクトを呼ぶ)ときは、必ず処理の「最後」にする。
  • お金を動かす前に、自分のコントラクト内の状態(残高など)を「先」に更新する。
  • 心配なときは ReentrancyGuard のような修飾子を頼る。

セキュリティの世界は一見すると難しく見えますが、こうした「身の回りの防犯の理屈」をプログラムに置き換えて考えることで、確実に堅牢なシステムを作ることができます。

一歩ずつ、安全で信頼されるエンジニアへの階段を登っていきましょう!次回の解説もお楽しみに!

コメント

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