Appearance
CTF 挑戦:Token Bridge の秘密 - 脆弱性の解明と資産保護
このCTFチャレンジは、L2からL1へのトークンブリッジにおける潜在的な脆弱性に焦点を当てています。巧妙に隠された「怪しい」引き出し要求を検出し、それを阻止しながら、他の正当な引き出しをすべて成功させる必要があります。さらに、ブリッジの資産を枯渇させないように注意を払う必要があります。
挑戦の概要
- 目標: L2からL1へのトークンブリッジからDVTトークンを引き出す。
- ブリッジの仕組み: L1側では、指定された遅延期間が経過し、有効なMerkleプルーフが提示されれば、誰でも引き出しを確定できます。プルーフは、ブリッジの所有者によって設定された最新の引き出しルートに対応している必要があります。
- 提供情報: 4つのL2での引き出し要求を示すJSON形式のイベントログ。これらは7日間の遅延後にL1で実行可能です。
- 課題:
- 4つの引き出し要求の中に隠された「怪しい」要求を特定する。
- その怪しい要求の実行を阻止する。
- 他のすべての正当な引き出しを成功させる。
- ブリッジからすべての資金を引き出さない。
脆弱性の探求:コードを読み解く
提供されたスマートコントラクト L1Forwarder.sol と L1Gateway.sol が、このブリッジの核心部分を担っています。
L1Gateway.sol の分析:
DELAY: 引き出し確定までに必要な7日間の遅延。root: 有効なMerkleプルーフの基準となるルート。finalizeWithdrawal関数:timestamp + DELAY > block.timestampという条件で、早期引き出しを禁止しています。isOperatorフラグは、ブリッジのオペレーター(プレイヤー)がプルーフなしで引き出しを確定できることを示唆しています。MerkleProof.verify(proof, root, leaf)によって、オペレーター以外は有効なMerkleプルーフを提示する必要があります。finalizedWithdrawalsマッピングで、すでに確定済みの引き出しが二重に実行されるのを防いでいます。- 重要な点:
isOperatorがtrueの場合、Merkleプルーフの検証がスキップされます。これは、オペレーター権限を悪用できる可能性を示唆しています。
L1Forwarder.sol の分析:
forwardMessage関数:- メッセージが
L1Gatewayから、かつgateway.xSender() == l2Handlerの場合に、!failedMessages[messageId]を要求します。 - それ以外の場合(つまり、オペレーターが直接呼び出す場合)、
failedMessages[messageId]を要求します。これは、オペレーターが(おそらく)失敗したメッセージを再試行できることを意味しますが、本来は保護されるべきです。 BadTarget()エラー:targetがaddress(this)またはaddress(gateway)である場合にエラーとなります。
- メッセージが
TokenBridge.sol の分析:
executeTokenWithdrawal関数:msg.sender != address(l1Forwarder)またはl1Forwarder.getSender() == otherBridgeの場合にUnauthorized()エラーとなります。- この関数は、L1Forwarder経由で呼び出されることを想定しており、L2からの送信者 (
l1Forwarder.getSender()) がotherBridgeでないことを確認しています。
謎解きの鍵:怪しい引き出しの正体
提供されたJSONファイルには、4つの引き出し要求の情報が含まれています。これらの情報とスマートコントラクトのロジックを照らし合わせることで、怪しい引き出しを特定できます。
CTFのコンテキストでは、通常、提供されるログデータの中に、脆弱性を突くための「餌」が隠されています。この場合、L1Gateway.sol の finalizeWithdrawal 関数におけるオペレーター権限の扱いや、L1Forwarder.sol の forwardMessage 関数における failedMessages のチェックロジックが鍵となります。
仮説:
- オペレーター権限の悪用: プレイヤーはオペレーター権限を持っているため、Merkleプルーフなしで引き出しを確定できます。この権限を利用して、本来は無効な引き出し(例えば、存在しないルートに基づいたものや、不正なデータを持つもの)を確定させてしまう可能性があります。
failedMessagesの操作:L1Forwarder.solのforwardMessage関数では、オペレーターがfailedMessagesに登録されているメッセージIDを渡した場合、そのメッセージが実行されるというロジックがあります。これは、意図的に失敗させられた引き出しを、オペレーターが再度実行させるための仕組みかもしれませんが、悪用されると問題が発生します。
解答の方向性:
- 怪しい引き出しの特定: 提供されたJSONデータから、Merkleルートとの関連性や、他の引き出しと異なる特徴を持つものを探します。特に、
L1Gateway.solのrootが有効でない、あるいはL1Forwarder.solのforwardMessage関数でfailedMessagesがtrueになるような引き出しが怪しいと考えられます。 - オペレーター権限の活用: プレイヤーはオペレーター権限を持つため、
L1Gateway.solのfinalizeWithdrawal関数を、Merkleプルーフを提示せずに呼び出すことができます。 - 攻撃シナリオ:
- まず、怪しい引き出しを特定します。
- 次に、その怪しい引き出しが
L1Gateway.finalizeWithdrawalで実行されることを意図的に引き起こします。これは、L1Forwarder.forwardMessageを通じて行われると考えられます。 - ここで重要なのは、
L1Forwarder.forwardMessageのロジックです。オペレーターが直接呼び出す場合、failedMessages[messageId]がtrueであることを要求されます。これは、L2側で意図的に失敗させたメッセージを、L1側でオペレーターが再試行させるためのものです。 - つまり、怪しい引き出しは、L2側で意図的に失敗するように仕向けられ、その結果
failedMessagesに登録された後、オペレーター権限でL1Forwarder.forwardMessageを呼び出すことで、L1側で実行を試みられる、という流れが考えられます。 - しかし、このCTFの目的は「怪しい引き出しを阻止し、他のものを成功させる」ことです。したがって、怪しい引き出しを実行させずに、他の正当な引き出しだけを実行させる必要があります。
- 防衛策:
- CTFの回答コード (
Withdrawal.t.sol) を見ると、skip(l1Gateway.DELAY()+4 minutes);で時間を進めています。これは、引き出しが実行可能になるのを待っていることを意味します。 - そして、
l1Gateway.finalizeWithdrawalをループで呼び出しています。ここでのproofは空のbytes32[]です。これは、プレイヤーがオペレーター権限を持っているため、プルーフが不要であることを利用しています。 - 怪しい引き出しの阻止: CTFの解答コードでは、4つの引き出し要求とは別に、もう1つの引き出し(
WITHDRAWALS_NUM = WITHDRAWALS_AMOUNT + 1)をループで処理しています。この追加の引き出しが、怪しい引き出しとして処理され、かつそれを成功させる(あるいは、他の正当な引き出しに影響を与えないようにする)必要があります。 - 解答コードの
test_withdrawal関数内で、WITHDRAWALS_NUMがWITHDRAWALS_AMOUNT+1となっており、5つの引き出しを処理していることがわかります。これは、提供された4つのログに加えて、もう1つの「特殊な」引き出しを処理することを意味します。 - この5番目の引き出しは、
noncesFs[0] = uint256(type(uint256).max)やl2SenderFs[0]がplayerアドレスであることから、プレイヤー自身が開始した「正規の」引き出しに見えます。 - しかし、
TokenBridge.solのexecuteTokenWithdrawal関数では、l1Forwarder.getSender() == otherBridgeの場合にUnauthorized()エラーとなります。 L1Forwarder.solのforwardMessage関数では、msg.sender == address(gateway) && gateway.xSender() == l2Handlerの場合と、それ以外の場合で、successfulMessagesとfailedMessagesのチェックが異なります。- CTFの核心: CTFの解答コードでは、5つの
finalizeWithdrawalを呼び出していますが、そのうちの1つ(i=0の場合)がnoncesFs[0] = type(uint256).maxとなっており、これは怪しい引き出しの処理を示唆しています。そして、finalizedWithdrawalsのチェックで、4つの特定の葉(leaf)が最終化されていることを確認しています。 - つまり、攻撃者は、提供された4つの引き出しログをすべて「正常」に処理し、そのうち1つ(または複数)を「怪しい」と見せかけ、それを阻止しつつ、本来の4つの引き出しをすべて実行させる必要があります。
- CTFの回答コード (
解答コードの戦略:
- 遅延を待つ:
skip(l1Gateway.DELAY()+4 minutes);で、引き出しが実行可能になるまで待ちます。 - 5つの引き出しを処理:
WITHDRAWALS_NUM = WITHDRAWALS_AMOUNT + 1として、5つの引き出し要求を生成し、l1Gateway.finalizeWithdrawalを呼び出します。 - 「怪しい」引き出しの処理:
i=0のループでは、noncesFs[0] = type(uint256).maxとなっており、これが怪しい引き出しの処理に関連している可能性が高いです。receiverTBs[0]とreceiverTBs[4]は同じplayerアドレスですが、amountTBs[0]とamountTBs[4]は異なります。l2SendersFs[0]もplayerアドレスです。L1Forwarder.solのforwardMessage関数において、msg.senderがaddress(gateway)でない場合(つまり、オペレーターが直接呼び出す場合)、require(failedMessages[messageId])が実行されます。- CTFの解答コードでは、5つの引き出しをすべて
proofなしでfinalizeWithdrawalしています。これはオペレーター権限を利用しています。 - 怪しい引き出しは、実は5番目の引き出し(
i=0の場合)であり、その nonce がtype(uint256).maxに設定されていること、そしてl2SenderFs[0]がプレイヤー自身のアドレスであることから、プレイヤーが意図的に無効な状態を作り出そうとした引き出しです。 - しかし、
L1Gateway.finalizeWithdrawalはisOperatorがtrueならプルーフなしで実行できます。 - 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番目の引き出しは、ブリッジの残高を管理し、チャレンジの成功条件を満たすための追加の操作となります。