
Damn Vulnerable Defi V4 Unstoppable
12/5/2024
Overview
There’s a tokenized vault with a million DVT tokens deposited. It’s offering flash loans for free, until the grace period ends.
To catch any bugs before going 100% permissionless, the developers decided to run a live beta in testnet. There’s a monitoring contract to check liveness of the flashloan feature.
Starting with 10 DVT tokens in balance, show that it’s possible to halt the vault. It must stop offering flash loans.
Research
In the context of smart contracts, stopping a flash loan can be achieved by triggering an error or a revert and that’s what we’re going to do since that’s the condition to show that it possible to halt the vault.
The question is… where can we find that trigger to halt the flashloan? Let’s focus on this contract UnstoppableVault.sol
and look closely on the flashLoan
function since this is the one that performs the flashloan.
Since we’re looking ways to trigger that condition, we must focus only on what makes things conditionally revert the flashLoan
function. If you notice, there are multiple if
statements here checking conditions, like whether the amount is zero, etc. Do you think there’s anything that could potentially cause issues with these checks?
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;
}
Now, if you read more about ERC4626, ERC20 and you got a little bit of common accounting knowledge, you will notice that the third check is sketchy. Basically this check just indicates that if convertToShares(totalSupply)
and balanceBefore
are not equal, it will revert.
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();
Let’s dissect the code first.
convertToShares
- Returns the amount of
shares
that would be exchanged by the vault for the amount ofassets
provided.
- Returns the amount of
totalSupply
- Returns the total number of unredeemed vault shares in circulation.
balanceBefore
- Actual token balance in the contract and this is from the built-in function of ERC4626
totalAssets
where it states “This function returns the total amount of underlying assets held by the vault.”
- Actual token balance in the contract and this is from the built-in function of ERC4626
InvalidBalance
- If it does not match the actual balance, the transaction will be reverted.
Now that we have the overview of these, let’s focus on what’s important, and that’s understanding how the accounting works. Starting with convertToShares
, let’s examine the code closely to see what it actually does, it handles two scenarios. If the supply is zero then it will return the asset amount and if the supply is not zero (which is our case) it will calculate the shares using mulDivDown()
which we can assume that it is a safe math and the formula here is (assets * supply) / totalAssets()
.
function convertToShares(uint256 assets) public view virtual returns (uint256) {
uint256 supply = totalSupply; // Saves an extra SLOAD if totalSupply is non-zero.
return supply == 0 ? assets : assets.mulDivDown(supply, totalAssets());
}
The third check assumes a perfect 1:1 relationship between shares and assets. We can think ways to trigger the revert and it is simple, disrupt the 1:1 ratio, but how we can achieve that?
If we use the deposit
function of ERC4626 it will mint the receiver address amount of shares causing the condition not to revert because totalAssets()
and convertToShares(totalSupply)
values will be always equal.
But if you read more about ERC4626, it is an extension of ERC20 and we have the transfer
function which we can use to directly transfer assets in the vault, and this results not minting a shares thus convertToShares(totalSupply)
is not updated but totalAssets()
increases.
Exploit
Now that we know the flaw of the contract, the next step is to execute the attack which is really simple.
function test_unstoppable() public checkSolvedByPlayer {
require(token.transfer(address(vault), 1));
}
Here’s what it does, and also you can use console.log to observe the changes of before and after of totalAssets
.
- Directly transfer to the vault
- Manually send
DVT
tokens to the vault contract using the ERC20transfer
function.
- Manually send
- Cause the flashloan to fail
- The external transfer inflates
totalAssets
without minting additional shares (oDVT
). - The condition in the
flashLoan
function fails due to the mismatch betweenconvertToShares(totalSupply)
andtotalAssets
.
- The external transfer inflates
- Result
- The
flashLoan
function becomes broken, which triggers the revertInvalidBalance
- The
Key Takeaways
- The attack relies on exploiting a mismatch between two accounting systems (
totalAssets
andconvertToShares
). - By inflating
totalAssets
it disables theflashLoan
functionality without interacting directly with its logic.