Introduction
The ERC4626 Tokenized Vault Standard has become a cornerstone of DeFi, providing a standardized interface for yield-bearing vaults. However, this powerful standard comes with a critical vulnerability that has cost protocols millions: the First Depositor Attack (also known as the Inflation Attack).
In this article, I'll walk you through the attack mechanism, show you how to detect it during security audits, and provide practical detection patterns that you can apply to your own reviews.
Understanding the Attack Mechanism
The Core Vulnerability
ERC4626 vaults operate on a simple principle: users deposit assets and receive shares in return. The exchange rate between shares and assets is calculated as:
shares = (assets * totalSupply) / totalAssets
The vulnerability emerges from integer division rounding in Solidity. When totalAssets is significantly larger than assets * totalSupply, the result rounds down to zero—meaning the depositor receives nothing.
Attack Scenario Step-by-Step
Let's trace through a complete attack:
Initial State: A new vault is deployed with 0 assets and 0 shares.
Step 1 - Attacker's Minimal Deposit:
// Attacker deposits 1 wei
attacker.deposit(1);
// totalAssets = 1, totalSupply = 1, attacker owns 1 share
Step 2 - Attacker's Donation:
// Attacker directly transfers tokens to the vault
underlyingToken.transfer(address(vault), 100_000e18);
// totalAssets = 100_000e18 + 1, totalSupply = 1
Step 3 - Victim's Deposit Gets Stolen:
// Victim tries to deposit 50,000 tokens
victim.deposit(50_000e18);
// shares = (50_000e18 * 1) / (100_000e18 + 1) = 0
// Victim receives 0 shares but loses 50,000 tokens!
Step 4 - Attacker Profits:
// Attacker redeems their 1 share
attacker.redeem(1);
// Receives all 150,000 tokens (original 100k + victim's 50k)
// Profit: 50,000 tokens minus gas costs
Mathematical Analysis
For an attacker to completely steal a victim's deposit u:
- Let
a₀= attacker's initial deposit - Let
a₁= attacker's donation amount
The victim receives zero shares when:
u × a₀ / (a₀ + a₁) < 1
Simplifying with a₀ = 1:
u < 1 + a₁
This means an attacker only needs to donate slightly more than the target deposit amount to steal it entirely.
Detection Methodology
Pattern 1: Missing Virtual Offset Defense
The most robust defense is implementing virtual shares and assets. Check for their absence:
// VULNERABLE: No virtual offset
function _convertToShares(uint256 assets) internal view returns (uint256) {
uint256 supply = totalSupply();
return supply == 0
? assets
: assets.mulDiv(supply, totalAssets());
}
// SECURE: With virtual offset
function _convertToShares(uint256 assets) internal view returns (uint256) {
return assets.mulDiv(
totalSupply() + 10 ** _decimalsOffset(), // Virtual shares
totalAssets() + 1, // Virtual assets
Math.Rounding.Floor
);
}
Detection Query:
Look for convertToShares or share calculation functions that:
- Return
assetsdirectly whentotalSupply == 0 - Don't add virtual offsets to numerator/denominator
Pattern 2: Unprotected First Deposit
Check if the contract has first-deposit protections:
// VULNERABLE: No minimum share requirement
function deposit(uint256 assets, address receiver) public returns (uint256 shares) {
shares = convertToShares(assets);
// No validation!
_deposit(msg.sender, receiver, assets, shares);
}
// SECURE: Minimum shares check
function deposit(uint256 assets, address receiver) public returns (uint256 shares) {
shares = convertToShares(assets);
require(shares >= MINIMUM_SHARES, "Shares too low");
_deposit(msg.sender, receiver, assets, shares);
}
Pattern 3: Using balanceOf for Total Assets
Vaults using direct balanceOf for asset calculation are vulnerable to donation attacks:
// VULNERABLE: Uses balanceOf directly
function totalAssets() public view returns (uint256) {
return asset.balanceOf(address(this));
}
// SECURE: Uses internal accounting
function totalAssets() public view returns (uint256) {
return _totalManagedAssets; // Updated only through deposit/withdraw
}
Pattern 4: Missing Dead Shares Mechanism
Check for Uniswap V2-style dead shares protection:
// SECURE: Burns minimum liquidity on first deposit
function deposit(uint256 assets) public returns (uint256 shares) {
shares = convertToShares(assets);
if (totalSupply() == 0) {
uint256 MINIMUM_LIQUIDITY = 1000;
_mint(address(0), MINIMUM_LIQUIDITY); // Dead shares
shares -= MINIMUM_LIQUIDITY;
}
_mint(msg.sender, shares);
}
Automated Detection Script
Here's a Python pattern matcher you can use in your audits:
import re
VULNERABLE_PATTERNS = [
# Pattern 1: Direct asset/supply ratio without offset
r"assets\s*\*\s*totalSupply\(\)\s*/\s*totalAssets\(\)",
# Pattern 2: Empty vault returns assets directly
r"totalSupply\(\)\s*==\s*0\s*\?\s*assets",
# Pattern 3: balanceOf used in totalAssets
r"function\s+totalAssets.*balanceOf\s*\(\s*address\s*\(\s*this\s*\)",
# Pattern 4: No minimum share validation
r"shares\s*=\s*convertToShares.*\n(?!.*require.*shares)",
]
def detect_first_depositor_vulnerability(solidity_code: str) -> list:
vulnerabilities = []
for i, pattern in enumerate(VULNERABLE_PATTERNS):
if re.search(pattern, solidity_code, re.MULTILINE | re.DOTALL):
vulnerabilities.append(f"Pattern {i+1} detected")
return vulnerabilities
Real-World Detection Checklist
When auditing ERC4626 implementations, verify:
Pre-Deployment Checks
- [ ] Virtual shares/assets offset implemented
- [ ] Minimum share threshold on deposits
- [ ] Dead shares mechanism for first deposit
- [ ] Internal asset tracking (not just
balanceOf)
Invariant Tests
function invariant_noZeroShareDeposit() public {
uint256 shares = vault.deposit(1e18, address(this));
assert(shares > 0); // Should never mint 0 shares for valid deposit
}
function invariant_exchangeRateStability() public {
uint256 rateBefore = vault.convertToAssets(1e18);
// Simulate donation
asset.transfer(address(vault), 1000e18);
uint256 rateAfter = vault.convertToAssets(1e18);
// Rate shouldn't change dramatically from donations
assert(rateAfter <= rateBefore * 2);
}
Foundry Fuzz Test
function testFuzz_firstDepositorAttack(
uint256 attackerDeposit,
uint256 donation,
uint256 victimDeposit
) public {
attackerDeposit = bound(attackerDeposit, 1, 1e6);
donation = bound(donation, victimDeposit, victimDeposit * 10);
victimDeposit = bound(victimDeposit, 1e18, 1e24);
// Attacker's initial deposit
vault.deposit(attackerDeposit, attacker);
// Attacker's donation
asset.transfer(address(vault), donation);
// Victim's deposit
uint256 victimShares = vault.deposit(victimDeposit, victim);
// Victim should receive meaningful shares
assertGt(victimShares, 0, "Victim received 0 shares!");
// Victim's shares should be worth close to their deposit
uint256 victimValue = vault.convertToAssets(victimShares);
assertGt(victimValue, victimDeposit * 90 / 100, "Victim lost too much value!");
}
Recommended Mitigations
1. OpenZeppelin's Virtual Offset (Recommended)
abstract contract ERC4626 is ERC20 {
uint8 private immutable _underlyingDecimals;
function _decimalsOffset() internal view virtual returns (uint8) {
return 0; // Override to increase security
}
function _convertToShares(uint256 assets, Math.Rounding rounding)
internal view returns (uint256)
{
return assets.mulDiv(
totalSupply() + 10 ** _decimalsOffset(),
totalAssets() + 1,
rounding
);
}
}
2. Initial Deposit by Protocol
constructor() {
// Protocol makes initial deposit to prevent attack
asset.transferFrom(msg.sender, address(this), INITIAL_DEPOSIT);
_mint(address(0xdead), INITIAL_SHARES);
}
3. Minimum Deposit Requirement
uint256 public constant MIN_DEPOSIT = 1e15; // 0.001 tokens
function deposit(uint256 assets, address receiver) public returns (uint256) {
require(assets >= MIN_DEPOSIT, "Deposit too small");
// ... rest of deposit logic
}
Conclusion
The ERC4626 First Depositor Attack is a subtle but devastating vulnerability that exploits integer division behavior. As security researchers, we must:
- Recognize the pattern: Look for unprotected share calculations
- Test aggressively: Use fuzz testing to catch edge cases
- Recommend robust fixes: Virtual offsets are the gold standard
The key insight is that any vault using shares = assets * supply / totalAssets without protection is vulnerable. By understanding this attack deeply, we can protect DeFi users from losing their funds.
Found this helpful? Follow me for more Web3 security content. Have questions? Drop a comment below!
Top comments (0)