こんにちは!ブロックチェーンの世界へようこそ。セキュリティリサーチャーの私です。
新しいスマートコントラクトを開発し、いざイーサリアムなどのブロックチェーン上にデプロイ(公開)したあとで、「あ、ここバグがあった!」「もっと機能を追加したい!」と気づいた経験はありませんか?
通常のブロックチェーンの世界では、一度デプロイしたコントラクトは書き換えができない(イミュータブル)のが鉄則です。しかし、「どうしても後から改修したい!」という開発現場の切実な願いから生まれたのが、今回解説する「Proxy(プロキシ)パターン」によるアップグレード機能です。
今回は、このプロキシパターンに潜む恐ろしい罠――「ストレージ衝突(Storage Collision)」と「初期化関数の乗っ取り」について、身近な例えを交えながら優しく紐解いていきましょう。一歩ずつ、安全なコントラクトの作り方を学んでいきましょうね!
—
1. 家の鍵で例える「プロキシパターン」の仕組み
まず、プロキシパターンがどのような仕組みなのか、私たちの身近な「家と鍵」に例えてみましょう。
あなたが新しく家を建てたとします。この家(ブロックチェーン上のデータやアドレス)の住所は絶対に引っ越したくありません。郵便物もずっと同じ場所に届いてほしいですし、ご近所さんからの信用もあります。
しかし、築10年が経ち、キッチンを最新のものにリフォームしたくなりました。
普通の家なら、家を一度取り壊して建て直す必要があります。これだと住所が変わってしまいますよね。
そこで登場するのが「プロキシ(代理人)パターン」です。
- プロキシ(代理人・玄関): ユーザーが訪れる「変わらない住所(窓口)」です。
- ロジック(実装コントラクト・実際の部屋): 中身の家具やキッチン(機能)が置いてある場所です。
ユーザーは常に「プロキシ(玄関)」のドアをノックします。プロキシは、裏で本当の仕事をしてくれる「ロジック(実際の部屋)」にこっそり処理を丸投げ(転送)します。
キッチンのリフォーム(機能のアップデート)をしたくなったら、住所(プロキシ)はそのままで、裏にある「ロジック(実際の部屋)」をごっそり新しい家に取り替えればいい、というわけです。とても便利ですよね!
—
2. 攻撃者が狙う盲点:ストレージ衝突(Storage Collision)
便利なプロキシパターンですが、ここに大きな落とし穴があります。それが「ストレージ衝突(Storage Collision)」です。
なぜ衝突が起きるのか?
イーサリアムのスマートコントラクトは、変数を保存する場所(ストレージ)が「名前」ではなく「番号の引き出し(スロット0、スロット1…)」で管理されています。
ここで、プロキシ(玄関)側と、ロジック(実際の部屋)側で、引き出しの使い方がズレてしまったらどうなるでしょうか?
- プロキシ側の引き出し0: 管理人の名前(例: アリス)
- 新しいロジック側の引き出し0: 金庫の暗証番号(例: 1234)
もしアップデートした新しいロジックが、うっかり引き出しの順番を間違えて設計されていると、プロキシ側が「管理人の名前」として保存していた大切なデータが、新しいロジック側では「金庫の暗証番号」として勝手に読み書きされてしまいます。
これがストレージ衝突です。攻撃者はこのズレを利用して、「管理人の名前(管理者権限)」が保存されている引き出しを書き換え、コントラクト全体を乗っ取ってしまうのです。怖いですよね。
—
3. もう一つの脅威:初期化関数の乗っ取り(Initializer)
プロキシパターンでは、通常のコントラクトで使われるコンストラクタ(constructor:家を建てたときに一度だけ実行される初期設定)がうまく動きません。そのため、代わりに initialize のような「初期化関数」を後から手動で呼び出す仕組みをとることがよくあります。
ここで、もしこの初期化関数に「誰でも何度でも呼び出せる」というガードの甘さがあったらどうなるでしょうか?
泥棒があなたの新しい家にやってきて、あなたが鍵(初期化)をかけるよりも先に、自分が「新しい家のオーナーだ!」と勝手に名乗り出て鍵をかけ替えてしまう――これが初期化関数の乗っ取りです。一瞬でコントラクトの所有権が奪われ、中に入っている資金を持ち逃げされてしまいます。
—
4. 実践!安全なUUPSプロキシと防御の実装コード
「うわ、怖そうだな…自分に扱えるかな…」と思いましたか? 大丈夫です!セキュリティの正しい知識と、実績のあるライブラリ(OpenZeppelinなど)を使えば、安全に実装できます。
ここでは、現在主流となっているUUPS(Universal Upgradeable Proxy Standard)パターンの安全な実装サンプルを見てみましょう。日本語のコメントを丁寧に書いたので、ポイントを確認していきましょう。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// OpenZeppelinの安全なアップグレード用コントラクトをインポートします
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
// Initializable: 初期化関数を「一度だけ」実行できるようにする
// UUPSUpgradeable: 安全なアップグレード機能を持たせる
// OwnableUpgradeable: 所有者(管理者)だけが操作できるようにする
contract MySecureToken is Initializable, UUPSUpgradeable, OwnableUpgradeable {
// 資産の残高などを保存する変数(ストレージの順番に注意!)
uint256 public myImportantData;
/// @notice コンストラクタの代わりに初期化関数を使います
/// @dev この関数はデプロイ時にプロキシ経由で1度だけ呼ばれます
function initialize(address initialOwner) initializer public {
__Ownable_init(initialOwner); // 所有者の初期化を確実に実行
__UUPSUpgradeable_init(); // UUPSの初期化
myImportantData = 42; // 大切なデータの初期値
}
/// @notice データを更新する一般的な関数
function setData(uint256 newData) public onlyOwner {
myImportantData = newData;
}
/// @dev 【超重要】誰でも勝手にアップデートできないよう、管理者だけが呼べる制限(onlyOwner)をかけます
function _authorizeUpgrade(address newImplementation)
internal
override
onlyOwner
{
// ここにアップグレード前の追加チェック(監査ログやマルチシグなど)を入れることも可能です
}
}
コードの安全ポイント
1. initializer 修飾子の活用: initialize 関数には必ず initializer をつけ、二重初期化を防ぎます。
2. _authorizeUpgrade の保護: アップグレードを実行する裏側の関数に onlyOwner をつけ、勝手にロジックをすり替えられないように厳重に鍵をかけています。
—
まとめ:安全なスマートコントラクト開発に向けて
今回は、プロキシパターンの裏側に潜む「ストレージ衝突」と「初期化関数の乗っ取り」について解説しました。
- プロキシパターンは、住所を変えずに中身をリフォームできる便利な仕組み。
- しかし、変数の順番(ストレージ)を間違えると、データがごちゃ混ぜになり乗っ取りの隙を生む。
- 初期化関数(
initialize)には必ず一度きりのガードをかけ、アップグレード権限は厳重に管理する。
「難しそう…」と感じたかもしれませんが、基礎をしっかり理解し、コミュニティで検証された安全なライブラリ(OpenZeppelin等)をベースに丁寧な実装を心がければ、怖がる必要はありません。
一歩ずつ、確実にセキュアな開発スキルを身につけていきましょう!次回の記事もお楽しみに!
コメント