DEV Community

ohmygod
ohmygod

Posted on

How to Detect ERC4626 First Depositor Attack: A Security Researcher's Guide

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 2 - Attacker's Donation:

// Attacker directly transfers tokens to the vault
underlyingToken.transfer(address(vault), 100_000e18);
// totalAssets = 100_000e18 + 1, totalSupply = 1
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Simplifying with a₀ = 1:

u < 1 + a₁
Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

Detection Query:
Look for convertToShares or share calculation functions that:

  • Return assets directly when totalSupply == 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);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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!");
}
Enter fullscreen mode Exit fullscreen mode

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
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

The ERC4626 First Depositor Attack is a subtle but devastating vulnerability that exploits integer division behavior. As security researchers, we must:

  1. Recognize the pattern: Look for unprotected share calculations
  2. Test aggressively: Use fuzz testing to catch edge cases
  3. 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!

References

Top comments (0)