【実務・中級編】 デリゲートコール(delegatecall)の危険性とストレージ衝突 – IoT・OT(制御システム) & ブロックチェーンセキュリティ防御ガイド

制御不能な「メモリの書き換え」:delegatecallとストレージ衝突が引き起こす惨劇

現場で「なぜか残高が消えた」「管理者の権限が勝手に書き換わった」という相談を受けるとき、その裏側に潜んでいるのが delegatecall の悪用です。これは、SCADAのPLC(プログラマブルロジックコントローラ)のメモリ空間を直接書き換えるような、非常に危険かつ魅惑的な脆弱性です。

今日は、ブロックチェーン開発者がつい陥りがちな「ストレージ衝突」の罠について、実戦的な視点で深掘りします。

—

delegatecallとは何か?——「借り物のコード」の代償

delegatecall は、外部コントラクトのコードを「自分のコンテキスト」で実行する仕組みです。例えば、ライブラリのロジックを再利用したいときに便利ですが、ここには致命的な前提条件があります。

「呼び出し元と呼び出し先のストレージレイアウトは、完全に同一でなければならない」

もし、呼び出し先のコントラクトが、あなたのコントラクトの「想定外の場所」を書き換えたらどうなるか? 攻撃者はその隙を突き、コントラクトの所有者フラグや、送金先リストを自由自在に上書きします。

ストレージ衝突のPoC:何が起きているのか?

以下のコードを見てください。非常に単純ですが、これこそが「ハッキングの入り口」です。

// 脆弱なコントラクト(Proxy)
contract Proxy {
    address public owner;     // スロット0
    address public lib;       // スロット1

    function callSetOwner(address _newOwner) public {
        lib.delegatecall(abi.encodeWithSignature("setOwner(address)", _newOwner));
    }
}

// 攻撃者が用意する悪意あるコントラクト
contract MaliciousLib {
    address public dummy;     // スロット0:Proxyのownerと衝突!
    address public attacker;  // スロット1:Proxyのlibと衝突!

    function setOwner(address _newOwner) public {
        // ここでProxyのスロット0(owner)を直接上書きする
        // アドレスを強制的に自分に変更できる
        owner = msg.sender; 
    }
}

このケースでは、Proxy の owner がスロット0に定義されているのに対し、MaliciousLib もスロット0で変数を定義しています。delegatecall を呼び出した瞬間、Proxy 側の owner は、MaliciousLib のロジックによって攻撃者のアドレスに書き換わります。これが「ストレージ衝突」の正体です。

—

実務で絶対に守るべき「セキュアな設計ルール」

この問題を回避するために、最新のWeb3開発現場では以下の実装パターンを徹底しています。

1. プロキシパターンでは「Unstructured Storage」を使う

変数の順序や型に依存せず、特定のストレージスロットを固定して変数を管理する手法です。OpenZeppelinの ERC1967 標準を必ず利用してください。

// OpenZeppelinの流儀を取り入れた固定スロットの定義
// 変数の位置に依存しないため、衝突リスクを排除できる
abstract contract StorageSlot {
    // 衝突しにくいハッシュ値をスロットに指定(ERC1967のルール)
    bytes32 internal constant _OWNER_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;

    function _getOwner() internal view returns (address) {
        address owner;
        assembly {
            owner := sload(_OWNER_SLOT)
        }
        return owner;
    }
}

2. ロジックコントラクトには「初期化(Initializer)」を導入

コントラクトのコンストラクタは、プロキシ経由では実行されません。その代わり、initialize 関数を一度だけ実行するガードを設けるのが鉄則です。

// JavaScript(Ethers.js)での安全なデプロイ例
const Proxy = await ethers.getContractFactory("MyProxy");
const proxy = await Proxy.deploy();

// 初期化関数を呼び出して権限を確定させる
const tx = await proxy.initialize(adminAddress);
await tx.wait();
console.log("初期化完了:権限の乗っ取りを防ぎました");

—

セキュリティチーフからの「泥臭い」アドバイス

技術書には載っていない、現場の知見を二つ共有します。

  • ライブラリの依存関係を厳格に管理せよ

delegatecall を使う際は、信頼できない外部コントラクトを絶対に経由させないこと。ライブラリをアップグレードする際は、ストレージレイアウトをツール(slither 等)で解析し、破壊的変更がないか自動検知するCI/CDパイプラインを組むのが「プロ」の仕事です。

  • 「とりあえずdelegatecall」を禁止せよ

複雑なロジックを共通化したいとき、本当に delegatecall が必要ですか? staticcall で十分なケースや、そもそもコントラクトを分割する必要がないケースが大半です。複雑性は脆弱性の母であることを忘れないでください。

最後に:インシデントは防げる

ストレージ衝突は、ハッキングの中でも最も「防ぎやすい」部類に入ります。なぜなら、設計段階でコードの静的解析を行えば、衝突箇所はツールが瞬時に指摘してくれるからです。

今日からあなたのプロジェクトでも、slither を導入し、delegatecall を使用している箇所にフラグを立ててください。それだけで、数千万円単位の資産を守ることにつながります。

セキュリティとは、派手なハッキング手法を見つけることではなく、退屈なルールを泥臭く守り続けること。これに尽きるのです。

コメント

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