おい、新人。ちょっとこっちに来い。
お前がさっきレビューに出してきたアップグレード可能なプロキシコントラクトのプルリクエスト、危うくプロダクション環境に爆弾を仕込むところだったぞ。
「ライブラリのコードを再利用するために delegatecall を使いました。ガス代も節約できます」だと?
……お前、delegatecall の本当の恐怖を分かって使っているのか?
OT(制御システム)のファームウェア書き換えでこんなミスをしたら、数千台のスマートメーターやプラントのバルブが一瞬でハッカーの言いなりになるレベルの致命傷だ。Web3の世界でも、ストレージ衝突(Storage Collision)を突かれたプロキシハックで、過去に何百億円もの資金が消え去っている。
今日は、この delegatecall がなぜこれほどまでに危険なのか、そしてどうやってその魔手を防ぐのか、俺が現場のリアルなノウハウを交えて徹底的に叩き込んでやる。心して聞け。
—
1. なぜ delegatecall は「諸刃の剣」なのか?
通常のスマートコントラクト間の呼び出し(call)は、宛先のコントラクトのコンテキスト(状態空間)でコードが実行される。つまり、呼び出し先(ライブラリ側)のストレージ変数が書き換わるだけで、呼び出し元(プロキシ側)の財布や状態は安全だ。
しかし、delegatecall は違う。
「コードは呼び出し先のロジックを使うが、ストレージの読み書きはすべて呼び出し元(プロキシ)のコンテキストで行う」 という特異な挙動をする。
これが何を意味するか分かるか?
プロキシ側とライブラri側で、ストレージ変数の宣言順序や型が1つでもズレていたら、ライブラリ側が意図しないプロキシ側の変数(例えば owner や balance)を平気な顔で上書きしてしまうということだ。これが ストレージ衝突(Storage Collision) の正体だ。
攻撃者はこの仕様の隙を突き、ライブラリ側の初期化関数などを直接叩かせてオーナー権限を奪い取る。IoTデバイスのファームウェアアップデート機構や、Web3のプロキシパターンにおいて、ここを見落とすエンジニアはプロとして失格の烙印を押されても文句は言えない。
—
2. 攻撃者の視点:ストレージ衝突のメカニズム
Solidityのストレージは、32バイトの「スロット」が並んだ配列のような構造をしている。変数が宣言された順番に slot 0, slot 1, slot 2 と割り当てられていく。
ここで、プロキシコントラクトと実装(ロジック)コントラクトの間で、変数の定義に以下のような食い違いがあったとする。
危険な実装例(脆弱な構造)
// プロキシコントラクト(呼び出し元)
contract VulnerableProxy {
address public owner; // slot 0 に格納
address public logicContract; // slot 1 に格納
// delegatecallを使ってロジックを実行する関数
function execute(bytes memory data) external {
(bool success, ) = logicContract.delegatecall(data);
require(success, "Delegatecall failed");
}
}
// 実装コントラクト(呼び出し先 / ライブラリ)
contract MaliciousLogic {
// ⚠️ 順番を間違えている!あるいは後から変数を追加してしまった
address public logicContract; // slot 0 に割り当てられてしまう!
address public owner; // slot 1 に割り当てられてしまう!
function changeOwner(address newOwner) external {
owner = newOwner; // これを実行すると、プロキシ側の slot 1 (logicContract) が書き換わる!
}
}
この状態で見ず知らずの攻撃者が changeOwner を delegatecall 経由で呼び出すとどうなるか?
プロキシ側の owner(slot 0)を守るつもりが、実装側では owner が slot 1 にあるため、プロキシ側の logicContract のアドレス(slot 1)が攻撃者のアドレスに書き換わってしまう。
結果として、プロキシの心臓部であるロジックコントラクトのアドレスを完全にっと乗っ取られ、次に任意のコードを自由に実行されるという最悪のバッドエンドを迎えることになる。
—
3. 【完全防御】セキュアな実装と設計パターン
じゃあ、どうやってこのリスクを完全に排除すればいいのか。答えは明確だ。
1. ストレージレイアウトを完全に一致させる(継承を使う場合は特に注意)。
2. OpenZeppelin等の枯れた標準ライブラリ(Proxyパターン)をそのまま使う(車輪の再発明はするな)。
3. EIP-1967などの標準規格に準拠し、ストレージスロットを擬似的にランダムかつ衝突しない場所に固定する。
百聞は一見にしかずだ。実務でそのまま使える、安全なプロキシコントラクトの実装サンプルを見せてやる。
コピペで使えるセキュアなアップグレード可能プロキシの実装例
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/**
* @dev EIP-1967に準拠したセキュアなプロキシコントラクト
* ストレージ衝突を防ぐため、特定のスロットにアドレスをハードコードする
*/
contract SecureProxy {
// 衝突を避けるためにランダムに生成されたストレージスロット
// bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1)
bytes32 private constant IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
// 管理者スロット
// bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1)
bytes32 private constant ADMIN_SLOT =
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
modifier onlyAdmin() {
require(msg.spender == _getAdmin(), "SecureProxy: caller is not the admin");
_;
}
constructor(address _logic) {
_setAdmin(msg.sender);
_setImplementation(_logic);
}
// 実装コントラクトのアドレスを取得
function _getImplementation() internal view returns (address impl) {
bytes32 slot = IMPLEMENTATION_SLOT;
assembly {
impl := sload(slot)
}
}
// 実装コントラクトのアドレスを設定
function _setImplementation(address newImplementation) internal {
bytes32 slot = IMPLEMENTATION_SLOT;
assembly {
sstorage(slot, newImplementation)
}
}
// 管理者アドレスを取得
function _getAdmin() internal view returns (address adm) {
bytes32 slot = ADMIN_SLOT;
assembly {
adm := sload(slot)
}
}
function _setAdmin(address newAdmin) internal {
bytes32 slot = ADMIN_SLOT;
assembly {
sstorage(slot, newAdmin)
}
}
/**
* @notice ロジックコントラクトの変更(アップグレード)
*/
function upgradeTo(address newImplementation) external onlyAdmin {
_setImplementation(newImplementation);
}
/**
* @notice すべての外部呼び出しをdelegatecallで実装コントラクトへ転送
*/
fallback() external payable {
address impl = _getImplementation();
require(impl !=address(0), "SecureProxy: implementation not set");
assembly {
// calldataのコピーとdelegatecallの実行
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
receive() external payable {
// ETHの直接受取用
}
}
—
4. セキュリティチーフからの実務的アドバイス
いいか、このコードを見れば分かる通り、EIP-1967方式では IMPLEMENTATION_SLOT や ADMIN_SLOT に通常の変数のようにおいそれとアクセスできないように、ケシカランハッシュ値から計算した特定のスロットに値を直接埋め込んでいる。これによって、通常のストレージ変数がどれだけ増減しようとも、プロキシの基幹データが衝突して破壊されるリスクを完全にシャットアウトできるんだ。
現場のエンジニアとして、お前に守ってほしい鉄則を最後にまとめておく。
1. 生の状態変数をプロキシコントラクトに直接定義するな
どうしてもプロキシ側で状態を持つ必要がある場合は、必ずEIP-1967のようなスロット固定化パターン(Namespaced Storage Pattern)を採用しろ。
2. 監査ツール(SlitherやMythril)をCI/CDに組み込め
プルリクエストの段階で、ストレージ衝突の兆候がないか自動で静的解析を走らせる仕組みを強制する。人力の目視レビューだけに頼るな。
3. アップグレードの権限管理を厳格化せよ
管理者権限(admin)が単一のEOA(個人の秘密鍵)になっていないか? マルチシグ(Gnosis Safeなど)やタイムロックを必ず挟む運用にしろ。
セキュリティとは、疑うことからはじまる。
「動けばいいや」という甘いコードを書く奴は、俺が容赦なくリポジトリから叩き出すからそのつもりでな。
さて、お前のプルリクエストは一旦差し戻しだ。今教えた内容を踏まえて、もう一度コードを書き直して出直してこい。期待してるぞ。
コメント