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

こんにちは!ブロックチェーン開発やスマートコントラクトの世界へようこそ。セキュリティの世界に初めて足を踏み入れると、なんだか難しそうな専門用語ばかりで圧倒されてしまいますよね。でも、一歩ずつ身近な例えから紐解いていけば大丈夫です。一緒に楽しく安全なコードの書き方を学んでいきましょう!

今回は、スマートコントラクトの開発でよく使われる便利な機能でありながら、一歩間違えると大惨事を引き起こしてしまう「delegatecall(デリゲートコール)とストレージ衝突(ストレージ・コリジョン)」というテーマについて、分かりやすく解説していきますね。

—

1. 家の鍵に例える「delegatecall」の仕組み

まずは、スマートコントラクトにおける delegatecall がどんなものか、身近な「合鍵と家事代行」に例えて考えてみましょう。

あなたが大きな一軒家に住んでいるとします。あなた(コントラクトA)は、家の中の掃除や料理(特定の処理)を、専門の家事代行サービスマン(コントラクトB / ライブラリ)にお願いしたいと考えました。

普通のお願い(通常の関数呼び出し)であれば、代行サービスマンに「我が家のキッチンで料理を作ってください」と頼み、料理を作ってもらいます。この場合、冷蔵庫から食材を取り出すのは代行サービスマンですが、家そのものは代行サービスマンの持ち物になるわけではありません。

しかし、delegatecall はこれとは全く違います。
delegatecall とは、「我が家のキッチン(私のコントラクトのストレージ)をそのまま貸し出すので、あなた(代行サービスマン)の持っている最新のレシピ(コード)を使って、我が家の冷蔵庫の食材を勝手に調理して、我が家の食卓に並べてください!」という特殊なお願いの仕方です。

つまり、「動かすコード(レシピ)は借り物だけど、書き換えられるデータ(食材や家具)はすべて自分の家(自分のコントラクト)のもの」になるのが、delegatecall の最大の特徴なんです。

—

2. なぜ危ないの?「ストレージ衝突」という悲劇

この「家を丸ごと貸し出す」仕組み、コードを使い回す(アップグレードプロキシパターンなど)には非常に便利なのですが、ここに大きな落とし穴(脆弱性)があります。それが「ストレージ衝突(Storage Collision)」です。

先ほどの例えに戻りましょう。
あなたと家事代行サービスマンの間で、「物の置き場所(ストレージの順番)」について、こんなルールをあらかじめ決めておいたとします。

  • あなた(コントラクトA)の家:
  • 1番目の引き出し:お財布(uint256 balance)
  • 2番目の引き出し:家のオーナーの名前(address owner)
  • 代行サービスマン(コントラクトB)の頭の中の設計図:
  • 1番目の引き出し:家のオーナーの名前(address owner)
  • 2番目の引き出し:お財布(uint256 balance)

さあ、ここで大問題が発生します!
あなたが delegatecall で代行サービスマンに「1番目の引き出しの整理整頓をお願い!」と頼んだとします。代行サービスマンは自分の設計図通り、「1番目の引き出しにはオーナーの名前が入っているはずだ」と思い込んで作業をしてしまいます。

その結果どうなるでしょうか?
あなたの家で「1番目の引き出し」に入っていたのは、大切なお財布(balance)です。代行サービスマンがそこに「新しいオーナーの名前(自分のアドレス)」を上書きしてしまったら……なんと、あなたの家(コントラクト)のオーナー権限が、勝手に代行サービスマンに乗っ取られてしまうのです!

これが、ストレージのレイアウト(順番や型)がズレることで発生する「ストレージ衝突」の恐怖のメカニズムです。

—

3. 実際のコードで見てみよう(脆弱なパターンの例)

百聞は一見にしかず。実際にSolidityのコードを見て、この恐ろしい現象を確認してみましょう。以下のコードは、ストレージの順番を間違えて大惨事になる典型的な例です。

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

// 【攻撃者・悪意あるライブラリ側】
contract BadLibrary {
    // 順番が間違っている!
    address public maliciousOwner; // 1番目:ここに攻撃者のアドレスが入ってしまう
    uint256 public storedValue;  // 2番目
    
    function updateValue(uint256 _value) public {
        storedValue = _value;
    }
}

// 【被害を受けるメインのコントラクト】
contract VulnerableVault {
    // 正しい順番
    uint256 public storedValue;  // 1番目本来は数値が入る場所
    address public owner;        // 2番目本来はオーナーのアドレスが入る場所
    
    // ライブラリをdelegatecallで呼び出す関数
    function runDelegate(address _lib, uint256 _value) public {
        // delegatecallを実行すると、VulnerableVaultの「1番目のスロット」が書き換わる!
        (bool success, ) = _lib.delegatecall(
            abi.encodeWithSignature("updateValue(uint256)", _value)
        );
        require(success, "Delegatecall failed");
    }
}

このコードでは、VulnerableVault の1番目の変数は storedValue(数値)ですが、呼び出し先の BadLibrary の1番目の変数は maliciousOwner(アドレス)になっています。

runDelegate を実行した瞬間、VulnerableVault の1番目のスロット(本来なら storedValue がある場所)に、ライブラリ側の処理を通じて値が書き込まれてしまい、結果として owner の領域やストレージ全体がめちゃくちゃに破壊されてしまうのです。

—

4. 安全な開発のための対策と防衛策

「うわ、怖いな……じゃあどうやって自分のコントラクトを守ればいいの?」と思いましたよね。安心してください、実務の現場ではしっかりとした防衛策が確立されています。一歩ずつ対策を学んでいきましょう!

対策①:ストレージレイアウトを完全に一致させる

ライブラリやプロキシ先を呼び出す際は、親コントラクトと子(ライブラリ)コントラクトの間で、状態変数(ストレージ)の宣言順序とデータ型を完全に同一にすることが鉄則です。変数を途中で追加・削除するときも、順番がズレないように細心の注意を払いましょう。

対策②:EIP-1967などの標準的なプロキシパターンを使う

コントラクトのアップグレードを安全に行うために、OpenZeppelinなどの信頼性の高いライブラリが提供している標準的なプロキシパターン(ERC1967Proxy など)を使いましょう。これらはストレージの衝突が起きないように、特定のストレージスロット(特殊なハッシュ値から生成された場所)にデータを厳密に格納する工夫がされています。

対策③:可能な限り library キーワードを活用する

Solidityには、ステート(状態変数)を持たない関数群を安全にまとめるための library という専用のキーワードが用意されています。

// 安全なライブラリの定義例
library SafeMathLib {
    // ライブラリ自体は状態変数を持たないように設計する
    function safeAdd(uint256 a, uint256 b) internal pure returns (uint256) {
        return a + b;
    }
}

library として定義されたコントラクトは、それ自体がストレージを持つことができない仕組みになっているため、うっかりストレージを書き換えてしまうリスクを根本から減らすことができます。

—

おわりに

今回は、delegatecall の仕組みと、そこから生まれる「ストレージ衝突」の危険性について解説しました。

スマートコントラクトの開発は、一度デプロイしてしまうと簡単に修正できない「一発勝負の世界」です。だからこそ、仕組みの裏側にあるメモリやストレージの動きをイメージしながら、慎重にコードを組んでいくことが何よりも大切になります。

「一歩ずつ対策を学んでいきましょう!」という気持ちを忘れずに、安全で堅牢なWeb3アプリケーションを一緒に作っていきましょうね。次回の記事もお楽しみに!

コメント

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