【入門編】 再入攻撃(Reentrancy)の高度な検知とガードの実装 – IoT・OT(制御システム) & ブロックチェーンセキュリティ防御ガイド

こんにちは!セキュリティリサーチャーとして、日々SCADA(制御システム)の物理的な隙間から、ブロックチェーンのコードの1行までを舐めるように解析している筆者です。

皆さんは「スマートコントラクト」の開発に触れたとき、最初に「なんだか魔法みたいだな」と思いませんでしたか? 自動でお金が動き、契約が執行される。しかし、その「魔法」の裏には、攻撃者が虎視眈々と狙っている「一瞬の隙」が存在します。

今日は、数々のプロジェクトを崩壊させてきた恐ろしい攻撃、「再入攻撃(Reentrancy Attack)」についてお話しします。難しそうな名前ですが、実は「家の鍵をかけるタイミング」と同じくらい身近な知恵で防げるものなんです。

初心者の方でも迷わないよう、泥棒と家の防犯に例えて、優しく、でも本質を突いた解説をしていきますね。一歩ずつ、一緒に学んでいきましょう!

—

1. 再入攻撃ってなに?「魔法の返金手続き」の罠

まずは、この攻撃がどんなものか、身近な例でイメージしてみましょう。

想像してみてください。あなたは近所の無人販売所で、1,000円の商品を返品して返金を受けようとしています。

1. あなたが「返金してください」とボタンを押します。
2. 機械が「はい、1,000円返しますね」とお金を出口に出します。
3. あなたがそのお金を手に取った後、機械が「返金済み」と帳簿に記録します。

一見、何も問題ないように見えますよね? でも、もしあなたが「凄腕の泥棒」だったらどうするでしょう。

機械がお金を出した瞬間(手順2の後)、帳簿に「返金済み」と書かれる前(手順3の前)に、もう一度「返金してください!」とボタンを押したらどうなるでしょうか?

機械はまだ帳簿を書き換えていないので、「おや、まだこの人は返金を受けていないな」と勘違いして、また1,000円を出してしまいます。 これを繰り返せば、機械の中のお金が空っぽになるまで引き出せてしまいますよね。これが「再入攻撃」の正体です。

—

2. なぜプログラムでこんなことが起きるの?

スマートコントラクト(Solidity)の世界では、外部の口座にお金を送る際、相手のプログラムが実行されるタイミングがあります。

攻撃者は自分の口座(コントラクト)に「お金を受け取った瞬間に、もう一度相手の返金関数を呼び出す」という特殊な仕掛けを組み込んでおきます。

【危険なコードの例】

まずは、あえて「隙」のあるコードを見てみましょう。

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

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

    // お金を預ける
    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    // お金を引き出す(ここが危険!)
    function withdraw() public {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "残高が足りません");

        // 1. 先にお金を送ってしまう
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "送金失敗");

        // 2. 送金が終わった後に残高をゼロにする(手遅れ!)
        balances[msg.sender] = 0;
    }
}

このコードの何がマズいかというと、「お金を渡してから、帳簿(balances)を書き換えている」点です。送金処理(call)の最中に攻撃者が戻ってきて、まだ 0 になっていない残高を元に、何度も引き出しを繰り返せてしまうのです。

—

3. 鉄壁の守りその1:Checks-Effects-Interactionsパターン

この攻撃を防ぐ最も基本的で強力なルールが、「Checks-Effects-Interactions(確認・実行・相互作用)」パターンです。

これは、防犯で言えば「品物を渡す前に、必ず領収書にサインをもらい、在庫台帳を消し込む」というルールを徹底することに似ています。

  • Checks: 条件の確認(残高はあるか?)
  • Effects: 自分の状態の更新(先に残高を0にする!)
  • Interactions: 外部とのやり取り(最後にお金を送る)

【安全なコードの例】

先ほどのコードを、このルールに従って書き換えてみましょう。

function withdraw() public {
    uint256 amount = balances[msg.sender];
    
    // [Checks] まずは条件をチェック!
    require(amount > 0, "残高が足りません");

    // [Effects] ここが超重要! お金を送る前に「帳簿」を先に書き換えます
    balances[msg.sender] = 0;

    // [Interactions] 最後に実際のお金を送ります
    (bool success, ) = msg.sender.call{value: amount}("");
    
    // もし送金が失敗したら、帳簿を元に戻す(ロールバックされる)ので安心です
    require(success, "送金失敗");
}

こうしておけば、攻撃者が送金の途中で「もう一回返して!」と戻ってきても、既に balances[msg.sender] は 0 になっているので、2回目は拒否されます。鍵を閉めてから中身を確認しに行くようなものですね。

—

4. 鉄壁の守りその2:ReentrancyGuard(二重ロック)

「ルールはわかったけど、もっと物理的にガチッと守る方法はないの?」という方におすすめなのが、ReentrancyGuard です。

これは、関数の入り口に「使用中」という札をかけ、処理が終わるまで誰も(自分自身でさえも)入れないようにする仕組みです。

OpenZeppelinという世界的に信頼されているライブラリを使うのが一般的です。

【ガードを実装した例】

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

// ReentrancyGuardを継承します
contract SecureBank is ReentrancyGuard {
    mapping(address => uint256) public balances;

    // nonReentrant という「お守り」を関数につけます
    function withdraw() public nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "残高が足りません");

        balances[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "送金失敗");
    }
}

nonReentrant という言葉を添えるだけで、この関数が実行されている間は、同じコントラクト内の他の(同じガードがついた)関数も呼び出せなくなります。まるで、「一度入ったら、用事が済んで外に出るまでドアをロックする」強力な自動ドアのようなものです。

—

5. 現場のリサーチャーが見る「盲点」:クロス関数再入

ここまでは「同じ関数にまた入ってくる」話でしたが、実はもっとずる賢い攻撃があります。それが「クロス関数再入」です。

例えば、「お金を引き出す関数」と「自分の残高を誰かに譲渡する関数」の2つがあったとします。

1. 「引き出し関数」でお金を送る(まだ帳簿は書き換わっていない)。
2. その途中で「譲渡関数」を呼び出し、まだ残っているはずの残高を別のアカウントに移動させる。
3. 移動先のアカウントからもお金を引き出す。

このように、複数の関数をまたいで「帳簿の書き換え待ち」を狙われることがあります。だからこそ、「お金に関連するすべての関数にガードをつける」ことと、「常にChecks-Effects-Interactionsを守る」ことがセットで重要になるんです。

—

最後に:一歩ずつ、安全なコードへ

いかがでしたでしょうか?
再入攻撃は、たった1行の書く順番を間違えるだけで、何十億円という資産が失われる恐ろしい脆弱性です。でも、その本質は「後片付けを先にする」という、私たちの日常生活でも大切な習慣と同じなんです。

1. Checks-Effects-Interactions を合言葉にする。
2. 大事な処理には nonReentrant の鍵をかける。
3. 「もしこの処理の途中で割り込まれたら?」と常に疑ってみる。

この3点を意識するだけで、あなたの書くコードの安全性は飛躍的に高まります。

セキュリティの世界は奥が深いですが、こうして一つずつ仕組みを解き明かしていけば、決して怖いものではありません。これからも一緒に、安全でワクワクするWeb3の世界を作っていきましょう!

もし分からないことがあれば、いつでもドキュメントに戻ってきたり、信頼できるコミュニティで質問してみてくださいね。「一歩ずつ」が、最強の防御への近道です!

コメント

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