Skip to content
On this page

Truster チャレンジ:フラッシュローンで100万DVTトークンを救出せよ!

Damn Vulnerable DeFi v4 へようこそ!今回のチャレンジは「Truster」です。このチャレンジでは、誰でも無料でフラッシュローンを利用できる新しいレンディングプールが登場します。プールには100万DVTトークンが用意されていますが、あなたは何も持っていません。

目標は、単一のトランザクションでプールから全ての資金を救出し、指定されたリカバリーアカウントに送金することです。

挑戦の概要

TrusterLenderPool.sol のコードを見ると、flashLoan 関数は非常にシンプルです。

  1. トークンの転送: 指定された borroweramount だけトークンを転送します。
  2. ターゲットへのコール: target アドレスに対して、data として渡されたバイトコードを実行します。
  3. 残高の確認: トランザクション実行後、プールのアドレスのトークン残高が、トランザクション実行前の残高よりも少なくなっていないかを確認します。もし少なくなっていれば、RepayFailed エラーを返します。

このロジックの脆弱性は、flashLoan 関数が nonReentrant 修飾子を持っているにも関わらず、プールがトークンを borrower に転送した後、target へのコールで、プール自身がトークンを返済するためのロジックが直接実装されていない 点にあります。

攻略の鍵:コールバックの悪用

flashLoan 関数の target.functionCall(data) の部分が、我々の攻撃ポイントとなります。この data は、任意のコードを実行するためのものです。

ここで、TrusterChallenge.t.soltest_truster 関数を見てみましょう。

solidity
function test_truster() public checkSolvedByPlayer {
    new MyContract(address(pool), recovery, TOKENS_IN_POOL);
}

これは、MyContract という新しいコントラクトをデプロイし、そのコンストラクタに pool のアドレス、recovery アドレス、そして TOKENS_IN_POOL (100万DVT) を渡していることを示しています。

この MyContract が、まさに我々のソリューションとなります。

ソリューション:MyContract の秘密

MyContract のコードは以下のようになります。

solidity
contract MyContract {
    TrusterLenderPool private immutable target;

    constructor(address _target, address recovery, uint256 amount) {
        target = TrusterLenderPool(_target);

        // 1. プールに、このMyContractがプールからトークンを引き出すための承認を与える
        bytes memory data = abi.encodeWithSignature("approve(address,uint256)", address(this), amount);
        bool res = target.flashLoan(0, address(this), address(target.token()), data);
        require(res, "Fail to flashLoan!");

        // 2. プールからリカバリーアカウントへトークンを転送する
        target.token().transferFrom(address(target), recovery, amount);
    }
}

このコントラクトは、以下のステップで問題を解決します。

  1. approve の実行:

    • TrusterLenderPoolflashLoan 関数を呼び出します。
    • amount0 を指定します。これは、フラッシュローン自体は必要なく、あくまでコールバックの機能を利用するためです。
    • borroweraddress(this)(つまり MyContract 自身)を指定します。
    • targetaddress(target.token())、つまり DamnValuableToken コントラクトのアドレスを指定します。
    • data として、target.token().approve(address(this), amount) を実行するバイトコードを渡します。
    • これにより、TrusterLenderPoolDamnValuableToken コントラクトに対して approve を実行し、MyContract がプールから amount までのトークンを引き出す権限を与えます。
  2. transferFrom による資金移動:

    • flashLoan のコールバックとして approve が実行された後、MyContract のコンストラクタは次に target.token().transferFrom(address(target), recovery, amount); を実行します。
    • approve によって、MyContract はプール (address(target)) から amount のトークンを、recovery アドレスに送金する権限を得ています。
    • この transferFrom によって、プールにあった100万DVTトークンが全て recovery アドレスに移動します。

なぜこれで成功するのか?

  • flashLoannonReentrant: flashLoan 関数には nonReentrant 修飾子が付いていますが、これは同じトランザクション内で flashLoan が直接複数回呼ばれるのを防ぐだけです。MyContractapprove を実行し、TrusterLenderPooltarget にコールバックする流れは、flashLoan が直接複数回呼ばれるわけではないため、この修飾子による制限を受けません。
  • 返済ロジックの欠如: TrusterLenderPool は、flashLoan でトークンを転送した後、target へのコールバックが完了した後に、プール自身の残高が減っていないか (token.balanceOf(address(this)) < balanceBefore) をチェックします。しかし、MyContractapprove を実行するだけで、プールにトークンを返済するロジックは実行しません。MyContract のコンストラクタの最後で transferFrom を実行するだけなので、プールからトークンは永久に失われます。
  • 単一トランザクション: MyContract のデプロイと flashLoan の実行、そして transferFrom による資金移動は、全て1つのトランザクション内で完結します。これにより、_isSolved 関数の「Player executed more than one tx」というアサーションもクリアされます。

これで、あなたは単一のトランザクションで100万DVTトークンをプールから救出し、リカバリーアカウントに送金することができました!おめでとうございます!

Built with AiAda