Damn Vulnerable Defi : Unstoppable
In this article, we’ll dive deep into the “Unstoppable” challenge from Damn Vulnerable DeFi v4, exploring a subtle but devastating vulnerability in a tokenized vault contract. We’ll examine Ethereum token standards, investigate how a single token transfer can permanently break a financial protocol, and unravel the mechanics of this attack.
A great challenge for beginners as it allows you to see different importants concepts
The Challenge
The “Unstoppable” challenge presents us with a scenario where a vault has been deployed with a million DVT tokens. The vault offers flash loans to users but includes a vulnerability that allows an attacker to permanently disable this functionality.
The challenge description states:
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.
Our objective is to find a way to break the flash loan feature, rendering the vault “unstoppable.”
Understanding the Relevant Standards
Before diving into the vulnerability, let’s understand the two standards that form the foundation of this vault contract.
ERC4626: The Tokenized Vault Standard
ERC4626 is an extension of the ERC20 token standard that standardizes tokenized vaults. The key concept behind ERC4626 is simple but powerful:
- Users deposit an underlying token (the “asset”) into the vault
- The vault issues “shares” (another token) that represent the user’s proportional ownership of the vault
- These shares can be later redeemed for the underlying assets, potentially with accumulated yield
The standard defines several key functions:
deposit()
: Deposit assets and receive shareswithdraw()
: Withdraw assets by burning sharesredeem()
: Redeem shares for assetstotalAssets()
: Return the total amount of underlying assets in the vaultconvertToShares()
: Convert a given amount of assets to an equivalent amount of sharesconvertToAssets()
: Convert a given amount of shares to an equivalent amount of assets
The primary benefit of ERC4626 is standardization, it creates a unified interface for yield-generating vaults across different protocols, making them more composable and user-friendly.
ERC3156: The Flash Loan Standard
ERC3156 standardizes flash loans, a DeFi primitive that allows borrowing assets without collateral, provided they are returned within the same transaction.
The standard defines two main interfaces:
IERC3156FlashLender
: Implemented by contracts that offer flash loansIERC3156FlashBorrower
: Implemented by contracts that want to borrow via flash loans
Key functions in the lender interface include:
maxFlashLoan()
: Returns the maximum amount available for a flash loanflashFee()
: Calculates the fee for a flash loanflashLoan()
: Executes the flash loan
The standard ensures that flash loan providers and consumers can interact seamlessly, regardless of which protocols they belong to.
The Vulnerable Contract
The UnstoppableVault
contract combines both the ERC4626 and ERC3156 standards. It’s a tokenized vault that also offers flash loans of its underlying asset.
Here’s the flash loan function where the vulnerability resides:
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;
}
The vulnerability lies in this specific check:
uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement
And here’s the totalAssets()
function it calls:
function totalAssets() public view override nonReadReentrant returns (uint256) {
return asset.balanceOf(address(this));
}
This check is verifying that the vault’s accounting is consistent, the total shares (represented by totalSupply
) converted to assets should equal the actual balance of assets in the vault.
The Inheritance Structure
To fully understand the vulnerability, we need to trace where totalSupply
comes from. The contract doesn’t explicitly define this function, so it must be inherited.
The inheritance chain looks like this:
UnstoppableVault
↳ ERC4626 (from Solmate)
↳ ERC20 (from Solmate)
The totalSupply()
function comes from the ERC20 standard, which is the foundation for ERC4626. In a typical ERC20 implementation, totalSupply()
returns the total number of tokens that have been minted minus the number that have been burned.
In the context of our ERC4626 vault:
- When users deposit assets, the vault mints shares, increasing
totalSupply
- When users withdraw assets, the vault burns shares, decreasing
totalSupply
The crucial relationship is that totalSupply
should always correspond to the number of shares that have been issued in exchange for assets.
The Exploit
The vulnerability exploits a fundamental assumption in the vault’s design: that tokens can only enter the vault through the official deposit functions.
Here’s the exploit, remarkably simple yet devastatingly effective:
function test_unstoppable() public checkSolvedByPlayer {
token.transfer(address(vault), 1);
}
What this does is transfer a token directly to the vault’s address, bypassing the standard deposit mechanism. Let’s break down why this breaks the vault:
- The attacker transfers 1 token directly to the vault
- This increases
totalAssets()
by 1, as this function simply returns the vault’s token balance - However, since no shares were minted,
totalSupply
remains unchanged - Now the check
convertToShares(totalSupply) != balanceBefore
will always evaluate to true - This causes all future flash loan calls to revert with
InvalidBalance()
The vault is now permanently broken - it cannot offer flash loans anymore!
Side note : 1 ether vs 1 Unit Confusion
A common confusion when examining this exploit revolves around this line:
token.transfer(address(vault), 1 ether);
Some might wonder: Are we transferring Ether (ETH) here? The answer is no. In Solidity, 1 ether
is simply a way to write the number 10^18 (1 followed by 18 zeros). It’s a convenience notation similar to how we might write 1 million
.
For most ERC20 tokens that use 18 decimals (as the DVT token does in this challenge), 1 ether
corresponds to exactly 1 whole token. So:
token.transfer(address(vault), 1);
transfers 1 base unit of the token, which is 0.000000000000000001 DVTtoken.transfer(address(vault), 1 ether);
transfers 10^18 base units, which is 1 whole DVT
Both approaches work to exploit the vulnerability, as they both create a discrepancy between totalAssets()
and convertToShares(totalSupply)
. The difference is merely the size of the discrepancy.
What Happens Under the Hood in token.transfer()
When we call token.transfer(address(vault), 1)
, several operations occur:
- The ERC20 token contract checks if the sender has enough tokens
- It decreases the sender’s balance by the specified amount (1 in this case)
- It increases the recipient’s balance by the same amount
- It emits a
Transfer
event recording this operation
Importantly, the recipient (in this case, the vault) does not need to do anything special to receive these tokens. There’s no receive()
function required as there would be for receiving Ether. The token contract simply updates its internal accounting to record that the vault now owns more tokens.
This is why direct token transfers can bypass the vault’s deposit mechanism, they update the token balances but don’t trigger any of the vault’s internal accounting functions.
How to Fix the Vulnerability
Several approaches could be used to fix this vulnerability:
- Use an internal counter for assets: Maintain a separate variable to track deposited assets instead of using the actual token balance.
contract UnstoppableVault {
uint256 private _internalAssetCount;
function totalAssets() public view override returns (uint256) {
return _internalAssetCount;
}
function deposit(uint256 assets, address receiver) public override returns (uint256) {
// Existing logic
_internalAssetCount += assets;
return shares;
}
function withdraw(uint256 assets, address receiver, address owner) public override returns (uint256) {
// Existing logic
_internalAssetCount -= assets;
return shares;
}
}
- Modify the check to allow for donations: Change the validation to allow the actual balance to be greater than (but never less than) the expected balance.
if (convertToShares(totalSupply) > balanceBefore) revert InvalidBalance();
- Add a reconciliation function: Create a function that can reconcile the vault’s state when “orphaned” tokens are detected.
Conclusion
The “Unstoppable” challenge from Damn Vulnerable DeFi beautifully illustrates how even simple design assumptions can lead to critical vulnerabilities. A single direct token transfer, an operation that seems harmless can permanently break the functionality of an entire protocol.
This vulnerability also showcases the importance of understanding the deeper mechanics of Ethereum standards and their interactions. The combination of ERC4626 and ERC3156 created a tension that, when exploited, rendered the vault unstoppable.
As DeFi protocols grow more complex and standards continue to evolve, keeping these fundamental security principles in mind becomes increasingly important for developers and auditors alike.