Damn Vulnerable DeFi Unstoppable Solution and Explain

Damn Vulnerable DeFi Unstoppable Solution and Explain

Welcome to the first challenge of Damn Vulnerable DeFi, called Unstoppable.

⚠️
If you haven't tried solving it yet, beware of spoilers ahead!

This post is for those who have given up or want to check their solution.

Understanding the Challenge

First, let's review the challenge. You can find the details here.

The challenge involves a tokenized vault with a million DVT tokens deposited. It offers free flash loans until a grace period ends. To pass the challenge, you need to make the vault stop offering flash loans. You start with 10 DVT tokens in your balance.

Diving into the Code

// 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("")
        );
    }
}

The UnstoppableVault contract and ReceiverUnstoppable contract are at the heart of this challenge. The test involves calling executeFlashLoan from the ReceiverUnstoppable contract to initiate a flash loan. Our goal is to figure out how to halt this contract.

Attack Strategy

Let's start with the function that gets called first:

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

This function doesn't seem vulnerable as it's just calling flashLoan with the pool's asset and is protected by the onlyOwner modifier.

Next, let's look at the flashLoan function in UnstoppableVault.sol:

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;
}

There are several points where a revert could happen. Let's focus on the following condition

if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();

The balanceBefore variable represents the total assets in the vault, and totalSupply is the total supply of tokens. The convertToShares function calculates how many shares the total supply can be exchanged for in the vault. So, the condition for revert is:

if (shares that can be exchanged for the token amount != vault's total asset amount) revert

As a hacker, you already have 10 DVT tokens, and the logic of the UnstoppableVault contract is functioning correctly. Therefore, simply increasing the contract's holdings will break this logic.

This is because you're supposed to use the deposit function to deposit into the vault. By directly transferring tokens to the UnstoppableVault contract, you skip the calculation inside the contract after the deposit. This results in a mismatch:

Metric Before Transfer After Transfer
Shares that can be exchanged 1000000000000000000000000 1000000000000000000000000
Vault's total asset amount 999990000099999000009999 1000010000000000000000000

By directly sending tokens to the vault without using the deposit function, you can ensure a revert will occur.

Solution

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

And there you have it! Surprisingly, it only takes one line of code to stop the contract, making this a very straightforward challenge.

If you couldn't figure it out, it's a good idea to study in detail why this happens! The concepts of ERC20 and ERC4626 were introduced in this challenge, so a bit of knowledge about these two is necessary.

Now that you've come closer to being a hacker, let's move on to the next challenge!