BriVault

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

Division by Zero in withdraw() Permanently Locks All Funds When No Winners Exist

Root + Impact

Description

  • After the tournament ends, the owner sets the winning team via setWinner(). Users who bet on the winning team should be able to withdraw their proportional share of the vault assets based on their share balance relative to total winner shares.

  • The protocol lacks validation to ensure the winning team actually has participants. When the owner (either maliciously or accidentally) sets a team with zero participants as the winner, _getWinnerShares() iterates through all users but finds no one bet on that team, resulting in totalWinnerShares = 0. Subsequently, every call to withdraw() attempts to execute Math.mulDiv(shares, vaultAsset, 0), which reverts due to division by zero. Since the winnerSet modifier prevents withdrawals until a winner is declared, and WinnerAlreadySet() prevents resetting the winner, all funds become permanently locked with no recovery mechanism.

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(); // Can result in totalWinnerShares = 0
_setFinallizedVaultBalance();
emit WinnerSet (winner);
return winner;
}
function _getWinnerShares () internal returns (uint256) {
for (uint256 i = 0; i < usersAddress.length; ++i){
address user = usersAddress[i];
@> totalWinnerShares += userSharesToCountry[user][winnerCountryId];
}
return totalWinnerShares; // Returns 0 if no one bet on winnerCountryId
}
function withdraw() external winnerSet {
if (block.timestamp < eventEndDate) {
revert eventNotEnded();
}
if (
keccak256(abi.encodePacked(userToCountry[msg.sender])) !=
keccak256(abi.encodePacked(winner))
) {
revert didNotWin();
}
uint256 shares = balanceOf(msg.sender);
uint256 vaultAsset = finalizedVaultAsset;
@> uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares); // Division by zero
_burn(msg.sender, shares);
IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
emit Withdraw(msg.sender, assetToWithdraw);
}

Risk

Likelihood:

  • This occurs when the owner makes an administrative error by selecting the wrong team index, which is reasonably likely given that tournament results must be manually entered and there are 48 possible teams to choose from.

  • The vulnerability can also occur in legitimate tournament scenarios where all participants converge on a small subset of popular teams (e.g., favorites), leaving the actual winning team with zero bets when an underdog wins unexpectedly.

Impact:

  • All vault funds become permanently and irrecoverably locked regardless of which team users bet on, since the WinnerAlreadySet() error prevents correcting the mistake and no emergency withdrawal function exists.

  • Users who correctly predicted the actual tournament outcome still lose their entire deposit because the protocol permanently locks all funds when the wrong winner is administratively set, creating complete loss of principal for all 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 WithdrawalLockZeroDivisionPoC is Test {
BriVault public vault;
BriTechToken public token;
address owner = makeAddr("owner");
address user1 = makeAddr("user1");
address user2 = makeAddr("user2");
address user3 = makeAddr("user3");
address feeAddress = makeAddr("feeAddress");
uint256 constant DEPOSIT_AMOUNT = 1000e18;
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(user1, 10000e18);
token.transfer(user2, 10000e18);
token.transfer(user3, 10000e18);
vm.stopPrank();
}
function test_WithdrawalLockIfNoWinningParticipants() public {
console.log("=== Withdrawal Lock via Division by Zero PoC ===");
vm.startPrank(user1);
uint256 fee = (DEPOSIT_AMOUNT * 150) / 10000;
token.approve(address(vault), DEPOSIT_AMOUNT + fee);
vault.deposit(DEPOSIT_AMOUNT, user1);
vault.joinEvent(0);
console.log("User1 joined Team0");
vm.stopPrank();
vm.startPrank(user2);
token.approve(address(vault), DEPOSIT_AMOUNT + fee);
vault.deposit(DEPOSIT_AMOUNT, user2);
vault.joinEvent(0);
console.log("User2 joined Team0");
vm.stopPrank();
vm.startPrank(user3);
token.approve(address(vault), DEPOSIT_AMOUNT + fee);
vault.deposit(DEPOSIT_AMOUNT, user3);
vault.joinEvent(0);
console.log("User3 joined Team0");
vm.stopPrank();
console.log("\n--- ALL USERS BET ON TEAM 0 ---");
console.log("Total participants:", vault.numberOfParticipants());
console.log("Winner Country ID will be set to: 1 (Team1 with 0 participants)");
vm.warp(block.timestamp + 8 days);
console.log("\n--- OWNER SETS TEAM 1 AS WINNER ---");
vm.prank(owner);
vault.setWinner(1);
console.log("Winner set to Team1");
console.log("totalWinnerShares:", vault.totalWinnerShares());
console.log("\n--- USER1 ATTEMPTS WITHDRAWAL ---");
uint256 user1BalanceBefore = token.balanceOf(user1);
vm.startPrank(user1);
vm.expectRevert();
vault.withdraw();
vm.stopPrank();
uint256 user1BalanceAfter = token.balanceOf(user1);
console.log("User1 balance before withdrawal:", user1BalanceBefore / 1e18, "tokens");
console.log("User1 balance after withdrawal attempt:", user1BalanceAfter / 1e18, "tokens");
console.log("Withdrawal FAILED - Division by zero in Math.mulDiv");
console.log("\n--- FUND LOCK VERIFICATION ---");
console.log("User1 shares:", vault.balanceOf(user1));
console.log("Vault asset balance:", token.balanceOf(address(vault)) / 1e18, "tokens");
console.log("Winner shares (zero):", vault.totalWinnerShares());
console.log("\nResult: ALL FUNDS PERMANENTLY LOCKED");
console.log("Fund Loss: ~2850 tokens (3 users * 1000 - fees)");
assertEq(vault.totalWinnerShares(), 0, "totalWinnerShares should be 0");
}
}

RESULT:

forge test --match-contract WithdrawalLockZeroDivisionPoC -vv
[⠑] Compiling...
No files changed, compilation skipped
Ran 1 test for test/WithdrawalLockZeroDivisionPoC.t.sol:WithdrawalLockZeroDivisionPoC
[PASS] test_WithdrawalLockIfNoWinningParticipants() (gas: 824604)
Logs:
=== Withdrawal Lock via Division by Zero PoC ===
User1 joined Team0
User2 joined Team0
User3 joined Team0
--- ALL USERS BET ON TEAM 0 ---
Total participants: 3
Winner Country ID will be set to: 1 (Team1 with 0 participants)
--- OWNER SETS TEAM 1 AS WINNER ---
Winner set to Team1
totalWinnerShares: 0
--- USER1 ATTEMPTS WITHDRAWAL ---
User1 balance before withdrawal: 9000 tokens
User1 balance after withdrawal attempt: 9000 tokens
Withdrawal FAILED - Division by zero in Math.mulDiv
--- FUND LOCK VERIFICATION ---
User1 shares: 985000000000000000000
Vault asset balance: 2955 tokens
Winner shares (zero): 0
Result: ALL FUNDS PERMANENTLY LOCKED
Fund Loss: ~2850 tokens (3 users * 1000 - fees)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.15ms (294.76µs CPU time)
Ran 1 test suite in 3.83ms (1.15ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

owner cannot set a winner 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();
+
+ // Validate that the winning team has participants
+ require(totalWinnerShares > 0, "No participants bet on winning team");
+
+ _setWinner = true;
_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!