BriVault

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

setWinner() Allows Zero-Participant Winners, Causing Division by Zero in withdraw()

Root + Impact

Description

  • BriVault's setWinner() function lacks validation to prevent declaring winners for teams with zero participants. When a winner is set for an empty team, _getWinnerShares() returns totalWinnerShares = 0. Any subsequent withdraw() call attempts Math.mulDiv(shares, vaultAsset, 0), causing division by zero and permanently locking all vault funds.

  • Root cause: BriVault's setWinner() function lacks validation to prevent declaring winners for teams with zero participants.

function setWinner(uint256 countryIndex) public onlyOwner returns (string memory) {
if (block.timestamp <= eventEndDate) {
revert eventNotEnded();
}
require(countryIndex < teams.length, "Invalid country index");
if (_setWinner) {
revert WinnerAlreadySet();
}
winnerCountryId = countryIndex;
winner = teams[countryIndex];
_setWinner = true;
@> _getWinnerShares(); // This can set totalWinnerShares = 0 if no participants!
@> // NO VALIDATION that totalWinnerShares > 0
_setFinallizedVaultBalance();
emit WinnerSet (winner);
return winner;
}

Risk

Likelihood: Medium

  • Owner can intentionally or mistakenly select unpopular teams with zero participants.

Impact: High

  • All vault funds become permanently inaccessible through division by zero

  • Winner selection becomes irreversible once setWinner() is called

  • Protocol fails completely with no emergency recovery functions

  • Owner gains ability to rug all participants by selecting zero-participant teams

Proof of Concept

The POC demonstrates how setWinner() allows declaring winners for teams with zero participants, causing totalWinnerShares = 0 and subsequent division by zero in withdraw() that permanently locks all vault funds. It shows the owner setting a winner for an empty team, then any withdrawal attempt causing a Math.mulDiv(shares, vaultAsset, 0) division by zero. The test verifies that all vault funds become permanently inaccessible once the invalid winner is declared.

// 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: setWinner() Allows Zero-Participant Winners, Causing Division by Zero in withdraw()
* Impact: Permanent fund lock of all vault assets
*
* Root Cause: setWinner() lacks validation for totalWinnerShares > 0, allowing owner to
* declare winners for teams with zero participants, causing Math.mulDiv(shares, vaultAsset, 0)
* to revert with division by zero in withdraw().
*/
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 ZeroWinnersDivisionByZeroPoC is Test {
BriVault vault;
MockERC20 asset;
address owner = makeAddr("owner");
address user1 = makeAddr("user1");
address user2 = makeAddr("user2");
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 - include one that will have zero participants
string[3] memory countries = ["Brazil", "Argentina", "France"];
vault.setCountry(countries);
vm.stopPrank();
// Setup user balances
asset.mint(user1, 1000 ether);
asset.mint(user2, 1000 ether);
// Approve vault to spend tokens
vm.startPrank(user1);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(user2);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
}
/**
* CRITICAL VULNERABILITY: setWinner() allows zero-participant winners, causing permanent fund lock
*
* Attack Flow:
* 1. Tournament has multiple teams
* 2. Users participate in popular teams only (one team has 0 participants)
* 3. Owner sets winner for the empty team
* 4. _getWinnerShares() returns totalWinnerShares = 0
* 5. All winner withdrawal attempts fail with division by zero
* 6. All vault funds become permanently locked
*/
function test_ZeroWinnersDivisionByZero_FundLock() public {
console.log("=== ZERO WINNERS DIVISION BY ZERO - FUND LOCK ===");
// Phase 1: Setup tournament with uneven participation
vm.startPrank(user1);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, user1);
vault.joinEvent(0); // Brazil - popular team
vm.stopPrank();
vm.startPrank(user2);
vault.deposit(200 ether, user2);
vault.joinEvent(1); // Argentina - also popular
vm.stopPrank();
// France (team index 2) has ZERO participants
console.log("Brazil participants:", vault.getWinnerSharesForTeam(0) / 198 ether);
console.log("Argentina participants:", vault.getWinnerSharesForTeam(1) / 198 ether);
console.log("France participants:", vault.getWinnerSharesForTeam(2) / 198 ether);
console.log("France has ZERO participants - perfect for attack!");
// Phase 2: Tournament ends, owner selects France as winner
vm.warp(EVENT_END + 1 hours);
console.log("Tournament ended, owner sets France (empty team) as winner...");
vm.startPrank(owner);
vault.setWinner(2); // France - zero participants!
vm.stopPrank();
console.log("Winner set to France (team with zero participants)");
console.log("totalWinnerShares:", vault.totalWinnerShares());
console.log("CRITICAL: totalWinnerShares = 0 - division by zero imminent!");
// Phase 3: Attempt withdrawal - DIVISION BY ZERO
console.log("Attempting withdrawal from winner...");
vm.startPrank(user1);
uint256 vaultBalanceBefore = asset.balanceOf(address(vault));
console.log("Vault balance before withdrawal:", vaultBalanceBefore);
vm.expectRevert(); // Math.mulDiv reverts on division by zero
vault.withdraw();
vm.stopPrank();
uint256 vaultBalanceAfter = asset.balanceOf(address(vault));
console.log("Vault balance after failed withdrawal:", vaultBalanceAfter);
// Phase 4: Verify permanent fund lock
assertEq(vault.totalWinnerShares(), 0, "totalWinnerShares is zero");
assertEq(vaultBalanceBefore, vaultBalanceAfter, "Funds remain locked in vault");
assertGt(vaultBalanceAfter, 0, "All funds permanently inaccessible");
console.log("=== CATASTROPHIC FUND LOCK CONFIRMED ===");
console.log("All vault funds permanently locked due to division by zero");
console.log("No recovery mechanism exists");
console.log("Protocol completely broken");
}
/**
* Demonstrate the root cause: setWinner() allows zero-participant winners
*/
function test_RootCause_ZeroWinnerValidationMissing() public {
console.log("=== ROOT CAUSE ANALYSIS ===");
// Setup: only one team has participants
vm.startPrank(user1);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, user1);
vault.joinEvent(0); // Only Brazil has participants
vm.stopPrank();
// Verify team participation
assertGt(vault.getWinnerSharesForTeam(0), 0, "Brazil has participants");
assertEq(vault.getWinnerSharesForTeam(1), 0, "Argentina has zero participants");
assertEq(vault.getWinnerSharesForTeam(2), 0, "France has zero participants");
// Tournament ends
vm.warp(EVENT_END + 1 hours);
// Owner can set ANY team as winner - even empty ones!
console.log("Owner sets Argentina (empty team) as winner...");
vm.startPrank(owner);
vault.setWinner(1); // Argentina - zero participants
vm.stopPrank();
console.log("PROBLEM: setWinner() succeeded despite zero participants!");
console.log("totalWinnerShares:", vault.totalWinnerShares());
console.log("This will cause division by zero in all withdrawals");
// Verify the dangerous state
assertEq(vault.totalWinnerShares(), 0, "totalWinnerShares = 0 - guaranteed division by zero");
assertEq(vault.winnerSet(), true, "Winner is set but invalid");
}
/**
* Show how this affects legitimate winners
*/
function test_ImpactOnLegitimateWinners() public {
console.log("=== IMPACT ON LEGITIMATE WINNERS ===");
// Setup: Brazil has participants, France is empty
vm.startPrank(user1);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, user1);
vault.joinEvent(0); // Brazil
vm.stopPrank();
vm.startPrank(user2);
vault.deposit(200 ether, user2);
vault.joinEvent(0); // Also Brazil
vm.stopPrank();
// Tournament ends
vm.warp(EVENT_END + 1 hours);
// Owner maliciously sets France (empty) as winner
vm.startPrank(owner);
vault.setWinner(2); // France - empty team
vm.stopPrank();
console.log("Owner set France (empty) as winner");
console.log("Brazil participants lose their rightful winnings");
// Both legitimate Brazil participants cannot withdraw
vm.startPrank(user1);
vm.expectRevert(); // Division by zero
vault.withdraw();
vm.stopPrank();
vm.startPrank(user2);
vm.expectRevert(); // Division by zero
vault.withdraw();
vm.stopPrank();
console.log("Both legitimate winners permanently locked out of their funds");
console.log("Owner successfully ruggod all participants");
}
/**
* Demonstrate the mathematical division by zero
*/
function test_MathematicalDivisionByZero() public {
console.log("=== MATHEMATICAL DIVISION BY ZERO DEMONSTRATION ===");
// Setup with participants
vm.startPrank(user1);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, user1);
vault.joinEvent(0);
vm.stopPrank();
// Set empty team as winner
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(2); // Empty team
vm.stopPrank();
// Show the math that causes division by zero
uint256 userShares = vault.balanceOf(user1); // 198 ether
uint256 vaultAsset = vault.finalizedVaultAsset(); // ~198 ether
uint256 totalWinnerShares = vault.totalWinnerShares(); // 0
console.log("Mathematical calculation in withdraw():");
console.log("userShares:", userShares);
console.log("vaultAsset:", vaultAsset);
console.log("totalWinnerShares:", totalWinnerShares);
console.log("Formula: Math.mulDiv(", userShares, ",", vaultAsset, ",", totalWinnerShares, ")");
// This would be: Math.mulDiv(198 ether, 198 ether, 0) = DIVISION BY ZERO
console.log("Result: DIVISION BY ZERO - transaction reverts");
// Verify the values
assertGt(userShares, 0, "User has shares");
assertGt(vaultAsset, 0, "Vault has assets");
assertEq(totalWinnerShares, 0, "totalWinnerShares is zero - guaranteed failure");
}
/**
* Show emergency recovery is impossible
*/
function test_NoRecoveryMechanism() public {
console.log("=== NO RECOVERY MECHANISM ===");
// Setup and trigger the bug
vm.startPrank(user1);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, user1);
vault.joinEvent(0);
vm.stopPrank();
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(2); // Empty team
vm.stopPrank();
// Verify funds are locked
uint256 lockedFunds = asset.balanceOf(address(vault));
assertGt(lockedFunds, 0, "Funds are locked in vault");
// Try to "fix" by setting a different winner - too late!
console.log("Attempting to reset winner...");
vm.startPrank(owner);
vm.expectRevert("WinnerAlreadySet"); // Cannot change winner
vault.setWinner(0); // Try to set Brazil
vm.stopPrank();
console.log("Cannot reset winner - WinnerAlreadySet error");
console.log("No emergency withdrawal functions exist");
console.log("Funds permanently locked forever");
// Verify permanent lock
vm.startPrank(user1);
vm.expectRevert(); // Still division by zero
vault.withdraw();
vm.stopPrank();
assertEq(asset.balanceOf(address(vault)), lockedFunds, "Funds remain permanently locked");
}
}

Recommended Mitigation

Add validation in setWinner() to ensure winners have participants.

- function setWinner(uint256 countryIndex) public onlyOwner returns (string memory) {
- if (block.timestamp <= eventEndDate) {
- revert eventNotEnded();
- }
-
- require(countryIndex < teams.length, "Invalid country index");
-
- if (_setWinner) {
- revert WinnerAlreadySet();
- }
-
- winnerCountryId = countryIndex;
- winner = teams[countryIndex];
-
- _setWinner = true;
-
- _getWinnerShares(); // This can set totalWinnerShares = 0 if no participants!
- // NO VALIDATION that totalWinnerShares > 0
-
- _setFinallizedVaultBalance();
-
- emit WinnerSet (winner);
-
- return winner;
- }
+ function setWinner(uint256 countryIndex) public onlyOwner returns (string memory) {
+ if (block.timestamp <= eventEndDate) {
+ revert eventNotEnded();
+ }
+
+ require(countryIndex < teams.length, "Invalid country index");
+
+ if (_setWinner) {
+ revert WinnerAlreadySet();
+ }
+
+ winnerCountryId = countryIndex;
+ winner = teams[countryIndex];
+
+ _setWinner = true;
+
+ _getWinnerShares();
+ require(totalWinnerShares > 0, "Cannot set winner with zero participants"); // ✅ ADD THIS
+
+ _setFinallizedVaultBalance();
+
+ emit WinnerSet (winner);
+
+ return winner;
+ }
Updates

Appeal created

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

Division by Zero in Withdraw Function When No Winners Bet on Winning Team

When no one bet on the winning team, making totalWinnerShares = 0, causing division by zero in withdraw and preventing any withdrawals.

Support

FAQs

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

Give us feedback!