Skip to content
On this page

CTF 挑戦:Token Bridge の秘密 - 脆弱性の解明と資産保護

このCTFチャレンジは、L2からL1へのトークンブリッジにおける潜在的な脆弱性に焦点を当てています。巧妙に隠された「怪しい」引き出し要求を検出し、それを阻止しながら、他の正当な引き出しをすべて成功させる必要があります。さらに、ブリッジの資産を枯渇させないように注意を払う必要があります。

挑戦の概要

  • 目標: L2からL1へのトークンブリッジからDVTトークンを引き出す。
  • ブリッジの仕組み: L1側では、指定された遅延期間が経過し、有効なMerkleプルーフが提示されれば、誰でも引き出しを確定できます。プルーフは、ブリッジの所有者によって設定された最新の引き出しルートに対応している必要があります。
  • 提供情報: 4つのL2での引き出し要求を示すJSON形式のイベントログ。これらは7日間の遅延後にL1で実行可能です。
  • 課題:
    1. 4つの引き出し要求の中に隠された「怪しい」要求を特定する。
    2. その怪しい要求の実行を阻止する。
    3. 他のすべての正当な引き出しを成功させる。
    4. ブリッジからすべての資金を引き出さない。

脆弱性の探求:コードを読み解く

提供されたスマートコントラクト L1Forwarder.solL1Gateway.sol が、このブリッジの核心部分を担っています。

L1Gateway.sol の分析:

  • DELAY: 引き出し確定までに必要な7日間の遅延。
  • root: 有効なMerkleプルーフの基準となるルート。
  • finalizeWithdrawal 関数:
    • timestamp + DELAY > block.timestamp という条件で、早期引き出しを禁止しています。
    • isOperator フラグは、ブリッジのオペレーター(プレイヤー)がプルーフなしで引き出しを確定できることを示唆しています。
    • MerkleProof.verify(proof, root, leaf) によって、オペレーター以外は有効なMerkleプルーフを提示する必要があります。
    • finalizedWithdrawals マッピングで、すでに確定済みの引き出しが二重に実行されるのを防いでいます。
    • 重要な点: isOperatortrue の場合、Merkleプルーフの検証がスキップされます。これは、オペレーター権限を悪用できる可能性を示唆しています。

L1Forwarder.sol の分析:

  • forwardMessage 関数:
    • メッセージが L1Gateway から、かつ gateway.xSender() == l2Handler の場合に、!failedMessages[messageId] を要求します。
    • それ以外の場合(つまり、オペレーターが直接呼び出す場合)、failedMessages[messageId] を要求します。これは、オペレーターが(おそらく)失敗したメッセージを再試行できることを意味しますが、本来は保護されるべきです。
    • BadTarget() エラー: targetaddress(this) または address(gateway) である場合にエラーとなります。

TokenBridge.sol の分析:

  • executeTokenWithdrawal 関数:
    • msg.sender != address(l1Forwarder) または l1Forwarder.getSender() == otherBridge の場合に Unauthorized() エラーとなります。
    • この関数は、L1Forwarder経由で呼び出されることを想定しており、L2からの送信者 (l1Forwarder.getSender()) が otherBridge でないことを確認しています。

謎解きの鍵:怪しい引き出しの正体

提供されたJSONファイルには、4つの引き出し要求の情報が含まれています。これらの情報とスマートコントラクトのロジックを照らし合わせることで、怪しい引き出しを特定できます。

CTFのコンテキストでは、通常、提供されるログデータの中に、脆弱性を突くための「餌」が隠されています。この場合、L1Gateway.solfinalizeWithdrawal 関数におけるオペレーター権限の扱いや、L1Forwarder.solforwardMessage 関数における failedMessages のチェックロジックが鍵となります。

仮説:

  1. オペレーター権限の悪用: プレイヤーはオペレーター権限を持っているため、Merkleプルーフなしで引き出しを確定できます。この権限を利用して、本来は無効な引き出し(例えば、存在しないルートに基づいたものや、不正なデータを持つもの)を確定させてしまう可能性があります。
  2. failedMessages の操作: L1Forwarder.solforwardMessage 関数では、オペレーターが failedMessages に登録されているメッセージIDを渡した場合、そのメッセージが実行されるというロジックがあります。これは、意図的に失敗させられた引き出しを、オペレーターが再度実行させるための仕組みかもしれませんが、悪用されると問題が発生します。

解答の方向性:

  1. 怪しい引き出しの特定: 提供されたJSONデータから、Merkleルートとの関連性や、他の引き出しと異なる特徴を持つものを探します。特に、L1Gateway.solroot が有効でない、あるいは L1Forwarder.solforwardMessage 関数で failedMessagestrue になるような引き出しが怪しいと考えられます。
  2. オペレーター権限の活用: プレイヤーはオペレーター権限を持つため、L1Gateway.solfinalizeWithdrawal 関数を、Merkleプルーフを提示せずに呼び出すことができます。
  3. 攻撃シナリオ:
    • まず、怪しい引き出しを特定します。
    • 次に、その怪しい引き出しが L1Gateway.finalizeWithdrawal で実行されることを意図的に引き起こします。これは、L1Forwarder.forwardMessage を通じて行われると考えられます。
    • ここで重要なのは、L1Forwarder.forwardMessage のロジックです。オペレーターが直接呼び出す場合、failedMessages[messageId]true であることを要求されます。これは、L2側で意図的に失敗させたメッセージを、L1側でオペレーターが再試行させるためのものです。
    • つまり、怪しい引き出しは、L2側で意図的に失敗するように仕向けられ、その結果 failedMessages に登録された後、オペレーター権限で L1Forwarder.forwardMessage を呼び出すことで、L1側で実行を試みられる、という流れが考えられます。
    • しかし、このCTFの目的は「怪しい引き出しを阻止し、他のものを成功させる」ことです。したがって、怪しい引き出しを実行させずに、他の正当な引き出しだけを実行させる必要があります。
  4. 防衛策:
    • CTFの回答コード (Withdrawal.t.sol) を見ると、skip(l1Gateway.DELAY()+4 minutes); で時間を進めています。これは、引き出しが実行可能になるのを待っていることを意味します。
    • そして、l1Gateway.finalizeWithdrawal をループで呼び出しています。ここでの proof は空の bytes32[] です。これは、プレイヤーがオペレーター権限を持っているため、プルーフが不要であることを利用しています。
    • 怪しい引き出しの阻止: CTFの解答コードでは、4つの引き出し要求とは別に、もう1つの引き出し(WITHDRAWALS_NUM = WITHDRAWALS_AMOUNT + 1)をループで処理しています。この追加の引き出しが、怪しい引き出しとして処理され、かつそれを成功させる(あるいは、他の正当な引き出しに影響を与えないようにする)必要があります。
    • 解答コードの test_withdrawal 関数内で、WITHDRAWALS_NUMWITHDRAWALS_AMOUNT+1 となっており、5つの引き出しを処理していることがわかります。これは、提供された4つのログに加えて、もう1つの「特殊な」引き出しを処理することを意味します。
    • この5番目の引き出しは、noncesFs[0] = uint256(type(uint256).max)l2SenderFs[0]player アドレスであることから、プレイヤー自身が開始した「正規の」引き出しに見えます。
    • しかし、TokenBridge.solexecuteTokenWithdrawal 関数では、l1Forwarder.getSender() == otherBridge の場合に Unauthorized() エラーとなります。
    • L1Forwarder.solforwardMessage 関数では、msg.sender == address(gateway) && gateway.xSender() == l2Handler の場合と、それ以外の場合で、successfulMessagesfailedMessages のチェックが異なります。
    • CTFの核心: CTFの解答コードでは、5つの finalizeWithdrawal を呼び出していますが、そのうちの1つ(i=0 の場合)が noncesFs[0] = type(uint256).max となっており、これは怪しい引き出しの処理を示唆しています。そして、finalizedWithdrawals のチェックで、4つの特定の葉(leaf)が最終化されていることを確認しています。
    • つまり、攻撃者は、提供された4つの引き出しログをすべて「正常」に処理し、そのうち1つ(または複数)を「怪しい」と見せかけ、それを阻止しつつ、本来の4つの引き出しをすべて実行させる必要があります。

解答コードの戦略:

  1. 遅延を待つ: skip(l1Gateway.DELAY()+4 minutes); で、引き出しが実行可能になるまで待ちます。
  2. 5つの引き出しを処理: WITHDRAWALS_NUM = WITHDRAWALS_AMOUNT + 1 として、5つの引き出し要求を生成し、l1Gateway.finalizeWithdrawal を呼び出します。
  3. 「怪しい」引き出しの処理:
    • i=0 のループでは、noncesFs[0] = type(uint256).max となっており、これが怪しい引き出しの処理に関連している可能性が高いです。
    • receiverTBs[0]receiverTBs[4] は同じ player アドレスですが、amountTBs[0]amountTBs[4] は異なります。
    • l2SendersFs[0]player アドレスです。
    • L1Forwarder.solforwardMessage 関数において、msg.senderaddress(gateway) でない場合(つまり、オペレーターが直接呼び出す場合)、require(failedMessages[messageId]) が実行されます。
    • CTFの解答コードでは、5つの引き出しをすべて proof なしで finalizeWithdrawal しています。これはオペレーター権限を利用しています。
    • 怪しい引き出しは、実は5番目の引き出し(i=0 の場合)であり、その nonce が type(uint256).max に設定されていること、そして l2SenderFs[0] がプレイヤー自身のアドレスであることから、プレイヤーが意図的に無効な状態を作り出そうとした引き出しです。
    • しかし、L1Gateway.finalizeWithdrawalisOperatortrue ならプルーフなしで実行できます。
    • CTFの目的は、怪しい引き出しを阻止し、残りの正当な引き出しをすべて成功させることです。
    • 解答コードでは、5つの引き出しをすべて finalizeWithdrawal していますが、そのうち i=0 の引き出し(noncesFs[0] = type(uint256).max)が、最終的に「怪しい」ものとして扱われ、それが実行されても問題がない(あるいは、他の引き出しに影響を与えない)ように設計されています。
    • _isSolved() 関数で確認されるのは、4つの特定の leaf が最終化されているかどうかです。これは、提供された4つの引き出しログに対応するものです。
    • 真の解答: プレイヤーは、オペレーター権限を利用して、与えられた4つの引き出しログに対応する finalizeWithdrawal をすべて実行します。 そして、5番目の引き出し(noncesFs[0] = type(uint256).max)は、実は「怪しい」ものではなく、正当な引き出しをすべて成功させた上で、ブリッジの資産を意図的に減少させないための追加の処理となります。
    • token.transfer(address(l1TokenBridge), INITIAL_BRIDGE_TOKEN_AMOUNT-1);token.transfer(deployer, 1); は、ブリッジの残高を調整し、完全に枯渇しないようにするための操作です。

まとめ

このCTFチャレンジは、スマートコントラクトのロジックを深く理解し、特に権限管理やメッセージ検証の仕組みに潜む脆弱性を見抜く能力を試します。オペレーター権限を巧みに利用し、意図的に無効な状態を作り出すのではなく、提供されたログのすべてを正当に処理しつつ、ブリッジの安全性を確保するという、一見相反する要求を満たすことが求められます。

最終的な解答は、提供された4つの引き出しログすべてに対応する finalizeWithdrawal を、オペレーター権限を利用して(プルーフなしで)実行することです。そして、5番目の引き出しは、ブリッジの残高を管理し、チャレンジの成功条件を満たすための追加の操作となります。

Built with AiAda