【警告】コントラクトの「影武者」には要注意:delegatecallによるストレージ汚染の深淵
現場のセキュリティリサーチャーとして、これまで数多のDAOやDeFiプロトコルの惨劇を見てきた。その中でも、特に「スマートコントラクトのアップグレード可能性(Proxyパターン)」を実装する際に、開発者が陥る最も致命的な罠が delegatecall によるストレージ衝突だ。
「ライブラリを使ってコードを再利用する」「プロキシで実装を入れ替える」。聞こえはいいが、これらはEVM(Ethereum Virtual Machine)のメモリ管理という、極めて泥臭い領域への理解なしに触れてはならない禁忌でもある。
なぜ delegatecall は「ストレージの書き換え」を許すのか
delegatecall は、呼び出し先のコントラクトのコードを「自分のコンテキスト(自分のストレージ領域)」で実行する。つまり、呼び出し先が変数 owner を書き換えれば、呼び出し元(Proxy側)の owner が書き換わる。
ここで発生するのがストレージレイアウトの不一致だ。
EVMにおいて、ストレージ変数はスロット(0番目、1番目…)に順次割り当てられる。もしProxy側とロジック側で、変数の定義順序がわずかでもズレていれば、攻撃者はロジック側の関数を呼び出すだけで、Proxy側の重要なフラグや管理者アドレスを自由自在に書き換えられる。
—
【PoC】脆弱性が生まれる瞬間
以下は、典型的な「やらかし」のコードだ。
// 脆弱なプロキシコントラクト
contract Proxy {
address public implementation; // スロット0
address public admin; // スロット1
function upgrade(address _impl) public {
admin = msg.sender;
implementation = _impl;
}
fallback() external {
(bool success, ) = implementation.delegatecall(msg.data);
require(success);
}
}
// 攻撃者が用意した不正なロジックコントラクト
contract MaliciousLogic {
address public hacked; // スロット0(Proxyのimplementationを上書き!)
address public newAdmin; // スロット1(Proxyのadminを上書き!)
function attack() public {
// ここを呼ぶと、Proxyのadminが書き換わり、乗っ取られる
}
}
このケースでは、MaliciousLogic のスロット0にある hacked が、Proxy側の implementation と衝突している。攻撃者はこれを突いて、Proxyが参照するロジック先を自分のコントラクトにすり替える。
—
実務で絶対に守るべき「セキュアな実装ルール」
この問題を回避するために、最新のプロキシパターン(EIP-1967など)では、変数を特定の固定スロットに配置する手法が定着している。
1. 変数の位置を「固定」する
変数を定義するのではなく、ハッシュ値から生成したランダムなスロット番号に直接値を書き込むことで、レイアウトの衝突を防ぐ。
// セキュアなプロキシのストレージ設計(OpenZeppelin流)
abstract contract ProxyStorage {
// 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
// この定数は、特定のメモリ領域を指すためのマジックナンバー
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
function _setImplementation(address newImplementation) internal {
assembly {
// sstore(スロット, 値) で直接メモリに書き込む
sstore(_IMPLEMENTATION_SLOT, newImplementation)
}
}
}
2. ロジックコントラクトでの継承順位を厳守する
もし複数のコントラクトを継承している場合、Storage コントラクトを必ず一番最初に継承させ、変数定義の順序を全バージョンで完全固定する。
—
運用現場での「防御の防波堤」
スマートコントラクトだけでなく、Web3インフラの運用側でも守りを固める必要がある。以下の設定は、インシデントハンドリングの現場で私が必ずチェックする項目だ。
NginxによるAPIゲートウェイの保護
ブロックチェーンのノードRPCを直接公開してはいけない。認証とレートリミットを必ず挟むこと。
# /etc/nginx/conf.d/rpc_proxy.conf
location /rpc {
# 認証を強制し、悪意あるスキャンをブロック
auth_basic "Restricted Access";
auth_basic_user_file /etc/nginx/.htpasswd;
# レートリミット(攻撃によるリソース枯渇を防ぐ)
limit_req zone=rpc_limit burst=5 nodelay;
proxy_pass http://localhost:8545;
proxy_set_header X-Real-IP $remote_addr;
}
まとめ:セキュリティは「構造」への理解から始まる
delegatecall は強力だが、諸刃の剣だ。ストレージのレイアウトを「コンパイラ任せ」にしているうちは、いつか必ず痛い目を見る。
1. アップグレード可能な設計にするなら、EIP-1967などの標準的なプロキシライブラリを絶対に使用せよ(車輪の再発明は禁止)。
2. ストレージ変数の定義順序を、プロジェクトの寿命が尽きるまで決して変更してはならない。
3. コントラクトのアップグレード時には、必ず storage layout の変更がないか解析ツール(hardhat-storage-layout 等)で確認を自動化せよ。
コードを書くとき、常に「この変数はメモリのどこに保存され、誰に上書きされる可能性があるか?」を想像してほしい。その想像力こそが、ハッカーの攻撃を無効化する唯一の武器になる。現場からは以上だ。
コメント