プロキシの裏側で何が起きているか? delegatecall とストレージ衝突の深層
おい、手を止めて画面を見てくれ。
先日、あるクライアントのDeFiプロトコルで、総額数千万ドル規模の資産がハッキングされかけるインシデントが発生した。原因をコードベースから特定したとき、私は思わず天を仰いだ。まただ。また「なんとなくコピペしてきたプロキシパターン」のせいで、致命的なストレージの書き換え(ストレージ衝突)が起きていたんだ。
Web3開発の世界では、「アップグレード可能なスマートコントラクト」を作るためにプロキシパターン(Proxy Pattern)が多用されている。ロジックを分離し、バグの修正や機能追加を後から行えるようにする便利な仕組みだ。しかし、この仕組みの裏側で動いている delegatecall の挙動を正しく理解していない開発者が多すぎる。
今回は、攻撃者がどのようにこの仕組みの隙を突き、ストレージを破壊してコントラクトを乗っ取るのか。そして、それを実務で完全に封じ込めるための決定版である EIP-1967 準拠の実装について、現場の知見を交えて徹底的に解説しよう。
—
1. delegatecall の魔力とストレージ衝突のメカニズム
まずは基礎の復習だが、ここは攻撃者の視点でシビアに理解してほしい。
delegatecall は、他のコントラクトのコードを「自分のコンテキスト(ストレージ、残高、アドレス)」で実行するための低水準(Low-level)命令だ。
通常の call であれば、呼び出し先のコントラクトのストレージが読み書きされる。しかし delegatecall は違う。「呼び出し元(Proxy)のストレージ」に対して、呼び出し先(Implementation)のロジックが書き込みを行う。
ここで問題になるのが 「ストレージレイアウトの不整合」 だ。
EVM(Ethereum Virtual Machine)において、コントラクトのストレージはスロット(Slot 0, Slot 1, Slot 2… 各32バイト)の配列として管理されている。Solidityのコンパイラは、変数が宣言された順番通りにストレージスロットを割り当てる。
もし、ProxyコントラクトとImplementationコントラクトの間で、状態変数の宣言順序や型が少しでもズレていたらどうなるか?
- Proxy側の Slot 0:
address public owner;(所有者のアドレス) - Implementation側の Slot 0:
uint256 public totalSupply;(トークンの総供給量)
この状態でImplementation側の関数が Slot 0 に数値を書き込むと、Proxy側の owner アドレスがその数値(バイト列)に書き換わってしまう。攻撃者が巧妙に計算された値を送り込めば、一瞬でオーナー権限が奪い取られるというわけだ。これがストレージ衝突の恐怖である。
—
2. 攻撃者の手口:悪意ある実装コントラクトによる乗っ取り
百聞は一見に如かず。実際にストレージ衝突を引き起こし、プロキシを乗っ取るための攻撃用スマートコントラクト(PoC)を見てみよう。JavaScript(Hardhat / Ethers.js)テスト環境を想定した攻撃シナリオだ。
脆弱なProxyがあり、そのストレージの Slot 0 にオーナーアドレスが格納されているとする。そこに、ストレージ構造を意図的に破壊する悪意あるコントラクトを delegatecall 経由で実行させる。
// 攻撃者のPoC(Hardhatテストスクリプトの抜粋)
const { ethers } = require("hardhat");
const { expect } = require("chai");
describe("Storage Collision Exploit PoC", function () {
it("delegatecallによるストレージ衝突でオーナー権限を奪う", async function () {
[owner, attacker] = await ethers.getSigners();
// 1. 脆弱なプロキシコントラクトのデプロイ(Slot 0 に owner を持つ)
const VulnerableProxy = await ethers.getContractFactory("VulnerableProxy");
const proxy = await VulnerableProxy.deploy();
await proxy.waitForDeployment();
// 初期オーナーの確認
expect(await proxy.owner()).to.equal(owner.address);
// 2. 攻撃用の実装コントラクトのデプロイ
// このコントラクトは、Slot 0 に攻撃者のアドレスを書き込む構造を持つ
const MaliciousImplementation = await ethers.getContractFactory("MaliciousImplementation");
const malicious = await MaliciousImplementation.deploy();
await malicious.waitForDeployment();
// 3. プロキシ経由で悪意あるロジックを delegatecall 実行
// プロキシの owner 変数(Slot 0)が、攻撃者のアドレスに書き換わる
const attackData = malicious.interface.encodeFunctionData("pwn", [attacker.address]);
await proxy.execute(await malicious.getAddress(), attackData);
// 4. オーナーが攻撃者に書き換わっていることを確認
expect(await proxy.owner()).to.equal(attacker.address);
console.log("【警告】ストレージ衝突により、オーナーが攻撃者に書き換えられました!");
});
});
このように、ストレージのレイアウト管理を少しでもミスると、セキュリティ機構は紙細工のように崩壊する。
—
3. 根本的解決:EIP-1967 による標準ストレージスロットの隔離
この問題を歴史的に解決したのが EIP-1967 だ。
アプローチは極めてシンプルかつ堅牢。「プロキシが管理すべき重要な変数(実装コントラクトのアドレスやオーナーなど)を、通常の変数と同じストレージスロットに置かない」 というもの。
EIP-1967では、ストレージ衝突が絶対に起きないよう、特定の「擬似ランダムなスロット番号」をハードコードして使用する。このスロット番号は、ケシツボ(Keccak-256)ハッシュから生成されており、通常のSolidityの変数宣言では絶対に割り当てられない領域を狙っている。
- 実装アドレス(Implementation):
bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1) - 管理者アドレス(Admin):
bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)
それでは、実務でそのままコピーして使える、EIP-1967準拠のセキュアなプロキシコントラクトの実装コードを提示しよう。
—
4. 【コピペOK】EIP-1967準拠 セキュア・プロキシ実装サンプル
以下のSolidityコードは、OpenZeppelin等の標準的な設計思想に基づき、ストレージ衝突のリスクを完全に排除したプロキシの実装だ。チームの開発ガイドラインやテンプレートとしてそのまま活用してほしい。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/**
* @title EIP-1967準拠のセキュアプロキシコントラクト
* @notice ストレージ衝突を防ぐため、実装アドレスを特殊なスロットに格納します。
*/
contract SecureProxy {
// EIP-1967で定められた実装アドレス格納用のスロット
// bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1) と同等
bytes32 private constant IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc5;
// EIP-1967で定められた管理者アドレス格納用のスロット
bytes32 private constant ADMIN_SLOT =
0xb53127324a5e14356eb3e5a315b7415494d4d682390f7797745e73099cd3f023;
/**
* @notice デプロイ時に初期管理者と初期実装アドレスを設定
* @param _logic 最初に呼び出す実装コントラクトのアドレス
* @param _data 初期化用のデータ(initializerの呼び出し等)
*/
constructor(address _logic, bytes memory _data) payable {
_setAdmin(msg.sender);
_upgradeTo(_logic);
if (_data.length > 0) {
(bool success, bytes memory reason) = _logic.delegatecall(_data);
require(success, string(reason));
}
}
/**
* @notice 管理者を取得する関数
*/
function admin() public view returns (address adm) {
bytes32 slot = ADMIN_SLOT;
assembly {
adm := sload(slot)
}
}
/**
* @notice 現在の実装アドレスを取得する関数
*/
function implementation() public view returns (address impl) {
bytes32 slot = IMPLEMENTATION_SLOT;
assembly {
impl := sload(slot)
}
}
/**
* @notice 実装コントラクトをアップグレードする関数(管理者のみ実行可能)
* @param newImplementation 新しい実装コントラクトのアドレス
*/
function upgradeTo(address newImplementation) external {
require(msg.sender == admin(), "SecureProxy: 権限がありません(管理者専用)");
require(newImplementation.code.length > 0, "SecureProxy: コントラクトではありません");
_upgradeTo(newImplementation);
}
/**
* @internal 実装アドレスをEIP-1967スロットに書き込む内部関数
*/
function _upgradeTo(address newImplementation) internal {
bytes32 slot = IMPLEMENTATION_SLOT;
assembly {
sstore(slot, newImplementation)
}
}
/**
* @internal 管理者アドレスをEIP-1967スロットに書き込む内部関数
*/
function _setAdmin(address newAdmin) internal {
bytes32 slot = ADMIN_SLOT;
assembly {
sstore(slot, newAdmin)
}
}
/**
* @notice 全ての外部呼び出しを受け付け、実装コントラクトへ delegatecall で転送するフォールバック関数
*/
fallback() external payable {
_delegate(implementation());
}
receive() external payable {
_delegate(implementation());
}
/**
* @internal インラインアセンブリを用いた低水準の委譲処理
*/
function _delegate(address _logic) internal {
assembly {
// calldataのコピー
calldatacopy(0, 0, calldatasize())
// delegatecallの実行
// 呼び出し先, 0からcalldatasizeまで入力, 出力先, 出力サイズ
let result := delegatecall(gas(), _logic, 0, calldatasize(), 0, 0)
// 戻り値のコピー
returndatacopy(0, 0, returndatasize())
// 成功・失敗に応じた処理
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
—
5. シニアセキュリティチーフからの実践的な運用Tips
このコードをデプロイすれば万全……と言いたいところだが、現場の運用ではさらに以下のポイントをチーム内で徹底してほしい。
1. OpenZeppelin Contractsの積極的な活用
自前でプロキシを書くのは、余程の理由がない限りアンチパターンだ。OpenZeppelinが提供する @openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol や UUPSUpgradeable をベースに使い、監査済みのコードベースを信頼せよ。
2. 初期化関数(initializer)の保護
実装コントラクト側で initialize() 関数が不特定多数から複数回叩かれないよう、_disableInitializers() や initializer 修飾子(Modifier)を確実につけること。ここを忘れると、アップグレード後に初期化関数を叩かれてコントラクトを乗っ取られる。
3. ストレージギャップ(Storage Gap)の確保
UUPSパターンなどで実装コントラクト側を継承して拡張していく場合、将来的な変数の追加に備えて uint256[50] private __gap; のようなストレージギャップを末尾に定義するのを忘れるな。これを怠ると、次のバージョンアップでストレージのオフセットが狂い、大惨事につながる。
セキュリティは「知らなかった」では済まされない世界だ。プロキシを扱うときは、常に「ストレージのどこに何が書き込まれているか」を頭の中でパケットレベル、スロットレベルで視覚化できるようにしておいてくれ。頼んだぞ。
コメント