制御不能な「メモリの書き換え」:delegatecallとストレージ衝突が引き起こす惨劇
現場で「なぜか残高が消えた」「管理者の権限が勝手に書き換わった」という相談を受けるとき、その裏側に潜んでいるのが delegatecall の悪用です。これは、SCADAのPLC(プログラマブルロジックコントローラ)のメモリ空間を直接書き換えるような、非常に危険かつ魅惑的な脆弱性です。
今日は、ブロックチェーン開発者がつい陥りがちな「ストレージ衝突」の罠について、実戦的な視点で深掘りします。
—
delegatecallとは何か?——「借り物のコード」の代償
delegatecall は、外部コントラクトのコードを「自分のコンテキスト」で実行する仕組みです。例えば、ライブラリのロジックを再利用したいときに便利ですが、ここには致命的な前提条件があります。
「呼び出し元と呼び出し先のストレージレイアウトは、完全に同一でなければならない」
もし、呼び出し先のコントラクトが、あなたのコントラクトの「想定外の場所」を書き換えたらどうなるか? 攻撃者はその隙を突き、コントラクトの所有者フラグや、送金先リストを自由自在に上書きします。
ストレージ衝突のPoC:何が起きているのか?
以下のコードを見てください。非常に単純ですが、これこそが「ハッキングの入り口」です。
// 脆弱なコントラクト(Proxy)
contract Proxy {
address public owner; // スロット0
address public lib; // スロット1
function callSetOwner(address _newOwner) public {
lib.delegatecall(abi.encodeWithSignature("setOwner(address)", _newOwner));
}
}
// 攻撃者が用意する悪意あるコントラクト
contract MaliciousLib {
address public dummy; // スロット0:Proxyのownerと衝突!
address public attacker; // スロット1:Proxyのlibと衝突!
function setOwner(address _newOwner) public {
// ここでProxyのスロット0(owner)を直接上書きする
// アドレスを強制的に自分に変更できる
owner = msg.sender;
}
}
このケースでは、Proxy の owner がスロット0に定義されているのに対し、MaliciousLib もスロット0で変数を定義しています。delegatecall を呼び出した瞬間、Proxy 側の owner は、MaliciousLib のロジックによって攻撃者のアドレスに書き換わります。これが「ストレージ衝突」の正体です。
—
実務で絶対に守るべき「セキュアな設計ルール」
この問題を回避するために、最新のWeb3開発現場では以下の実装パターンを徹底しています。
1. プロキシパターンでは「Unstructured Storage」を使う
変数の順序や型に依存せず、特定のストレージスロットを固定して変数を管理する手法です。OpenZeppelinの ERC1967 標準を必ず利用してください。
// OpenZeppelinの流儀を取り入れた固定スロットの定義
// 変数の位置に依存しないため、衝突リスクを排除できる
abstract contract StorageSlot {
// 衝突しにくいハッシュ値をスロットに指定(ERC1967のルール)
bytes32 internal constant _OWNER_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
function _getOwner() internal view returns (address) {
address owner;
assembly {
owner := sload(_OWNER_SLOT)
}
return owner;
}
}
2. ロジックコントラクトには「初期化(Initializer)」を導入
コントラクトのコンストラクタは、プロキシ経由では実行されません。その代わり、initialize 関数を一度だけ実行するガードを設けるのが鉄則です。
// JavaScript(Ethers.js)での安全なデプロイ例
const Proxy = await ethers.getContractFactory("MyProxy");
const proxy = await Proxy.deploy();
// 初期化関数を呼び出して権限を確定させる
const tx = await proxy.initialize(adminAddress);
await tx.wait();
console.log("初期化完了:権限の乗っ取りを防ぎました");
—
セキュリティチーフからの「泥臭い」アドバイス
技術書には載っていない、現場の知見を二つ共有します。
- ライブラリの依存関係を厳格に管理せよ
delegatecall を使う際は、信頼できない外部コントラクトを絶対に経由させないこと。ライブラリをアップグレードする際は、ストレージレイアウトをツール(slither 等)で解析し、破壊的変更がないか自動検知するCI/CDパイプラインを組むのが「プロ」の仕事です。
- 「とりあえずdelegatecall」を禁止せよ
複雑なロジックを共通化したいとき、本当に delegatecall が必要ですか? staticcall で十分なケースや、そもそもコントラクトを分割する必要がないケースが大半です。複雑性は脆弱性の母であることを忘れないでください。
最後に:インシデントは防げる
ストレージ衝突は、ハッキングの中でも最も「防ぎやすい」部類に入ります。なぜなら、設計段階でコードの静的解析を行えば、衝突箇所はツールが瞬時に指摘してくれるからです。
今日からあなたのプロジェクトでも、slither を導入し、delegatecall を使用している箇所にフラグを立ててください。それだけで、数千万円単位の資産を守ることにつながります。
セキュリティとは、派手なハッキング手法を見つけることではなく、退屈なルールを泥臭く守り続けること。これに尽きるのです。
コメント