Appearance
Unstoppable No More: How a Subtle Invariant Brought Down a DeFi Vault
In the fast-paced world of decentralized finance, robustness and security are paramount. Developers strive to build "unstoppable" systems, often relying on intricate logic and battle-tested standards. Yet, as the "Unstoppable" challenge from Damn Vulnerable DeFi brilliantly illustrates, even a seemingly minor oversight can bring an entire system to a grinding halt.
This particular challenge presented us with a tokenized vault holding a million DVT tokens, generously offering free flash loans during an initial grace period. To ensure its stability before going fully permissionless, a dedicated UnstoppableMonitor contract was deployed. This monitor's sole purpose: to periodically check if flash loans were functioning correctly. If a check failed, the monitor would automatically pause the vault and transfer its ownership for review – an emergency brake designed to prevent catastrophe. Our mission, starting with just 10 DVT tokens, was to trigger this emergency brake.
The Unstoppable Vault: Anatomy of a seemingly robust system
The heart of the challenge was UnstoppableVault.sol, an ERC4626-compliant vault that also implemented the IERC3156FlashLender standard for flash loans. ERC4626 vaults manage both underlying assets (like DVT tokens) and shares representing ownership of those assets. Crucially, the vault had a strict internal check within its flashLoan function:
solidity
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();
Here, balanceBefore is derived from totalAssets(), which simply returns asset.balanceOf(address(this)) – the actual DVT token balance held by the vault contract. totalSupply, on the other hand, represents the total amount of shares minted to depositors. For an ERC4626 vault to operate correctly, especially when managing deposits and withdrawals, the amount of assets reported by totalAssets() should always be perfectly in sync with the value represented by totalSupply (shares) when converted back to assets. This is an invariant that should hold true under normal operation.
The Hidden Vulnerability: Breaking the Invariant
The key to solving "Unstoppable" lies in understanding how to break this critical invariant without directly interacting with the vault's deposit/withdrawal logic. The standard ways to interact with an ERC4626 vault are through its deposit() and mint() functions (to provide assets and get shares) or withdraw() and redeem() (to get assets back by burning shares). All these functions are designed to keep totalAssets() and convertToShares(totalSupply) in perfect harmony.
However, smart contracts can receive tokens in ways other than through their designated deposit functions. An attacker can simply call transfer() on the underlying DVT token contract, sending tokens directly to the UnstoppableVault's address.
This seemingly innocuous action has a profound effect:
totalAssets()Increases: When DVT tokens are directly transferred to the vault contract, itsbalanceOf(address(this))for the DVT token naturally increases. Thus,totalAssets()will report a higher value.totalSupplyRemains Unchanged: Since nodeposit()ormint()function was called, no new shares were minted to reflect this additional asset. Therefore,totalSupply(the total number of shares) remains exactly the same.
The direct result? The invariant check within the flashLoan function – convertToShares(totalSupply) != balanceBefore – will now evaluate to true. The converted shares of the existing totalSupply will no longer match the vault's actual balanceBefore (its totalAssets()).
The Attack: A Simple Transfer
With our initial 10 DVT tokens, the attack becomes surprisingly straightforward:
solidity
token.transfer(address(vault), INITIAL_PLAYER_TOKEN_BALANCE);
By transferring just 10 DVT directly to the vault, we successfully manipulate the vault's internal state. The totalAssets() value now reflects 1,000,010 DVT, while convertToShares(totalSupply) (which is 1,000,000 DVT converted from 1,000,000 shares at a 1:1 ratio) remains 1,000,000 DVT.
The Monitor's Dilemma and Our Victory
With the invariant broken, any subsequent attempt to perform a flash loan will cause the UnstoppableVault's flashLoan function to revert with an InvalidBalance() error.
Enter the UnstoppableMonitor. When it performs its periodic checkFlashLoan() call, it attempts to take a flash loan from the vault. This attempt will now fail, triggering the catch block in checkFlashLoan. As per its design, the monitor then executes its emergency protocol:
vault.setPause(true);– The vault is paused.vault.transferOwnership(owner);– Ownership is transferred back to thedeployerfor inspection.
We've successfully halted the "Unstoppable" vault, demonstrating a critical flaw with a mere 10 DVT tokens.
Lessons Learned: The Fragility of Invariants
The "Unstoppable" challenge serves as a potent reminder of several key principles in smart contract security:
- Invariant Checks are Vital, but Fragile: Invariants are powerful tools for maintaining contract integrity. However, they must account for all possible ways a contract's state can change, including unintended or direct interactions that bypass standard functions.
- Direct Token Transfers are a Risk: Contracts not explicitly designed to handle direct ERC20 token transfers can be vulnerable. While some contracts explicitly revert such transfers or have mechanisms to sweep accidentally sent tokens, others – like this vault – might suffer state corruption.
- The Power of Monitoring: While this attack succeeded, the
UnstoppableMonitorultimately functioned as intended, detecting the issue and initiating an emergency shutdown. Robust monitoring and emergency protocols are essential safety nets in DeFi.
In the pursuit of truly unstoppable and permissionless systems, developers must consider every edge case, every potential interaction, and every subtle invariant. As this challenge shows, sometimes, the simplest action can reveal the most profound vulnerabilities.