Appearance
Truster チャレンジ:フラッシュローンで100万DVTトークンを救出せよ!
Damn Vulnerable DeFi v4 へようこそ!今回のチャレンジは「Truster」です。このチャレンジでは、誰でも無料でフラッシュローンを利用できる新しいレンディングプールが登場します。プールには100万DVTトークンが用意されていますが、あなたは何も持っていません。
目標は、単一のトランザクションでプールから全ての資金を救出し、指定されたリカバリーアカウントに送金することです。
挑戦の概要
TrusterLenderPool.sol のコードを見ると、flashLoan 関数は非常にシンプルです。
- トークンの転送: 指定された
borrowerにamountだけトークンを転送します。 - ターゲットへのコール:
targetアドレスに対して、dataとして渡されたバイトコードを実行します。 - 残高の確認: トランザクション実行後、プールのアドレスのトークン残高が、トランザクション実行前の残高よりも少なくなっていないかを確認します。もし少なくなっていれば、
RepayFailedエラーを返します。
このロジックの脆弱性は、flashLoan 関数が nonReentrant 修飾子を持っているにも関わらず、プールがトークンを borrower に転送した後、target へのコールで、プール自身がトークンを返済するためのロジックが直接実装されていない 点にあります。
攻略の鍵:コールバックの悪用
flashLoan 関数の target.functionCall(data) の部分が、我々の攻撃ポイントとなります。この data は、任意のコードを実行するためのものです。
ここで、TrusterChallenge.t.sol の test_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);
}
}
このコントラクトは、以下のステップで問題を解決します。
approveの実行:TrusterLenderPoolのflashLoan関数を呼び出します。amountは0を指定します。これは、フラッシュローン自体は必要なく、あくまでコールバックの機能を利用するためです。borrowerはaddress(this)(つまりMyContract自身)を指定します。targetはaddress(target.token())、つまりDamnValuableTokenコントラクトのアドレスを指定します。dataとして、target.token().approve(address(this), amount)を実行するバイトコードを渡します。- これにより、
TrusterLenderPoolはDamnValuableTokenコントラクトに対してapproveを実行し、MyContractがプールからamountまでのトークンを引き出す権限を与えます。
transferFromによる資金移動:flashLoanのコールバックとしてapproveが実行された後、MyContractのコンストラクタは次にtarget.token().transferFrom(address(target), recovery, amount);を実行します。approveによって、MyContractはプール (address(target)) からamountのトークンを、recoveryアドレスに送金する権限を得ています。- この
transferFromによって、プールにあった100万DVTトークンが全てrecoveryアドレスに移動します。
なぜこれで成功するのか?
flashLoanのnonReentrant:flashLoan関数にはnonReentrant修飾子が付いていますが、これは同じトランザクション内でflashLoanが直接複数回呼ばれるのを防ぐだけです。MyContractがapproveを実行し、TrusterLenderPoolがtargetにコールバックする流れは、flashLoanが直接複数回呼ばれるわけではないため、この修飾子による制限を受けません。- 返済ロジックの欠如:
TrusterLenderPoolは、flashLoanでトークンを転送した後、targetへのコールバックが完了した後に、プール自身の残高が減っていないか (token.balanceOf(address(this)) < balanceBefore) をチェックします。しかし、MyContractはapproveを実行するだけで、プールにトークンを返済するロジックは実行しません。MyContractのコンストラクタの最後でtransferFromを実行するだけなので、プールからトークンは永久に失われます。 - 単一トランザクション:
MyContractのデプロイとflashLoanの実行、そしてtransferFromによる資金移動は、全て1つのトランザクション内で完結します。これにより、_isSolved関数の「Player executed more than one tx」というアサーションもクリアされます。
これで、あなたは単一のトランザクションで100万DVTトークンをプールから救出し、リカバリーアカウントに送金することができました!おめでとうございます!