こんにちは。SCADAやIoTといった「物理的なモノを動かすシステム」から、Web3という「デジタルな価値を動かすシステム」まで、一見遠いようで実は「一度動かしたら止められない」という共通の恐怖を持つ領域で、日々セキュリティの急所を突いているリサーチャーです。
今日は、Web3の世界、特にスマートコントラクトの開発において避けては通れない「アップグレード(契約の更新)」のお話。そして、その裏に潜む「ストレージ衝突(Storage Collision)」という、まるで「他人の家のタンスに自分の荷物を突っ込んでしまう」ような、奇妙で恐ろしいバグについて解説します。
「ブロックチェーンは書き換えられないはずなのに、どうやってアップデートするの?」
「プロキシって何? 美味しいの?」
そんな疑問を抱えている新人のIT担当者の方や、これからセキュリティを学びたい開発者の皆さん。難しい専門用語を、私たちの「身の回りの防犯」に例えながら、一歩ずつ紐解いていきましょう!
—
1. 「一度建てたら壊せない家」をリフォームする方法
通常、スマートコントラクトは一度ブロックチェーンにデプロイ(配置)すると、二度と中身を書き換えることができません。これはセキュリティ上の大きなメリットですが、バグが見つかった時には致命的です。
そこで考え出されたのが、「プロキシコントラクト」という仕組みです。
これを家に例えるとこうなります。
- プロキシ(代理人): 「玄関のドア」です。住所(アドレス)は変わりません。
- ロジック(中身): 「家の中の設備」です。
住人(ユーザー)は常に同じ「玄関のドア(プロキシ)」から入ります。しかし、中の設備(ロジック)が古くなったら、プロキシが指し示す先を「新しい家(新しいコントラクト)」に切り替えるのです。これで、ユーザーから見れば「住所はそのままに、機能だけが最新になった」ように見えます。
2. ストレージ衝突:タンスの共有から生まれる悲劇
ここで一つ、大きな問題が発生します。それが「ストレージ衝突」です。
スマートコントラクトには、データを保存するための「引き出し(ストレージスロット)」が、0番、1番、2番……と順番に並んでいます。
プロキシの仕組みでは、「データ(お金の残高など)はプロキシの引き出しに保存し、計算ルールだけをロジック(家の中)に借りに行く」という特殊な動きをします。
泥棒もびっくり?引き出しの取り合い
想像してみてください。
1. プロキシ君は、自分の「0番の引き出し」に、リフォーム先の住所(ロジックのアドレス)をメモして入れています。
2. しかし、新しくやってきたロジック君も、「0番の引き出し」が空いていると思って、そこに「自分のオーナーの名前」を書き込もうとします。
ガシャン!
ロジック君が書き込んだせいで、プロキシ君が大切にしていた「リフォーム先の住所」が上書きされて消えてしまいました。これが「ストレージ衝突」です。
行き先を失ったプロキシ君は、二度と正しい家(ロジック)に辿り着けなくなり、システムは完全に壊れてしまいます。攻撃者はこれを利用して、管理者の権限を奪い取ったり、コントラクトを操作不能にしたりするのです。
3. 「初期化関数」という名の開けっ放しの鍵
もう一つの落とし穴が、「初期化関数の保護」です。
通常のコントラクトには constructor(コンストラクタ)という、家を建てた瞬間に一度だけ実行される仕組みがあります。しかし、プロキシを使う場合、このコンストラクタが使えません。代わりに initialize(イニシャライズ)という名前の関数を自分で作って、後から呼び出す必要があります。
これが泥棒にとっての絶好のチャンスです。
- 落とし穴: 「家を建てたあと、鍵をかける(初期化する)までの間に、誰かが勝手に入って『俺がこの家の主だ!』と宣言できてしまう」
これを防ぐには、「一度だけしか実行できない強力な鍵」をかける必要があります。
—
4. 実践:UUPSパターンと防御コードの書き方
現在、最も推奨されるパターンの一つが UUPS (Universal Upgradeable Proxy Standard) です。これは、アップグレードするための機能を、プロキシ側ではなく「ロジック側」に持たせるスマートな手法です。
では、実際にどうやって「ストレージ衝突」と「初期化の隙」を防ぐのか、コード例を見てみましょう。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// OpenZeppelinという信頼できるライブラリを使います
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
/**
* @title 安全なアップグレード可能コントラクトの例
*
* Initializable: 初期化関数を一度きりに制限する
* UUPSUpgradeable: UUPSパターンの骨組み
*/
contract MySmartContract is Initializable, OwnableUpgradeable, UUPSUpgradeable {
// --- 変数の定義 ---
// 注意:アップグレード時に変数の順番を絶対に変えてはいけません。
// 変えてしまうと、引き出し(スロット)の中身が混ざってしまいます!
uint256 public myData;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
// ロジックコントラクト自体が勝手に初期化されないよう、
// デプロイ時に初期化を無効化(ロック)しておきます。
// これが「玄関の鍵の閉め忘れ」を防ぐ第一歩です。
_disableInitializers();
}
/**
* @dev 初期化関数。constructorの代わりです。
* initializer モディファイアがあるおかげで、世界で一度しか実行できません。
*/
function initialize(uint256 _initialValue) public initializer {
// 内部でOwnable(権限管理)の初期化も忘れずに行います
__Ownable_init();
__UUPSUpgradeable_init();
myData = _initialValue;
}
/**
* @dev アップグレードを許可する人を制限する関数。
* これを書かないと、誰でも勝手にロジックを差し替えられてしまいます。
*/
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {
// onlyOwnerがついているので、管理者以外はアップグレードの指示を出せません。
}
// --- 追加の変数を将来入れるための「隙間」 ---
// ストレージ衝突を防ぐために、あえて空の引き出しを予約しておくテクニックです。
uint256[50] private __gap;
}
コードのポイント解説
1. _disableInitializers(): これをコンストラクタに書くことで、ロジックコントラクトそのものが悪用されるのを防ぎます。「誰も住まない展示用のモデルハウスに、勝手に鍵をかけられないようにする」イメージです。
2. initializer: この魔法の言葉(モディファイア)をつけるだけで、OpenZeppelinのライブラリが「この関数は一度呼ばれたら二度と動かさない」と見張ってくれます。
3. __gap: これは「将来の予備の引き出し」です。後から機能を追加したくなったとき、既存のデータが入っている引き出しを壊さないためのクッションになります。
—
5. まとめ:セキュリティは「想像力」から
スマートコントラクトのアップグレードは、便利さと引き換えに、物理的なシステム(OT/IoT)で言うところの「予期せぬバルブの誤作動」のようなリスクを孕んでいます。
- ストレージ衝突は、引き出しの奪い合い。
- 未初期化は、玄関の鍵の閉め忘れ。
これらは、教科書的な知識だけでは見落としがちです。攻撃者は常に「開発者が忘れているはずの0番目の引き出し」を狙っています。
「一歩ずつ対策を学んでいきましょう!」と言いましたが、まずは「OpenZeppelinのような、世界中のリサーチャーが揉みに揉んだ標準ライブラリを正しく使うこと」。これが、最大の防御になります。
皆さんのコントラクトが、泥棒につけ入る隙を与えない、頑丈な「デジタル資産の金庫」になることを願っています。また次回のディープな技術解説でお会いしましょう!
コメント