Cover photo

Damn Vulnerable DeFi #1: Unstoppable Solution

Introduction

Hey there, I’m creating a series of my solutions to the Damn Vulnerable DeFi challenges here starting with challenge #1 “Unstoppable”. This challenge looks at a flash loan implementation that contains a vulnerability. But first, what is a flash loan and how is it implemented?

Flash Loans and ERC3156

Flash Loans were first introduced by Aave as part of Aave V3 which you can learn more about here. They provide a way for users to borrow crypto without needing to put down any collateral first. The catch is that the entire loan must be paid back within the same transaction or the entire transaction will fail. This provides a way for users to make high leverage trades without any upfront capital.

ERC3156 was developed as a standardized interface for interacting with Flash Loan contracts. The standard consists of two contracts, the lender and borrower (IERC3156FlashLender and IERC3156FlashBorrower). You can read more about the standard here. At a high level, these interfaces provide the following functions

interface IERC3156FlashLender {

    /**
     * @dev The amount of currency available to be lent.
     * @param token The loan currency.
     * @return The amount of `token` that can be borrowed.
     */
    function maxFlashLoan(
        address token
    ) external view returns (uint256);

    /**
     * @dev The fee to be charged for a given loan.
     * @param token The loan currency.
     * @param amount The amount of tokens lent.
     * @return The amount of `token` to be charged for the loan, on top of the returned principal.
     */
    function flashFee(
        address token,
        uint256 amount
    ) external view returns (uint256);

    /**
     * @dev Initiate a flash loan.
     * @param receiver The receiver of the tokens in the loan, and the receiver of the callback.
     * @param token The loan currency.
     * @param amount The amount of tokens lent.
     * @param data Arbitrary data structure, intended to contain user-defined parameters.
     */
    function flashLoan(
        IERC3156FlashBorrower receiver,
        address token,
        uint256 amount,
        bytes calldata data
    ) external returns (bool);
}
interface IERC3156FlashBorrower {

    /**
     * @dev Receive a flash loan.
     * @param initiator The initiator of the loan.
     * @param token The loan currency.
     * @param amount The amount of tokens lent.
     * @param fee The additional amount of tokens to repay.
     * @param data Arbitrary data structure, intended to contain user-defined parameters.
     * @return The keccak256 hash of "ERC3156FlashBorrower.onFlashLoan"
     */
    function onFlashLoan(
        address initiator,
        address token,
        uint256 amount,
        uint256 fee,
        bytes calldata data
    ) external returns (bytes32);
}

The borrower contract can inherit IERC3156FlashBorrower by implementing the onFlashLoan contract. After this, the borrower can call the flashLoan function with the amount and token requested from the lender contract. On the lender side, flashLoan is implemented to send those tokens to the receiver, call the onFlashLoan callback function and then pull the tokens plus a fee back from the borrower contract. The code execution flow looks like this:

Flash Loan execution flow
Flash Loan execution flow

The borrower can implement any logic that they want within the onFlashLoan function, but the transaction will only be successful if they approve the loaned tokens plus a fee to the lender at the end. The next thing to understand about this challenge is ERC4626 vaults

Vaults and ERC4626

ERC4626 was developed to standardized tokenized yield bearing vaults. Simply put, this vault allows users to hold a share of some underlying asset that is controlled in a vault. For example, consider a vault holding 100USDC. Alice could hold 69% of the vault and Bob could hold 20% of the vault. This gives Alice access to 69USDC from the vault and Bob 20USDC from the vault. This is a simple example, but the idea can easily be extended to more complex vaults such as Yearn, which generate yield on the assets in the vault. The yield can be distributed to users based on how much stake they have.

Contract overview

Alright, with that background out of the way, we can take a look at the vulnerable contract. At a high level, this contract is an ERC4626 that supports lending using ERC3156. There is some grace period logic that allows users to lend funds without paying any fees within the first 30 days.

The maxFlashLoan function returns 0 if the asset is not supported and returns the totalAssets in the vault otherwise. The flashFee function has the logic for returning 0 if within the grace period (and the user is not attempting to borrow all the funds in the vault) or 5% of the borrowed amount otherwise. setFeeRecipient is gated to allowing only the contract owner to call it and sets a new recipient for all fees.

The logic in totalAssets looks a bit scary, so I’ll break it down

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

First, we check if the value in the first storage slot is 0. To find this storage slot, we need to check the first line of the contract

contract UnstoppableVault is IERC3156FlashLender, ReentrancyGuard, Owned, ERC4626 {

Storage is ordered by the contracts that are inherited first by the order that they are inherited. Since IERC3156FlashLender has no storage, we check ReentrancyGuard. The first storage item here is the locked flag which ensures that reentrancy does not happen. If the value is 2 (the function has been entered), then this will revert. To be honest, I’m not sure why this check is done since none of the calls that use totalAssets has the nonReentrant modifier. Secondly this could be implemented by using the nonReentrant modifier directly on totalAssets and probably use less gas on the deployment but 🤷.

Finally, we get to the meat of this contract - the flashLoan function. In here, we check a bunch of conditions transfer the requested funds to the caller, call the callback and get the funds back plus a fee.

The hack

The exploit we are looking for is a way to block all users from being able to use the vault to make flash loans. Looking closely at the code in flashLoan there is this line:

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

This struck out to me because there is an assumption that the total number of tokens in the vault will always be equal to the total number of tokens owned by the vault. If we can somehow make the vault receive assets (update totalAssets) without updating the totalSupply, then this contract will become bricked and will always revert until this was somehow resolved. Digging into the ERC4626 and ERC20, we see that totalSupply is only updated during a mint or burn event. The mint is called by the deployer who has 100% of the shares in the vault.

However, transferring tokens does not affect totalSupply. Thus, we can simply transfer some tokens from our user to the vault and all further loans will revert.

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

For more details, we can console.log the balanceBefore and convertToShares(totalSupply) values after doing our token transfer. We see the following:

totalSupply 1000000000000000000000000
convertToShares(totalSupply) 999999000000999999000000
balanceBefore 1000001000000000000000000

Since the balance has gone up, but the total supply of shares in the vault remains the same, convertToShares returns a value smaller than balanceBefore.

Resolution

An exploit like this can be resolved by blocking transfers of the token into the vault directly. These direct transfers break the internal accounting of ERC4626.

Credits

Credit to Jason Dent for the cover photo

https://unsplash.com/photos/3wPJxh-piRw?utm_source=unsplash&utm_medium=referral&utm_content=creditShareLink