【テクニカル・上級編】 コントラクトの自己破壊(selfdestruct)による資金凍結 – IoT・OT(制御システム) & ブロックチェーンセキュリティ防御ガイド

コントラクトの自己破壊(SELFDESTRUCT)がもたらす「死の残高強制送金」の悪夢

スマートコントラクトの開発において、私たちはつねに「状態変数」と「残高(address(this).balance)」の整合性に頭を悩ませてきた。だが、実務の現場でセキュリティ監査を行っていると、いまだに初歩的かつ致命的な罠に足を取られるプロジェクトが後を絶たない。その代表格が、EVM(Ethereum Virtual Machine)の低レイヤ命令である SELFDESTRUCT を悪用した強制資金凍結の脅威だ。

教科書的なセキュリティガイドはこう言う。「コントラクトの残高に依存したロジックを書くな」と。しかし、チーフホワイトハッカーやテックリードが知りたいのは、そんな綺麗事ではない。なぜ攻撃者はその手法を選ぶのか、EVMのどの仕様の隙間を突き、どうやってプロトコルの会計を完全に破壊するのか。その泥臭いメカニズムと、現場で生き残るための防衛アーキテクチャを解き明かしていこう。

—

根本原因:EVMの低レイヤ挙動と SELFDESTRUCT の仕様

SELFDESTRUCT(旧 SUICIDE)オペコードは、指定されたコントラクトをブロックチェーン上から消去し、残存するすべてのETHを指定アドレスに強制送金するための機能だった。(※Cancunアップグレード以降、その仕様は縮小傾向にあるが、過去のコントラクトやL2環境を含め、依然として油断ならないリスクである)。

ここでセキュリティエンジニアが理解しなければならないEVMの残酷な真実は、「SELFDESTRUCT による強制送金は、ターゲットコントラクトの receive() や fallback() 関数を完全にバイパスする」 という点だ。

通常、スマートコントラクトにETHを送る場合、何らかの関数が実行されるか、payableな関数がフックされる。しかし、SELFDESTRUCT を使った送金は、受信側のロジックを1バイトたりとも実行させずに、物理的なストレージ上の残高(Balance)の数字だけを強制的に書き換える。

攻撃シナリオの解剖

例えば、以下のような非常にシンプルだが脆弱なデポジット管理コントラクトを考えてみよう。

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

/**
 * @dev 脆弱な会計管理コントラクトのサンプル
 * 残高と内部の帳簿(mapping)の整合性をコントラクトのbalanceに依存させている
 */
contract VmAccountingVault {
    // ユーザーごとの預託金を記録するマッピング
    mapping(address => uint256) public balances;
    
    // 預金総額をコントラクト自身の残高と一致させようとする設計(アンチパターン)
    function deposit() external payable {
        require(msg.value > 0, "Zero deposit");
        balances[msg.sender] += msg.value;
    }

    // 全資産を引き出す関数
    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");
        
        balances[msg.sender] = 0;
        
        // コントラクトの物理残高をチェックしている(ここに脆弱性が潜む)
        require(address(this).balance >= amount, "Insufficient contract balance");

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

このコントラクトの最大の実装ミスは、withdraw() 内の require(address(this).balance >= amount) という一文にある。開発者は「コントラクトが実際に持っているETHの量」を安全弁として信じ込んでいる。

しかし、攻撃者は以下のような捨てゴマ用のコントラクトを用意する。

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

import "./VmAccountingVault.sol";

/**
 * @dev 強制送金(Griefing)を実行するための攻撃者コントラクト
 */
contract GriefingAttacker {
    VmAccountingVault public targetVault;

    constructor(address _target) payable {
        targetVault = VmAccountingVault(_target);
    }

    // 少額をデポジットして正当なユーザーとしてのレコードを作ることも可能
    function attack() external payable {
        // 自爆攻撃によって、強制的にターゲットへETHを送りつける
        // targetVaultのbalanceは増えるが、mapping上のbalancesは1ミリも増えない
        selfdestruct(payable(address(targetVault)));
    }
}

攻撃者がこの GriefingAttacker に少額のETHを持たせてデプロイし、attack() を実行すると、selfdestruct によりターゲットの VmAccountingVault へ強制的にETHが流れ込む。

結果として何が起きるか?

  • VmAccountingVault の address(this).balance は増加する。
  • しかし、どのユーザーの balances[msg.sender] も増加しない。

この状態になると、後からまともなユーザーが預金を引き出そうとした際、コントラクトの総残高と内部帳簿の辻褄が狂い、最悪の場合、一部のユーザーが二度と資金を引き出せない「永久資金凍結(Permanent DoS)」の状態に陥る。コントラクトのロジックが物理的な残高に依存しているがゆえの悲劇だ。

—

セキュリティアーキテクトのための防衛戦略

では、私たちはこの「不可避の強制送金」に対して、どのように盾を構えるべきか。モダンなWeb3セキュリティ設計における3つの鉄則を提示する。

1. address(this).balance への依存を完全に断つ

スマートコントラクト内のビジネスロジックで address(this).balance を直接比較条件や計算の分母に使用してはならない。残高管理は常に内部のストレージ変数(例: totalDeposited や mapping)のみで行うべきだ。外部からの強制送金によって物理残高が増えたとしても、内部変数が追従しなければ、コントラクトの動作に影響を与えない設計にすることが大前提となる。

2. 「不変の会計(Invariable Accounting)」の導入

資産の総量をトラッキングする際は、以下のように厳密な内部変数によるクローズドな会計システムを構築する。

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

/**
 * @dev 堅牢なアーキテクチャのサンプル
 * 内部の累計値のみで会計を管理し、実際の残高に依存しない
 */
contract SecureVault {
    mapping(address => uint256) public userBalances;
    uint256 public totalInternalBalance; // 内部管理された総額

    function deposit() external payable {
        require(msg.value > 0, "Invalid amount");
        userBalances[msg.sender] += msg.value;
        totalInternalBalance += msg.value; // 内部変数で厳密に追跡
    }

    function withdraw(uint256 amount) external {
        require(userBalances[msg.sender] >= amount, "Insufficient balance");
        
        userBalances[msg.sender] -= amount;
        totalInternalBalance -= amount;

        // 残高のチェックではなく、内部の数学的整合性のみを信頼する
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }

    // 万が一強制送金された余剰資金を救出するための管理者用関数を別途用意する(必要に応じて)
    function rescueExcessBalance(address payable _to) external onlyOwner {
        uint256 actualBalance = address(this).balance;
        require(actualBalance > totalInternalBalance, "No excess balance");
        
        uint256 excess = actualBalance - totalInternalBalance;
        (bool success, ) = _to.call{value: excess}("");
        require(success, "Rescue failed");
    }
}

この設計であれば、たとえ攻撃者が SELFDESTRUCT によってどれだけ大量のETHを送りつけてきても、totalInternalBalance と userBalances は微動だにせず、正当なユーザーの引き出し処理(withdraw)は何の障害もなく実行され続ける。余剰分は rescueExcessBalance のような安全な経路で切り離せばよい。

—

監査の現場から:プロの手口を見抜くチェックリスト

テックリードやコードレビューアーがプルリクエスト(PR)を監査する際、以下のポイントを機械的かつ執念深くチェックしてほしい。

1. balance キーワードの検索: コードベース全体で address(this).balance または balanceOf(address(this)) が使われていないか。使われている場合、それが単なる情報表示(UI用など)ではなく、ロジックの分岐条件になっていないか。
2. プレースホルダー的コントラクトの検証: ファクトリーパターンやプロキシパターンを採用している場合、生成される子コントラクトが強制送金に対して耐性を持っているか。
3. テストコードでのカオスエンジニアリング: 単体テストにおいて、意図的に selfdestruct を実行する攻撃用モックコントラクトを作成し、通常のデポジット・ウィズドローサイクルに予期せぬ外部資金流入をぶつける「混沌テスト(Chaos Testing)」をCIパイプラインに組み込んでいるか。

セキュリティとは、性善説で作られた美しいコードを褒め合うことではない。悪意ある攻撃者がEVMの仕様の隙間を縫って、システムをどのように崩壊させるかを先回りして想像し、その破壊衝動をあらかじめコードの構造で無力化することだ。

プロトコルの寿命を左右するのは、洗練されたUIでも華やかなトークノミクスでもなく、こうした泥臭い低レイヤへの理解に裏打ちされた、鉄壁のスマートコントラクト・アーキテクチャにほかならない。

コメント

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