Unstoppable

This is the first from a series of blog posts in which I would document my Damn Vulnerable DeFi journey. Hope you find it helpful.

Goal

In this first challenge we are presented with a tokenized vault. The vault has 1 000 000 DVT tokens deposited. We (as a player) start with a balance of 10 DVT tokens. The vault offers free flash loans for a grace period of 30 days. Our goal is to somehow break the vault so it stops offering flash loans.

Prerequisites

This challenge assumes some knowledge on ERC-3156: Flash Loans and ERC-4626: Tokenized Vault. The smart contracts in this challenge also use the OpenZeppelin and Solmate libraries. These are popular open-source frameworks which help engineers build secure and reliable smart contracts.

If you are not familiar with these standards and libraries, I would recommend that you get acquainted with them at least on a basic level before diving in.

Rundown

Let’s first explore the contracts in this challenge before diving into an in-depth analysis and coming up with any attack vectors. There are 2 contracts - UnstoppableVault and ReceiverUnstoppable. Let’s start with the latter since it’s the simpler of the two.

ReceiverUnstoppable

This contract is a minimal implementation of a flash loan borrower. Nothing interesting is really going on here. The contract implements OpenZeppelin’s IERC3156FlashBorrower interface. The onFlashLoan callback function performs a set of validations and approves the return of the borrowed funds as per ERC-3156.

It also has an external function called executeFlashLoan(amount) which is pretty self-explanatory. It’s restricted to the contract owner.

UnstoppableVault

This is the contract that our attack should target.

contract UnstoppableVault is IERC3156FlashLender, ReentrancyGuard, Owned, ERC4626 {
   ...
}

From the very beginning of the contract definition, we see that UnstoppableVault extends Solmate’s ReentrancyGuard which provides reentrancy protection for functions marked as nonReentrant. It also extends Solmate’s Owner which enables the contract to have an owner and allows certain functions to be restricted only to the owner via the onlyOwner modifier.

The contract is also a tokenized vault (ERC4626). When a user deposits DVT tokens, the vault gives them oDVT tokens in lieu.

We already know from the challenge goal that UnstoppableVault is a flash loan lender as well. It implements OpenZeppelin’s IERC3156FlashLender. Upon contract creation, a grace period of 30 days is configured. During this period the flash loan fee is 0%. There is an exception to this rule though - if a user tries to borrow the maximum allowed flash loan, then the fee is 5%. Once the grace period is over, the fee becomes 5% for all flash loans:

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

The vault only offers flash loans for the underlying asset and thus maxFlashLoan(address) returns totalAssets() if the DVT token address is passed as an argument. Otherwise it returns 0 as per the ERC-3156 spec (which indicates an unsupported flash loan token):

function maxFlashLoan(address _token) public view returns (uint256) {
    if (address(asset) != _token)
        return 0;

    return totalAssets();
}

The vault has a feeRecipient state variable which as the name implies receives all incurred flash loan fees. The feeRecipent can only be updated by the contract owner via the setFeeRecipient(address) function.

The totalAssets() function itself is a bit interesting as it contains some logic written in Yul before returning the total amount of assets at the end:

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

I don’t have much experience with Yul but with some googling it was relatively easy to figure out what the code does.

TL;DR: it performs a reentrancy check; if the check fails, the contract reverts with a Reentrant() error.

Detailed explanation of the Yul code:

  1. sload(0) loads data from storage slot #0 onto the stack. In our case, this is the locked storage variable inherited from ReentrancyGuard
  2. it checks if the value of locked is equal to 2. As per the ReentrancyGuard implementation this indicates the mutex is in a locked state
  3. if the above check is true (i.e. reentrancy is detected) then mstore(0x00, 0xed3ba6a6) stores 0xed3ba6a6 at memory offset 0x00
  4. revert(0x1c, 0x04) reverts the transaction with a Reentrant() error. revert() halts the contract execution with the error located in memory starting from offset 0x1c to offset 0x1c + 0x04 (which is 0x1f). This is the data mstore previously stored into memory - 0xed3ba6a6. It is the 4-byte signature of the Reentrant() error (you may want check it yourself at 4byte.directory). At this point, you might be slightly confused and wondering why mstore stores it at offset 0x00 but we query it at offset 0x1c. Well, the reason is that the EVM memory layout uses 32-byte words. It also uses big-endian order of bytes. In this case, mstore stores 4 bytes of data. This means the data only occupies the last 4 bytes of the 32 bytes word. The leading bytes are padded with zeroes.

memory

Last but not least - the flashLoan() function. On a high level, it is implemented in a pretty standard way. It contains some input validations and checks. What’s interesting is that it also contains an invariant which makes sure the number of vault tokens is equal to the number of underlying asset tokens:

// ...
uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();
// ...

Below I would explore the ideas that I came up with on how this challenge could be solved.

Idea #1: Drain all funds

My first idea was that if we could somehow drain all the assets from the vault, it would no longer be able to offer flash loans. A possible attack vector would be a reentrancy attack. After some digging through the code it turned out this would not be possible since the Solmate’s ERC-4626 implementation is (expectedly) impeccable and follows the checks-effects-interactions pattern which prevents reentrancy. What’s more, the beforeWithdraw and afterDeposit hooks are secured with a nonReentrant modifier. These hooks are used in all functions which update the token and asset balances (deposit, mint, withdraw, redeem).

Idea #2: Break some of the checks in flashLoan

function flashLoan(
    IERC3156FlashBorrower receiver,
    address _token,
    uint256 amount,
    bytes calldata data
) external returns (bool) {
    if (amount == 0) revert InvalidAmount(0);
    if (address(asset) != _token) revert UnsupportedCurrency();
    
    uint256 balanceBefore = totalAssets();
    if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();
    
    uint256 fee = flashFee(_token, amount);
    ERC20(_token).safeTransfer(address(receiver), amount);
    
    if (receiver.onFlashLoan(msg.sender, address(asset), amount, fee, data) != keccak256("IERC3156FlashBorrower.onFlashLoan"))
        revert CallbackFailed();
    
    ERC20(_token).safeTransferFrom(address(receiver), address(this), amount + fee);
    ERC20(_token).safeTransfer(feeRecipient, fee);
    return true;
}

The flashLoan function contains a couple of checks. If we manage to somehow break any of them, we would be able to stop the vault from offering flash loans. After some code inspection it turns out that three of them are input data validations:

  • Requested loan amount must be a non-zero value;
  • Loans can only be requested for the vault’s underlying asset tokens. The underlying asset token is configured during contract creation (in ERC4626’s constructor) so we cannot really change it;
  • receiver.onFlashLoan() must return keccak256("IERC3156FlashBorrower.onFlashLoan") as per the ERC-3156 spec. Technically speaking, this is not exactly an input validation but receiver is passed a function param;

We have no control over any of these when someone else calls the contract so not much we could do here.

Idea 3: Break the assets == tokens invariant

As previously mentioned, the flashLoan function contains an invariant which ensures that the # of vault tokens must be equal to the # of underlying asset tokens. Thus, if we are able to break this invariant, we would achieve our goal.

One possibility is to try to use some combination of ERC4625’s balance impacting functions. Let’s take, for example, the withdraw function. It takes as an argument a number of assets tokens a user would like to withdraw. As a result, the function burns the corresponding number of vault tokens to maintain the invariant. Let’s see how the withdraw function works:

function withdraw(
    uint256 assets,
    address receiver,
    address owner
) public virtual returns (uint256 shares) {
    shares = previewWithdraw(assets); // No need to check for rounding error, previewWithdraw rounds up.

    if (msg.sender != owner) {
        uint256 allowed = allowance[owner][msg.sender];

        if (allowed != type(uint256).max) allowance[owner][msg.sender] = allowed - shares;
    }

    beforeWithdraw(assets, shares);
    _burn(owner, shares);
    emit Withdraw(msg.sender, receiver, owner, assets, shares);
    asset.safeTransfer(receiver, assets);
}

We could see the function uses previewWithdraw to calculate the number of shares to be burnt. Here is the implementation of previewWithdraw:

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

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

The number of shares to burn is calculated via assets.mulDivUp(supply, totalAssets()). mulDivUp() is a function which comes with the FixedPointMathLib library and it calculates the following expression by rounding the result up: $$ \frac{assets \times supply}{totalAssets} $$ Let’s dive deeper into the implementation:

function mulDivUp(
    uint256 x,
    uint256 y,
    uint256 denominator
) internal pure returns (uint256 z) {
    assembly {
        // Store x * y in z for now.
        z := mul(x, y)

        // Equivalent to require(denominator != 0 && (x == 0 || (x * y) / x == y))
        if iszero(and(iszero(iszero(denominator)), or(iszero(x), eq(div(z, x), y)))) {
            revert(0, 0)
        }

        // First, divide z - 1 by the denominator and add 1.
        // We allow z - 1 to underflow if z is 0, because we multiply the
        // end result by 0 if z is zero, ensuring we return 0 if z is zero.
        z := mul(iszero(iszero(z)), add(div(sub(z, 1), denominator), 1))
    }
}

Mathematically speaking, this implementation calculates the amount of tokens to burn using the following formula: $$ tokensToBurn = \left[ \frac{(assetsToWithdraw \times totalTokens) -1}{totalAssets}+1 \right] \times \begin{cases} 1,& \text{if } assetsToWithdraw \times totalTokens \not = 0\newline 0, & \text{otherwise} \end{cases} $$

We are not interested in the case where $assetsToWithdraw \times totalTokens = 0$, thus we could simplify the formula like that: $$ tokensToBurn = \frac{(assetsToWithdraw \times totalTokens) -1}{totalAssets}+1 $$

Unfortunately, I was not able to find any value for $assetsToWithdraw$ that would result in $tokensToBurn \not = assetsToWithdraw$. Thus, idea did not prove to be fruitful.

Breakthrough idea

After some further inspection of the code, I realized that for one of the invariant components - totalAssets, the contract relies entirely on an external contract. The totalAssets() function calculates the number of asset tokens in the vault via asset.balanceOf(address(this)). In other words, the vault token queries the DVT ERC-20 for its own balance. This means that if we simply transfer some of our DVT tokens to the vault address via the DVT ERC-20, we would change the number of its of asset tokens without changing the number of its vault tokens. This would break the invariant in the flashLoan() function and cause the vault stop offering flash loans.

Here’s is how we solve the challenge at unstoppable.challenge.js:

it('Execution', async function () {
    /** CODE YOUR SOLUTION HERE */
    console.log("[Before] Vault assets: %s DVT",      ethers.utils.formatEther(await vault.totalAssets()));
    console.log("[Before] Vault tokens: %s oDVT", ethers.utils.formatEther(await vault.totalSupply()));

    await token.connect(player).transfer(vault.address, ethers.utils.parseEther("2.0"));

    console.log("[After] Vault assets: %s DVT", ethers.utils.formatEther(await vault.totalAssets()));
    console.log("[After] Vault tokens: %s oDVT", ethers.utils.formatEther(await vault.totalSupply()));
});

Now if we run yarn run unstoppable we would see we have successfully solved the challenge.

Outro

I hope you found this write-up useful. Since this is my first CTF write-up, any feedback is more than welcome. If you feel something is missing, incorrect or the wording is clunky, please reach out to me on Twitter or Discord.