Published on

Damn Vulnerable DeFi V3 - Unstoppable - Solution

Authors

Damn Vulnerable DeFi V3 - Unstoppable - Solution

Contracts

UnstoppableVault.sol

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

import "solmate/src/utils/FixedPointMathLib.sol";
import "solmate/src/utils/ReentrancyGuard.sol";
import { SafeTransferLib, ERC4626, ERC20 } from "solmate/src/mixins/ERC4626.sol";
import "solmate/src/auth/Owned.sol";
import { IERC3156FlashBorrower, IERC3156FlashLender } from "@openzeppelin/contracts/interfaces/IERC3156.sol";

/**
 * @title UnstoppableVault
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract UnstoppableVault is IERC3156FlashLender, ReentrancyGuard, Owned, ERC4626 {
    using SafeTransferLib for ERC20;
    using FixedPointMathLib for uint256;

    uint256 public constant FEE_FACTOR = 0.05 ether;
    uint64 public constant GRACE_PERIOD = 30 days;

    uint64 public immutable end = uint64(block.timestamp) + GRACE_PERIOD;

    address public feeRecipient;

    error InvalidAmount(uint256 amount);
    error InvalidBalance();
    error CallbackFailed();
    error UnsupportedCurrency();

    event FeeRecipientUpdated(address indexed newFeeRecipient);

    constructor(ERC20 _token, address _owner, address _feeRecipient)
        ERC4626(_token, "Oh Damn Valuable Token", "oDVT")
        Owned(_owner)
    {
        feeRecipient = _feeRecipient;
        emit FeeRecipientUpdated(_feeRecipient);
    }

    /**
     * @inheritdoc IERC3156FlashLender
     */
    function maxFlashLoan(address _token) public view returns (uint256) {
        if (address(asset) != _token)
            return 0;

        return totalAssets();
    }

    /**
     * @inheritdoc IERC3156FlashLender
     */
    function flashFee(address _token, uint256 _amount) public view returns (uint256 fee) {
        if (address(asset) != _token)
            revert UnsupportedCurrency();

        if (block.timestamp < end && _amount < maxFlashLoan(_token)) {
            return 0;
        } else {
            return _amount.mulWadUp(FEE_FACTOR);
        }
    }

    function setFeeRecipient(address _feeRecipient) external onlyOwner {
        if (_feeRecipient != address(this)) {
            feeRecipient = _feeRecipient;
            emit FeeRecipientUpdated(_feeRecipient);
        }
    }

    /**
     * @inheritdoc ERC4626
     */
    function totalAssets() public view override returns (uint256) {
        assembly { // better safe than sorry
            if eq(sload(0), 2) {
                mstore(0x00, 0xed3ba6a6)
                revert(0x1c, 0x04)
            }
        }
        return asset.balanceOf(address(this));
    }

    /**
     * @inheritdoc IERC3156FlashLender
     */
    function flashLoan(
        IERC3156FlashBorrower receiver,
        address _token,
        uint256 amount,
        bytes calldata data
    ) external returns (bool) {
        if (amount == 0) revert InvalidAmount(0); // fail early
        if (address(asset) != _token) revert UnsupportedCurrency(); // enforce ERC3156 requirement
        uint256 balanceBefore = totalAssets();
        if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement
        uint256 fee = flashFee(_token, amount);
        // transfer tokens out + execute callback on receiver
        ERC20(_token).safeTransfer(address(receiver), amount);
        // callback must return magic value, otherwise assume it failed
        if (receiver.onFlashLoan(msg.sender, address(asset), amount, fee, data) != keccak256("IERC3156FlashBorrower.onFlashLoan"))
            revert CallbackFailed();
        // pull amount + fee from receiver, then pay the fee to the recipient
        ERC20(_token).safeTransferFrom(address(receiver), address(this), amount + fee);
        ERC20(_token).safeTransfer(feeRecipient, fee);
        return true;
    }

    /**
     * @inheritdoc ERC4626
     */
    function beforeWithdraw(uint256 assets, uint256 shares) internal override nonReentrant {}

    /**
     * @inheritdoc ERC4626
     */
    function afterDeposit(uint256 assets, uint256 shares) internal override nonReentrant {}
}

ReceiverUnstoppable.sol

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

import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import "solmate/src/auth/Owned.sol";
import { UnstoppableVault, ERC20 } from "../unstoppable/UnstoppableVault.sol";

/**
 * @title ReceiverUnstoppable
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract ReceiverUnstoppable is Owned, IERC3156FlashBorrower {
    UnstoppableVault private immutable pool;

    error UnexpectedFlashLoan();

    constructor(address poolAddress) Owned(msg.sender) {
        pool = UnstoppableVault(poolAddress);
    }

    function onFlashLoan(
        address initiator,
        address token,
        uint256 amount,
        uint256 fee,
        bytes calldata
    ) external returns (bytes32) {
        if (initiator != address(this) || msg.sender != address(pool) || token != address(pool.asset()) || fee != 0)
            revert UnexpectedFlashLoan();

        ERC20(token).approve(address(pool), amount);

        return keccak256("IERC3156FlashBorrower.onFlashLoan");
    }

    function executeFlashLoan(uint256 amount) external onlyOwner {
        address asset = address(pool.asset());
        pool.flashLoan(
            this,
            asset,
            amount,
            bytes("")
        );
    }
}

Solution

Before attempting this challenge, I highly recommend familiarizing yourself with flash loans and token vaults.

Now, to get an idea of the starting conditions of this challenge, let's first take a closer look at the unstoppable.challenge.js file.

unstoppable.challenge.js

const { ethers } = require('hardhat')
const { expect } = require('chai')

describe('[Challenge] Unstoppable', function () {
  let deployer, player, someUser
  let token, vault, receiverContract

  const TOKENS_IN_VAULT = 1000000n * 10n ** 18n
  const INITIAL_PLAYER_TOKEN_BALANCE = 10n * 10n ** 18n

  before(async function () {
    /** SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE */

    ;[deployer, player, someUser] = await ethers.getSigners()

    token = await (await ethers.getContractFactory('DamnValuableToken', deployer)).deploy()
    vault = await (
      await ethers.getContractFactory('UnstoppableVault', deployer)
    ).deploy(
      token.address,
      deployer.address, // owner
      deployer.address // fee recipient
    )
    expect(await vault.asset()).to.eq(token.address)

    await token.approve(vault.address, TOKENS_IN_VAULT)
    await vault.deposit(TOKENS_IN_VAULT, deployer.address)

    expect(await token.balanceOf(vault.address)).to.eq(TOKENS_IN_VAULT)
    expect(await vault.totalAssets()).to.eq(TOKENS_IN_VAULT)
    expect(await vault.totalSupply()).to.eq(TOKENS_IN_VAULT)
    expect(await vault.maxFlashLoan(token.address)).to.eq(TOKENS_IN_VAULT)
    expect(await vault.flashFee(token.address, TOKENS_IN_VAULT - 1n)).to.eq(0)
    expect(await vault.flashFee(token.address, TOKENS_IN_VAULT)).to.eq(50000n * 10n ** 18n)

    await token.transfer(player.address, INITIAL_PLAYER_TOKEN_BALANCE)
    expect(await token.balanceOf(player.address)).to.eq(INITIAL_PLAYER_TOKEN_BALANCE)

    // Show it's possible for someUser to take out a flash loan
    receiverContract = await (
      await ethers.getContractFactory('ReceiverUnstoppable', someUser)
    ).deploy(vault.address)
    await receiverContract.executeFlashLoan(100n * 10n ** 18n)
  })

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

  after(async function () {
    /** SUCCESS CONDITIONS - NO NEED TO CHANGE ANYTHING HERE */

    // It is no longer possible to execute flash loans
    await expect(receiverContract.executeFlashLoan(100n * 10n ** 18n)).to.be.reverted
  })
})

As we can see from the above code, the before hook sets up the challenge as follows:

  1. The deployer (who has a DVT balance of type(uint256).max after deploying the DVT contract) first deposits 1,000,000 DVT via the vault's deposit function. This means that, after the deposit, the vault holds 1,000,000 DVT, and the deployer holds 1,000,000 shares - let's call them "S tokens". In particular, S now has a total supply of 1,000,000.
  2. After making the deposit, the deployer then sends 10 DVT to the player.

Our goal is to make the vault stop offering flash loans. In other words, we're looking for a way to DoS the flashLoan function.

To start, notice that flashLoan contains various revert statements. If we can somehow achieve that one of these reverts is triggered with every function call, then we'd complete the challenge.

Taking a closer look, we see that the lines

uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement

are indeed vulnerable!

To see that, let's first take a closer look at convertToShares:

function convertToShares(uint256 assets) public view virtual returns (uint256) {
    uint256 supply = totalSupply; // Saves an extra SLOAD if totalSupply is non-zero.

    return supply == 0 ? assets : assets.mulDivDown(supply, totalAssets());
}

First, notice that we don't have the case supply == 0 after performing our initial setup since the total supply of S tokens is 1,000,000 after the deployer made the initial deposit. So, roughly speaking, the function will perform the following calculation:

assets * supply / totalAssets()

Now, in the flashLoan function, the asset parameter is set to totalSupply, i.e., the total supply of S tokens. Ignoring decimals, the above calculation will, therefore, yield 1,000,000 * 1,000,000 / 1,000,000 since supply (i.e., the total supply of S) and totalAssets() (i.e., the vault's DVT balance) are equal to 1,000,000.

So far, nothing bad happened. However, notice that we can donate DVT to the vault without calling deposit!

If we, e.g., donate 1 DVT, we'll end up with balanceBefore being 1,000,001 while convertToShares(totalSupply) being 1,000,000 * 1,000,000 / 1,000,001. In other words, by simply transferring 1 DVT to the vault (using DVT's transfer, not the vault's deposit!), we can achieve that convertToShares(totalSupply) != balanceBefore will be true and flashLoan will revert.

As a conclusion, we simply need to add the following line to unstoppable.challenge.js:

it('Execution', async function () {
  /** CODE YOUR SOLUTION HERE */
  await token.connect(player).transfer(vault.address, 1n * 10n ** 18n)
})