【実務・中級編】 再入攻撃(Reentrancy Attack)のメカニズムと防衛策 – IoT・OT(制御システム) & ブロックチェーンセキュリティ防御ガイド

再入攻撃(Reentrancy Attack)の深淵:なぜ「残高更新」を最後に回してはいけないのか

現場で長年インシデント対応をしていると、ふと気がつくことがある。「なぜ、あれほど教科書的に警告されている脆弱性が、未だにこれほどまでにプロダクション環境で息づいているのか」と。

特にWeb3やDeFiの文脈で語られる「再入攻撃(Reentrancy Attack)」は、その筆頭だ。しかし、この脆弱性は何もSolidityだけの話ではない。API設計や非同期処理が絡むシステムであれば、その本質は全く同じ。外部リソースを呼び出すタイミングと、内部状態を更新するタイミングの「ズレ」が、システムの全資産を食いつぶすバグを生む。

今日は、この「再入攻撃」のメカニズムを解剖し、現場で即座に実装可能な防衛策を叩き込む。

—

1. 再入攻撃のメカニズム:時計を止めるトリック

再入攻撃を理解するには、銀行の窓口を想像してほしい。

1. あなたが「残高を全部引き出したい」と伝える。
2. 窓口の担当者は「残高があるか」を確認する。
3. 確認後、担当者が「現金を準備しに行っている隙に」、あなたがもう一回「残高を引き出したい」と別の窓口で申請する。
4. 最初の窓口はまだ「残高確認」を終えた状態のままだから、二重に払い出されてしまう。

これをコードでやるとこうなる(Solidityの例だが、本質は全言語共通だ)。

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

    // ★ここで外部呼び出しが発生!
    // 攻撃者はこの呼び出しの隙に、再度 withdraw() を叩く
    (bool success, ) = msg.sender.call{value: balance}("");
    require(success);

    // 状態更新が外部呼び出しの後にあるのが致命的
    balances[msg.sender] = 0; 
}

攻撃者は、この呼び出しの最中に制御を奪い、再帰的に関数を呼び出す。状態変数が更新される前に次の処理が走るため、システムは「まだ残高がある」と誤認し続ける。

—

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

この脆弱性を根絶する最もシンプルで強力なルールが、「Checks-Effects-Interactions」という設計思想だ。

1. Checks: 入力値や条件の確認(require)。
2. Effects: 内部状態の更新(残高を0にする)。
3. Interactions: 外部との通信(送金や外部API呼び出し)。

これを守るだけで、ほとんどの再入攻撃は防げる。

// 【セキュアな実装例】
function withdraw() public {
    uint256 balance = balances[msg.sender];
    require(balance > 0); // Checks

    balances[msg.sender] = 0; // Effects (先に状態をゼロにする)

    (bool success, ) = msg.sender.call{value: balance}(""); // Interactions
    require(success);
}

—

3. 実務で使える守護神:ReentrancyGuard

もし、複雑なロジックで「どうしても外部呼び出しを先にしなければならない」という例外的な状況があるなら、ミューテックス(排他制御)を導入するしかない。

JavaScript(Node.js)で非同期処理を行うWeb API開発でも、この考え方は重要だ。以下は、JSで排他ロックを模倣する実装例である。

// 【Node.js向け:再入防止のための簡易ガード実装】
let isLocked = false;

async function withdraw(userId, amount) {
    if (isLocked) {
        throw new Error("現在処理中です。連続操作はできません。");
    }

    // ロックをかける
    isLocked = true;

    try {
        const balance = await db.getBalance(userId);
        if (balance < amount) throw new Error("残高不足");

        // 外部APIを叩く前に状態を確定させる
        await db.updateBalance(userId, balance - amount);
        
        // 外部決済サービスへの呼び出し
        await paymentGateway.transfer(userId, amount);
        
    } finally {
        // 処理終了後にロックを解除
        isLocked = false;
    }
}

—

4. セキュリティチーフからの「最後の警告」

技術スタックが何であれ、Webサービスにおける脆弱性は「状態の整合性」に対する甘さから生まれる。

  • WAFの役割: WAFはSQLiやXSSを防ぐには有能だが、ビジネスロジックの不備(再入攻撃など)までは防げない。これは開発者がコードで守るべき領域だ。
  • ログの監視: もし、短時間に同じユーザーから異常な回数のリクエストが飛んできているなら、それは攻撃の予兆だ。Nginxのlimit_reqモジュールなどでレートリミットを厳格に設定し、監視アラートを飛ばす運用をセットで行うこと。
# Nginx設定例:短時間の連打を制限する
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=1r/s;

location /api/withdraw {
    limit_req zone=api_limit burst=5 nodelay;
    proxy_pass http://backend_cluster;
}

現場のエンジニア諸君、セキュリティはパッチを当てることではなく、設計段階で「悪意ある第三者がシステムをどうハックするか」をシミュレーションすることから始まる。今回紹介した「先に状態を更新する」という鉄則を、今すぐ自社のレポジトリに適用してほしい。

コードは嘘をつかない。だが、その論理構造は時に人を欺く。常に疑い、常に検証せよ。それがプロの仕事だ。

コメント

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