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

こんにちは!セキュリティの世界へようこそ。

Web3やスマートコントラクトの世界に足を踏み入れると、必ず耳にするのが「一度ブロックチェーンに書き込んだプログラムは誰にも書き換えられない(イミュータブル)」という特徴ですよね。改ざんができないからこそ信頼できる、というのがブロックチェーンの素晴らしいところです。

しかし、開発現場の目線に立つと、これは恐ろしいことでもあります。「もしプログラムに致命的なバグが見つかったらどうするの?」「新機能を追加したいときは?」──そう、バグすらも修正できなくなってしまうのです。

そこで賢い先人たちが考え出したのが、プログラムの本体を後から差し替え可能にする「アップグレーダブル(Proxy)パターン」です。そして、その心臓部として使われるのがdelegatecall(デリゲートコール)という特殊な命令です。

ですが、この便利な仕組み、使い方を一歩間違えると「泥棒に家の合鍵を渡してしまう」ような大惨事を引き起こします。今回は、新人の開発者やセキュリティ担当者のみなさんに向けて、delegatecallの落とし穴である「ストレージ衝突」の怖さと、それを防ぐ標準規格「EIP-1967」の仕組みを、防犯の例えを交えながら優しく紐解いていきます!

—

1. delegatecallってどんな仕組み? 〜出張シェフの例え〜

スマートコントラクトの通信には通常のcallとdelegatecallの2種類があります。この違いを、「出前を取る」か「出張シェフを家に呼ぶ」かでイメージしてみましょう。

  • 通常の call(出前を取る):

レストランに注文して、料理を作ってもらいます。レストラン側の食材(データ)を使って調理され、出来上がった結果だけがあなたの家に届きます。レストランの人があなたの家の冷蔵庫を開けることは絶対にありませんよね。

  • delegatecall(出張シェフを呼ぶ):

シェフ(ロジック側コントラクト)をあなたの家(プロキシ側コントラクト)に呼びます。シェフは調理の手順(コード)だけを持参し、調理はすべて「あなたの家のキッチンと冷蔵庫(ストレージ)」を使って行います。

【delegatecallのイメージ】
ユーザー ──> [ プロキシ(あなたの家:データ保管庫) ]
                     │  ↑
   コードの手順だけを借りる │  │ データは自分の家の中で書き換える!
                     ↓  │
             [ ロジック(出張シェフ:処理手順のみ) ]

この出張シェフ方式(delegatecall)を使うと、後から「もっと腕の良いシェフ」に変更するだけで、家の冷蔵庫(ユーザーの残高データなど)をそのまま残したまま、システムを最新版へアップデートできるようになります。これがプロキシパターンの基本です。

しかし、ここに重大なセキュリティリスクが潜んでいます。

もし呼んだシェフが悪者だったり、うっかり屋だったりして、「キッチンの引き出し(食材入れ)」と「リビングの金庫(家の権利書)」を勘違いしてしまったらどうなるでしょうか?

—

2. 大惨事の元凶「ストレージ衝突(Storage Collision)」とは?

スマートコントラクト(Ethereum)の世界では、変数は「スロット(Slot)」と呼ばれる0番から順に並んだ引き出しに順番に保管されます。

  • 1番目に宣言した変数:Slot 0
  • 2番目に宣言した変数:Slot 1
  • 3番目に宣言した変数:Slot 2 …

ここが落とし穴です。delegatecall先(ロジック)は、プロキシ側の「変数名」を見て書き込むのではなく、「何番目の引き出し(Slot番号)か」だけを見てデータを書き込みます。

身近な防犯に例えてみましょう。

  • あなたの家(プロキシ):

玄関の引き出し(Slot 0)に「家の合鍵(オーナーのアドレス)」を入れている。

  • シェフ(ロジック):

自分のレシピメモには「引き出し(Slot 0)には今日のメニューの暗証番号(自由に変更できる数字)を入れる」と書いてある。

シェフが「よし、今日の暗証番号を書き込んでおこう!」とSlot 0を書き換えた瞬間……なんと、あなたの家の合鍵(オーナーのアドレス)がシェフの指定した値に上書きされてしまうのです!

これが「ストレージ衝突(Storage Collision)」と呼ばれる現象です。攻撃者にこの仕組みを突かれると、一瞬でコントラクトの管理者権限を奪われてしまいます。

—

3. コードで見る脆弱性の実態

実際にどのようなコードで事故が起きるのか、シンプルな例を見てみましょう。

危険なプロキシコントラクト(あなたの家)

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

contract VulnerableProxy {
    // 【Slot 0】プロキシ自体の管理者を保管している大事な引き出し!
    address public proxyOwner; 
    // 【Slot 1】ロジック(シェフ)の呼び出し先アドレス
    address public implementation;

    constructor(address _implementation) {
        proxyOwner = msg.sender;
        implementation = _implementation;
    }

    // アップグレード用関数
    function upgradeTo(address _newImplementation) external {
        require(msg.sender == proxyOwner, "Not the owner!");
        implementation = _newImplementation;
    }

    // ユーザーからの呼び出しをすべてロジックに丸投げ(delegatecall)する
    fallback() external payable {
        address impl = implementation;
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }
}

後から呼ばれるロジックコントラクト(出張シェフ)

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

contract BadLogic {
    // 【Slot 0】ロジック側では、単なるポイント計算用の数字だと思っている!
    uint256 public userPoints;

    // 誰でも呼べるポイント初期化関数
    function setPoints(uint256 _points) external {
        // ここで Slot 0 を書き換えてしまう!
        userPoints = _points; 
    }
}

何が起きるのか?

攻撃者がプロキシ経由で setPoints(自分のウォレットアドレスを数値化したもの) を呼び出します。
すると、ロジック側は「Slot 0の userPoints を書き換えた」つもりですが、実際に書き換わったのはプロキシ側の「Slot 0にある proxyOwner(オーナーの鍵)」です。

結果として、攻撃者が新しいプロキシオーナーになりすまし、コントラクトを自由に乗っ取れてしまうのです。

—

4. 防衛策:EIP-1967規格で「秘密の隠し金庫」を作る!

「引き出しの0番や1番を使うから被るなら、絶対に誰も使わないような場所に隠せばいいのでは?」

まさにその発想で生まれた標準規格が 「EIP-1967」 です。

EIP-1967では、プロキシ自身が使う重要データ(ロジックのアドレスや管理者アドレス)を、0番や1番のような手前のスロットではなく、特定の文字列をハッシュ化(Keccak-256)して得られる、事実上無限に近い彼方のスロット番号に直接配置します。

例えば、ロジックのアドレスを保存するスロットは以下のように定義されています。

> bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
> (値: 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc)

こんな天文学的な番号の引き出し、通常のコントラクトが変数宣言で偶然使う確率はゼロですよね!これでストレージの衝突を100%回避できます。

EIP-1967に準拠した安全なプロキシの実装例

実務ではOpenZeppelinのライブラリ(ERC1967Proxyなど)を使うのが鉄則ですが、中身でどのような防犯対策が行われているのかを理解するために、ミニマムな安全コードを見てみましょう。

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

contract SecureEIP1967Proxy {
    // EIP-1967で規格化された「秘密の引き出し」の場所(固定ハッシュ値)
    bytes32 private constant IMPLEMENTATION_SLOT =
        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    bytes32 private constant ADMIN_SLOT =
        0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;

    constructor(address _logic, address _admin) {
        // 通常の変数代入ではなく、指定のスロットへ直接書き込む(sstore)
        _setSlotValue(ADMIN_SLOT, bytes32(uint256(uint160(_admin))));
        _setSlotValue(IMPLEMENTATION_SLOT, bytes32(uint256(uint160(_logic))));
    }

    // 内部関数:指定したスロットにデータを安全に書き込む
    function _setSlotValue(bytes32 slot, bytes32 value) internal {
        assembly {
            sstore(slot, value)
        }
    }

    // 内部関数:指定したスロットからデータを安全に読み出す
    function _getSlotValue(bytes32 slot) internal view returns (bytes32 value) {
        assembly {
            value := sload(slot)
        }
    }

    // 安全にロジックコントラクトのアドレスを取得
    function _implementation() internal view returns (address) {
        return address(uint160(uint256(_getSlotValue(IMPLEMENTATION_SLOT))));
    }

    // フォールバック関数:秘密の引き出しから安全にアドレスを取り出してdelegatecall
    fallback() external payable {
        address impl = _implementation();
        require(impl != address(0), "Implementation not set");

        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }
}

このように、プロキシ側が通常の変数宣言(Slot 0, 1…)を一切行わず、EIP-1967で定義された専用のスロットに管理者やロジックの情報を隔離して保管することで、ロジック側がどんな変数の定義をしていようとも、プロキシ側の管理領域を誤って破壊される心配がなくなります。

—

5. まとめ:安全なスマートコントラクト運用のために

最後に、今回の重要なポイントをおさらいしましょう!

  • delegatecallは「出張シェフ」: 相手のコードを借りて、自分の家の引き出し(ストレージ)を操作する強力かつ危険な命令。
  • ストレージ衝突は「引き出しの取り違え」: プロキシとロジックでスロット番号(変数の並び順)が被ると、大事な鍵が上書きされてしまう。
  • EIP-1967は「秘密の隠し金庫」: 衝突しようがない超巨大なハッシュ値のスロットに変数を格納することで、データ破壊を根本から防ぐ。

スマートコントラクトの開発では、「動くものを作る」ことと同じくらい「攻撃者がどのようにデータを破壊できるか」という防犯の視点を持つことが大切です。

プロキシコントラクトを自作するのはセキュリティリスクが高いため、実務では実績十分なOpenZeppelin Contracts(ERC1967Proxy や UUPSUpgradeable)をそのまま採用するのが一番の防犯対策になります。「巨人の肩に乗る」ことも、セキュリティでは非常に賢い選択ですよ。

一歩ずつ、安全なコントラクト設計の知識を身につけていきましょう!

コメント

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