The POC demonstrates how the setWinner() function creates a critical single point of failure where owner key compromise permanently locks all participant funds. It shows that when the owner declares a winner, the _setWinner flag becomes irreversibly set to true, and if the owner key is lost or compromised afterward, all participants are permanently unable to withdraw their funds.
The test verifies that legitimate tournament winners cannot access their rightfully earned payouts when the owner becomes unavailable after winner declaration.
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 Access Control Surface Vulnerability
* Impact: Permanent fund lockup via single owner dependency
*
* Root Cause: setWinner() requires onlyOwner and creates irreversible _setWinner = true.
* If owner key compromised/lost/unavailable, all participant funds permanently locked
* since withdraw() requires winnerSet modifier which depends on setWinner() being called.
*/
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 SetWinnerAccessControlPoC is Test {
BriVault vault;
MockERC20 asset;
address owner = makeAddr("owner");
address attacker = makeAddr("attacker");
address participant1 = makeAddr("participant1");
address participant2 = makeAddr("participant2");
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[48] memory countries;
countries[0] = "Brazil";
countries[1] = "Argentina";
vault.setCountry(countries);
vm.stopPrank();
asset.mint(participant1, 1000 ether);
asset.mint(participant2, 1000 ether);
vm.startPrank(participant1);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(participant2);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
}
* CRITICAL VULNERABILITY: Owner key compromise leads to permanent fund lockup
*
* Attack Flow:
* 1. Participants deposit and join Brazil team
* 2. Attacker compromises owner key and declares Argentina as winner
* 3. Brazil participants cannot withdraw (did not win)
* 4. All funds permanently locked with no recovery mechanism
*/
function test_OwnerKeyCompromise_PermanentFundLockup() public {
console.log("=== OWNER KEY COMPROMISE ATTACK ===");
vm.startPrank(participant1);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, participant1);
vault.joinEvent(0);
vm.stopPrank();
vm.startPrank(participant2);
vault.deposit(200 ether, participant2);
vault.joinEvent(0);
vm.stopPrank();
vm.warp(EVENT_END + 1 hours);
vm.prank(owner);
vault.setWinner(1);
assertTrue(vault._setWinner(), "Winner flag set by compromised owner");
assertEq(vault.winner(), "Argentina", "Wrong winner declared");
vm.startPrank(participant1);
vm.expectRevert(BriVault.didNotWin.selector);
vault.withdraw();
vm.stopPrank();
vm.startPrank(participant2);
vm.expectRevert(BriVault.didNotWin.selector);
vault.withdraw();
vm.stopPrank();
uint256 lockedFunds = asset.balanceOf(address(vault));
assertGt(lockedFunds, 0, "All funds permanently locked");
assertEq(vault.winner(), "Argentina", "Wrong winner permanently set");
console.log("=== CRITICAL VULNERABILITY DEMONSTRATED ===");
console.log("- Owner key compromise leads to permanent fund lockup");
console.log("- No recovery mechanism exists");
console.log("- Tournament system completely broken");
}
* CRITICAL VULNERABILITY: Owner unavailability leads to permanent fund lockup
*
* Attack Flow:
* 1. Participants deposit and join tournament
* 2. Tournament ends but owner becomes unavailable (key lost/incapacitated)
* 3. setWinner() never called, winnerSet modifier blocks all withdrawals
* 4. All funds permanently locked with no recovery mechanism
*/
function test_OwnerUnavailability_PermanentFundLockup() public {
console.log("=== OWNER UNAVAILABILITY ATTACK ===");
vm.startPrank(participant1);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, participant1);
vault.joinEvent(0);
vm.stopPrank();
vm.startPrank(participant2);
vault.deposit(200 ether, participant2);
vault.joinEvent(0);
vm.stopPrank();
vm.warp(EVENT_END + 1 hours);
assertFalse(vault._setWinner(), "Winner never set due to owner unavailability");
vm.startPrank(participant1);
vm.expectRevert(BriVault.winnerNotSet.selector);
vault.withdraw();
vm.stopPrank();
vm.startPrank(participant2);
vm.expectRevert(BriVault.winnerNotSet.selector);
vault.withdraw();
vm.stopPrank();
uint256 lockedFunds = asset.balanceOf(address(vault));
assertGt(lockedFunds, 0, "All funds permanently locked");
vm.warp(block.timestamp + 365 days);
assertFalse(vault._setWinner(), "Winner still not set after 1 year");
assertEq(asset.balanceOf(address(vault)), lockedFunds, "Funds still locked");
console.log("=== CRITICAL VULNERABILITY DEMONSTRATED ===");
console.log("- Owner unavailability leads to permanent fund lockup");
console.log("- No backup mechanism or recovery exists");
console.log("- Tournament system economically unviable");
}
}
Implement multisig with timelock and emergency recovery mechanisms to eliminate the single point of failure while maintaining tournament integrity.