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

スマートコントラクトの世界へようこそ!ブロックチェーン上で動くプログラムは、一度デプロイしてしまうと簡単に修正できない「不変性」を持っています。そのため、最近のスマートコントラクト開発では、プログラムの本体(ロジック)とデータ(ストレージ)を切り離し、後から機能をアップデートできるようにする「プロキシパターン(代理人パターン)」がよく使われます。

このアップデート機能の裏側でこっそり使われているのが、今回取り上げる delegatecall(デリゲートコール)という少し曲者な機能です。

今回は、新人の開発者さんやセキュリティに初めて触れる方に向けて、この delegatecall が抱える恐ろしい「ストレージ衝突(ストレージ・コリジョン)」の罠と、その回避策について、身近な例えを交えながら一歩ずつ優しく紐解いていきましょう!

—

1. 家の鍵と合鍵で例える「delegatecall」の仕組み

まず、delegatecall が一体どんなものなのか、身近な「家の鍵と防犯」に例えて考えてみましょう。

あなたが一軒家を建てたとします。家の中には金庫があり、そこにはあなたの大切な財産や通帳(これがブロックチェーン上の「ストレージ(データ)」です)が保管されています。

ある日、あなたは家をちょっとリフォームしたい、あるいは最新のスマート家電(これが「ロジック(プログラム)」です)を導入したいと考えました。しかし、専門的な工事は自分ではできません。そこで、リフォーム業者(別のコントラクト)を呼んでこう頼みます。

> 「私の家の合鍵(delegatecall)をあげるので、私の家(あなたのコントラクトの文脈)に入って、リフォームの作業をしてきてください!」

ここで重要なポイントがあります。業者(ロジック)は自分の道具を持ってあなたの家に入りますが、作業を行う場所はあくまで「あなたの家の中」です。つまり、業者が持ってきたプログラムが動くとき、操作される金庫の位置や家具の配置は、すべてあなた(代理人)の家のルールに従うことになるのです。

これが delegatecall の本質です。「呼び出し元のコントラクトのストレージ(データ保存場所)を使って、呼び出し先のコントラクトのコードを実行する」という仕組みになります。

—

2. 泥棒が侵入する瞬間:ストレージ衝突(ストレージ・コリジョン)とは?

一見すると、機能のアップデートができて非常に便利な delegatecall ですが、ここに大きな罠(脆弱性)が潜んでいます。それが 「ストレージ衝突(Storage Collision)」 です。

先ほどの家の例えに戻りましょう。

あなたとリフォーム業者は、事前に「家具の配置図(ストレージレイアウト)」をしっかり共有しておく必要があります。

  • あなたの家:1番目の部屋に「タンス(所有者アドレス)」、2番目の部屋に「金庫の暗証番号」を置いていたとします。
  • 業者のプログラム:何も考えずに、「1番目の部屋には『テレビ(一時的な変数)』を置く場所だ」と思い込んで作業を始めました。

もし、この状態で業者のプログラムが「1番目の部屋のものを書き換えろ!」という命令を実行するとどうなるでしょうか? 業者はテレビのつもりで作業していますが、あなたにとっては大切な「所有者のアドレス」が勝手に書き換えられてしまうことになります!

これがストレージ衝突です。delegatecall は、変数の「名前」ではなく、メモリ上の「何番目の部屋(スロット)か」という物理的な位置だけでデータを読み書きします。そのため、呼び出し元と呼び出し先のデータの並び順(レイアウト)がズレていると、大切なデータが上書きされ、最悪の場合、ハッカーにコントラクトの所有権(オーナー権)を乗っ取られてしまうのです。

—

3. コードで見てみよう:危ない実装と安全な実装

百聞は一見に如かず。実際にSolidityのコードを使って、何が危険でどう直すべきなのかを見ていきましょう。

危険なパターン(ストレージの並び順がバラバラ)

以下の例では、プロキシ(代理人)と実装(ロジック)で変数の順番が異なっています。

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

// 【危険なロジックコントラクト】
contract BadLogic {
    // スロット0: ここに勝手にアドレスが入ってしまう!
    address public someRandomVariable; 
    address public owner;

    function setOwner(address _newOwner) public {
        owner = _newOwner;
    }
}

// 【危険なプロキシコントラクト】
contract BadProxy {
    // スロット0: オーダー権限をここに保持しているつもり
    address public owner; 
    address public logicContract;

    function updateLogic(address _newLogic) public {
        logicContract = _newLogic;
    }

    // delegatecallを使う関数
    function execute(address _newOwner) public {
        // BadLogicのsetOwnerを呼び出すが、ストレージの並び順が違うため大惨事になる!
        (bool success, ) = logicContract.delegatecall(
            abi.encodeWithSignature("setOwner(address)", _newOwner)
        );
        require(success, "Delegatecall failed");
    }
}

このコードでは、BadProxy のスロット0には owner がいますが、BadLogic のスロット0には someRandomVariable がいます。delegatecall を実行した瞬間、ハッカーに都合の良いように owner が書き換えられてしまう隙が生まれます。

—

安全な対策パターン(EIP-1967の活用とストレージの統一)

このリスクを防ぐためには、どうすればよいでしょうか?
現実のプロファイル開発現場では、主に以下の2つのアプローチを組み合わせて鉄壁の守りを作ります。

1. ストレージの並び順(レイアウト)を完全に一致させる
2. OpenZeppelinなどが提供する標準化されたプロキシパターン(EIP-1967など)をそのまま使う

特に、自前でプロキシを書くのはセキュリティリスクが非常に高いため、業界標準のライブラリを利用するのが鉄則です。

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

// OpenZeppelinの安全なプロキシコントラクトをベースにした概念的な実装例です
contract SafeProxy {
    // EIP-1967で定められた、衝突しにくい特定のスロット位置にアドレスを格納する
    // bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
    bytes32 private constant IMPLEMENTATION_SLOT = 
        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    // プロキシ自体のオーナー管理用スロットなども標準規格に則って配置する
    bytes32 private constant ADMIN_SLOT = 
        0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;

    constructor(address _logic) {
        _setImplementation(_logic);
    }

    function _setImplementation(address _newLogic) internal {
        assembly {
            sstore(IMPLEMENTATION_SLOT, _newLogic)
        }
    }

    // フォールバック関数を使って、受け取ったすべての呼び出しを安全に delegatecall する
    fallback() external payable {
        bytes32 slot = IMPLEMENTATION_SLOT;
        assembly {
            let impl := sload(slot)
            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()) }
        }
    }
}

このように、データを格納する「部屋の番地(ストレージスロット)」を通常の変数宣言ではなく、ハッシュ値から計算した特殊な固定スロットに割り当てることで、ロジック側でどんな変数を追加・変更しようとも、プロキシ側の重要なデータ(アドレスなど)が破壊されるのを防ぐことができます。

—

まとめ:一歩ずつ安全なスマートコントラクト開発へ

今回は、delegatecall が持つ危険な「ストレージ衝突」のメカニズムと、それを防ぐための考え方を解説しました。

  • delegatecall は「自分の家(ストレージ)」で「他人のプログラム(ロジック)」を動かす仕組み。
  • ストレージの並び順や配置場所がズレると、大切なデータが上書きされ、乗っ取りの危険が生じる。
  • 実務では絶対に自前の適当なプロキシを書かず、OpenZeppelinなどの実績ある標準ライブラリ(EIP-1967など)を必ず活用する。

スマートコントラクトの開発は、一度公開したら後戻りできないシビアな世界ですが、正しい防犯の知識(セキュリティパターン)を一つずつ身につけていけば、怖くありません。

今日からあなたのコードでも、変数の並び順やプロキシの設計に少しだけ意識を向けてみてくださいね。一歩ずつ、セキュアなエンジニアへの道を歩んでいきましょう!

コメント

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