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

こんにちは!IoTデバイスやブロックチェーンのセキュリティの世界へようこそ。
日々、スマートコントラクトの開発やインフラの保守に奮闘されている新人の皆さん、本当にお疲れ様です。「Web3の世界はなんだか専門用語が多くて敷居が高いな…」と感じていませんか?

大丈夫です、一歩ずつ一緒に紐解いていけば必ず理解できますよ。今回は、スマートコントラクトのセキュリティにおいて最も恐ろしい脆弱性の一つである、delegatecall(デリゲートコール)の危険性とコンテキストの分離について、身近な例えを交えながら優しく解説していきますね。

—

1. 家の鍵と「合鍵マジック」で例える delegatecall の世界

まず、ブロックチェーン(Ethereumなど)の世界におけるコントラクトの仕組みを、私たちの生活に置き換えて考えてみましょう。

通常、あなたが自分の家にいるとき、掃除は「あなた自身のルール」や「あなたの道具」を使って行いますよね。スマートコントラクトでも同じで、コントラクトA(あなた)がコントラクトB(お手伝いさん)のプログラムを呼び出すときは、通常、Bの持ち物(ストレージ)やBの権限で処理が行われます。これを通常の call と呼びます。

しかし、delegatecall は一味違います。これは例えるなら、「お手伝いさんに、あなたの『財布』と『家の権利書』が丸ごと入った金庫の暗証番号を教え、あなたの名前と財布を使って、あなたの家の中で自由に買い物や模様替えをしてもらう」ような状態です。

プログラムの「中身(ロジック)」だけを外部から借りてくるのに、動かしている「お財布や部屋(ストレージコンテキスト)」は、すべて呼び出し元(あなた)のものが使われてしまう――これが delegatecall の正体です。超便利である反面、使い方を少しでも誤ると、家中がめちゃくちゃにされてしまう危険な仕組みなんです。

—

2. なぜ危ないの?「ストレージレイアウトの不一致」という罠

攻撃者がこの delegatecall のどこを狙うかと言うと、「ストレージレイアウトの不一致(データのズレ)」です。

スマートコントラクトのデータ保管庫(ストレージ)は、エクセルや棚の「1番目の引き出し」「2番目の引き出し」のように、変数を宣言した順番でスロット(番号)が割り振られます。ここで恐ろしいミスが起こります。

例えば、呼び出し元(あなた)の金庫の引き出しの順番がこうだったとします。

  • 0番目の引き出し:所有者(Owner)の名前
  • 1番目の引き出し:残高(Balance)

ところが、借りてきた外部のプログラム(お手伝いさん)が、実は違う設計図を持っていて、こう勘違いしていたらどうでしょう?

  • 0番目の引き出し:残高(Balance)
  • 1番目の引き出し:所有者(Owner)の名前

この状態でプログラムを実行すると、お手伝いさんは「残高を書き換えよう」としたつもりが、あなたの家の「所有者の名前(Owner)」の引き出しを書き換えてしまうことになります。結果として、攻撃者にコントラクトの全権限(オーナー権)をいとも簡単に奪われてしまうのです。これが、実務の現場で数億円規模のハッキングを引き起こしてきた悪名高い脆弱性のメカニズムです。

—

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

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

危険なコード例(脆弱性あり)

以下の例では、ライブラリ側の変数の順番が、メインのコントラクトとズレてしまっています。

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

// 【危険なライブラリ側】変数の順番が間違っている
// スロット0: uint256 number (数値を先に宣言してしまっている!)
library DangerousLibrary {
    uint256 public number;

    function setNumber(uint256 _num) public {
        number = _num;
    }
}

// 【メインのコントラクト】
contract VulnerableVault {
    // スロット0: address public owner (オーナー情報を最初に保持している)
    address public owner;
    uint256 public number;

    constructor() {
        owner = msg.sender; // デプロイした人をオーナーに設定
    }

    // 外部のライブラリを delegatecall で実行してしまう危険な関数
    function updateThroughDelegate(address _lib, uint256 _num) public {
        // 危険!delegatecallを使うことで、ライブラリのコードが
        // 「VulnerableVaultのストレージ(スロット)」を直接書き換えます。
        (bool success, ) = _lib.delegatecall(
            abi.encodeWithSignature("setNumber(uint256)", _num)
        );
        require(success, "Delegatecall failed");
    }
}

このコードを実行すると、DangerousLibrary はスロット0の number を書き換えるつもりで delegatecall を呼びますが、呼び出し元である VulnerableVault のスロット0には owner(アドレス) が入っています。その結果、数値(_num)がそのままアドレスに変換され、コントラクトのオーナー権限が勝手に書き換わってしまうという大事故が起きるのです。

—

安全な対策コード(プロキシパターンの正しい実装)

「じゃあ delegatecall なんて使わないほうがいいの?」と思われるかもしれませんが、アップグレード可能なスマートコントラクト(プロキシパターン)などでは、delegatecall はなくてはならない技術です。

安全に使うための鉄則は、「呼び出し元と呼び出し先のストレージレイアウト(変数の型と宣言順序)を完全に一致させること」、そして「信頼できるコントラクトアドレス以外とは絶対に接続しないこと」です。

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

// 【安全なライブラリ側】
// 呼び出し元のコントラクトと「まったく同じ順番・同じ型の変数」を定義します。
library SafeLibrary {
    // スロット0: オーナー情報(順番を完全に一致させる!)
    address public owner;
    // スロット1: 数値データ
    uint256 public number;

    function setNumber(uint256 _num) public {
        // 安全にスロット1の値を更新
        number = _num;
    }
}

// 【安全なメインのコントラクト】
contract SecureVault {
    // スロット0
    address public owner;
    // スロット1
    uint256 public number;

    constructor() {
        owner = msg.sender;
    }

    // 安全なdelegatecall実行関数
    function updateSafely(address _lib, uint256 _num) public {
        // 実務では、ここで「_lib が本当に信頼できる公式のアドレスか?」を
        // 厳格にチェック(アクセス制御やホワイトリストの確認)するロジックを入れます。
        require(msg.sender == owner, "Only owner can call this");

        // ストレージのレイアウトが完全に一致しているため、安全に処理が行われます
        (bool success, ) = _lib.delegatecall(
            abi.encodeWithSignature("setNumber(uint256)", _num)
        );
        require(success, "Delegatecall failed");
    }
}

このように、変数の順番をガチガチに合わせ、さらに「誰がその関数を呼び出せるか(アクセス制御)」を厳しく管理することで、悪意あるデータ破壊を防ぐことができます。

—

4. 実務で役立つ!今日からできるチェックリスト

最後に、実際の開発やセキュリティレビューの現場で役立つ、具体的な対策のポイントをまとめておきます。

1. 安易な delegatecall を避ける

  • 通常の開発では delegatecall を使う必要性はほとんどありません。使うのは主に「アップグレード可能なプロキシコントラクト」を自作・保守する時だけです。特別な理由がない限り、通常の call を選択しましょう。

2. ストレージの構造をチームで厳重に管理する

  • プロキシパターンを採用する場合、親コントラクトと子(実装)コントラクトの間で、変数の追加や削除を行うとレイアウトが狂います。OpenZeppelinなどが提供するアップグレードプラグイン(Initializable やストレージギャップの活用など)を必ず導入し、手動でのレイアウトミスを防ぎましょう。

3. アドレスのハードコーディングや外部入力を検証する

  • ユーザーから動的に渡されたアドレス(address _lib など)に対してそのまま delegatecall を実行するのは、「赤の他人に自宅の合鍵を渡す」のと同じ自殺行為です。呼び出し先は厳格なオーナー権限やガバナンスによって管理されたアドレスに限定してください。

セキュリティの世界は一見難しく見えますが、基本のルールと仕組みさえ分かってしまえば、しっかりとした要塞を築くことができます。一歩ずつ、安全で堅牢なコードを書けるエンジニアを目指して一緒に頑張っていきましょう!

コメント

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