BriVault

First Flight #52
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Impact: high
Likelihood: medium
Invalid

Centralization Risk: Arbitrary Winner Selection by Owner

Description

The BriVault contract’s setWinner function grants the owner full control to arbitrarily select the winning team after the event, with no verification mechanism for the actual outcome. This centralization introduces a critical vulnerability:

  • Scenario: The owner can ignore the real-world result and declare any country as the winner, resulting in honest users losing their funds and manipulated winners receiving the vault.

function setWinner(uint256 countryIndex) public onlyOwner {
@> winner = teams[countryIndex]; // @> No verification of actual event result; owner can select any team
_setWinner = true;
winnerCountryId = countryIndex;
winnerTimestamp = block.timestamp;
emit WinnerSet(winner, winnerCountryId);
}

Risk

Likelihood: High

  • The vulnerability is trivially exploitable by the owner at any time, since no technical barriers or verification checks exist. All outcome control rests solely with the owner.

Impact: High

  • Honest users lose their funds if the owner manipulates the result. The protocol becomes untrustworthy, mimicking centralized or fraudulent betting platforms. Reputation damage and participant loss are inevitable, with no recourse for affected users.

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 {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {MockERC20} from "../MockErc20.t.sol";
/**
* @title C-03: Centralization Risk - Arbitrary Winner Selection
* @notice Demonstrates owner can manipulate winner regardless of real-world outcome
*/
contract C03_ArbitraryWinnerSelection is Test {
MockERC20 public mockToken;
BriVault public vault;
address owner = makeAddr("owner");
address ownerFriend = makeAddr("ownerFriend");
address feeAddress = makeAddr("feeAddress");
// Honest users who picked the correct team
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address carol = makeAddr("carol");
address dave = makeAddr("dave");
address eve = makeAddr("eve");
// Users who picked the "wrong" team (but will be declared winners)
address frank = makeAddr("frank");
address grace = makeAddr("grace");
uint256 participationFeeBsp = 300; // 3%
uint256 eventStartDate;
uint256 eventEndDate;
uint256 minimumAmount = 1 ether;
string[48] countries = [
"Brazil", "Argentina", "France", "Germany", "Spain",
"Portugal", "England", "Netherlands", "Italy", "Croatia",
"Belgium", "Switzerland", "Denmark", "Poland", "Serbia",
"Sweden", "Austria", "Morocco", "Senegal", "Nigeria",
"Cameroon", "Egypt", "South Africa", "Ghana", "Algeria",
"Tunisia", "Ivory Coast", "Japan", "South Korea", "Australia",
"Iran", "Saudi Arabia", "Qatar", "United States", "Canada",
"Mexico", "Costa Rica", "Panama", "Ecuador", "Uruguay",
"Colombia", "Peru", "Chile", "New Zealand", "United Arab Emirates",
"Iraq", "Uzbekistan", "Jordan"
];
function setUp() public {
eventStartDate = block.timestamp + 2 days;
eventEndDate = eventStartDate + 31 days;
mockToken = new MockERC20("Mock Token", "MTK");
// Mint tokens to all users
mockToken.mint(alice, 200 ether);
mockToken.mint(bob, 300 ether);
mockToken.mint(carol, 250 ether);
mockToken.mint(dave, 150 ether);
mockToken.mint(eve, 100 ether);
mockToken.mint(frank, 50 ether);
mockToken.mint(grace, 50 ether);
mockToken.mint(ownerFriend, 100 ether);
}
function test_ArbitraryWinnerSelection_OwnerManipulation() public {
console.log("=== POC: Arbitrary Winner Selection - Owner Manipulation ===");
// Step 1: Deploy vault
console.log("\n--- Step 1: Vault Deployment ---");
vm.startPrank(owner);
vault = new BriVault(
IERC20(address(mockToken)),
participationFeeBsp,
eventStartDate,
feeAddress,
minimumAmount,
eventEndDate
);
vault.setCountry(countries);
vm.stopPrank();
console.log("Vault deployed");
console.log("Event: FIFA World Cup 2026");
console.log("countries[0]: Brazil");
console.log("countries[1]: Argentina");
// Step 2: Majority of users pick Brazil (the actual winner)
console.log("\n--- Step 2: Users Participate ---");
console.log("Real-world outcome: Brazil wins World Cup");
console.log("\nHonest users picking Brazil (90% of pool):");
uint256 totalBrazilDeposits = 0;
// Alice picks Brazil
vm.startPrank(alice);
mockToken.approve(address(vault), 200 ether);
vault.deposit(200 ether, alice);
vault.joinEvent(0); // Brazil
vm.stopPrank();
totalBrazilDeposits += 200 ether;
console.log(" Alice: 200 ETH -> Brazil");
// Bob picks Brazil
vm.startPrank(bob);
mockToken.approve(address(vault), 300 ether);
vault.deposit(300 ether, bob);
vault.joinEvent(0); // Brazil
vm.stopPrank();
totalBrazilDeposits += 300 ether;
console.log(" Bob: 300 ETH -> Brazil");
// Carol picks Brazil
vm.startPrank(carol);
mockToken.approve(address(vault), 250 ether);
vault.deposit(250 ether, carol);
vault.joinEvent(0); // Brazil
vm.stopPrank();
totalBrazilDeposits += 250 ether;
console.log(" Carol: 250 ETH -> Brazil");
// Dave picks Brazil
vm.startPrank(dave);
mockToken.approve(address(vault), 150 ether);
vault.deposit(150 ether, dave);
vault.joinEvent(0); // Brazil
vm.stopPrank();
totalBrazilDeposits += 150 ether;
console.log(" Dave: 150 ETH -> Brazil");
// Eve picks Brazil
vm.startPrank(eve);
mockToken.approve(address(vault), 100 ether);
vault.deposit(100 ether, eve);
vault.joinEvent(0); // Brazil
vm.stopPrank();
totalBrazilDeposits += 100 ether;
console.log(" Eve: 100 ETH -> Brazil");
// Step 3: Owner and friends pick Argentina (minority)
console.log("\nOwner and friends picking Argentina (10% of pool):");
uint256 totalArgentinaDeposits = 0;
// Frank picks Argentina
vm.startPrank(frank);
mockToken.approve(address(vault), 50 ether);
vault.deposit(50 ether, frank);
vault.joinEvent(1); // Argentina
vm.stopPrank();
totalArgentinaDeposits += 50 ether;
console.log(" Frank: 50 ETH -> Argentina");
// Grace picks Argentina
vm.startPrank(grace);
mockToken.approve(address(vault), 50 ether);
vault.deposit(50 ether, grace);
vault.joinEvent(1); // Argentina
vm.stopPrank();
totalArgentinaDeposits += 50 ether;
console.log(" Grace: 50 ETH -> Argentina");
// Owner's friend picks Argentina
vm.startPrank(ownerFriend);
mockToken.approve(address(vault), 100 ether);
vault.deposit(100 ether, ownerFriend);
vault.joinEvent(1); // Argentina
vm.stopPrank();
totalArgentinaDeposits += 100 ether;
console.log(" Owner's Friend: 100 ETH -> Argentina");
// Calculate pool breakdown
console.log("\n--- Pool Breakdown ---");
console.log("Total pool: ", totalDeposits / 1 ether, "ETH");
console.log("Brazil pool:", totalBrazilDeposits / 1 ether, "ETH");
console.log("Argentina pool:", totalArgentinaDeposits / 1 ether, "ETH");
// Step 4: Owner manipulates winner (ATTACK)
console.log("\n--- Step 3: Owner Manipulation (ATTACK) ---");
vm.warp(eventEndDate + 1);
console.log("Real-world outcome: Brazil wins");
console.log("Owner action: Declares Argentina as winner");
vm.prank(owner);
string memory declaredWinner = vault.setWinner(1); // Argentina (FRAUD!)
console.log("Declared winner:", declaredWinner);
console.log("Owner bypassed real-world verification!");
// Step 5: Honest users lose everything
console.log("\n--- Step 4: Honest Users Cannot Withdraw ---");
console.log("\nBrazil pickers (CORRECT prediction):");
vm.prank(alice);
vm.expectRevert(abi.encodeWithSignature("didNotWin()"));
vault.withdraw();
console.log(" Alice: REVERTED - Lost 200 ETH");
vm.prank(bob);
vm.expectRevert(abi.encodeWithSignature("didNotWin()"));
vault.withdraw();
console.log(" Bob: REVERTED - Lost 300 ETH");
vm.prank(carol);
vm.expectRevert(abi.encodeWithSignature("didNotWin()"));
vault.withdraw();
console.log(" Carol: REVERTED - Lost 250 ETH");
vm.prank(dave);
vm.expectRevert(abi.encodeWithSignature("didNotWin()"));
vault.withdraw();
console.log(" Dave: REVERTED - Lost 150 ETH");
vm.prank(eve);
vm.expectRevert(abi.encodeWithSignature("didNotWin()"));
vault.withdraw();
console.log(" Eve: REVERTED - Lost 100 ETH");
// Step 6: Owner and friends profit
console.log("\nArgentina pickers (INCORRECT but declared winners):");
uint256 frankBefore = mockToken.balanceOf(frank);
vm.prank(frank);
vault.withdraw();
uint256 frankReceived = mockToken.balanceOf(frank) - frankBefore;
console.log(" Frank: SUCCESS - Received", frankReceived / 1 ether, "ETH");
uint256 graceBefore = mockToken.balanceOf(grace);
vm.prank(grace);
vault.withdraw();
uint256 graceReceived = mockToken.balanceOf(grace) - graceBefore;
console.log(" Grace: SUCCESS - Received", graceReceived / 1 ether, "ETH");
uint256 friendBefore = mockToken.balanceOf(ownerFriend);
vm.prank(ownerFriend);
vault.withdraw();
uint256 friendReceived = mockToken.balanceOf(ownerFriend) - friendBefore;
console.log(" Owner's Friend: SUCCESS - Received", friendReceived / 1 ether, "ETH");
// Step 7: Impact summary
console.log("\n=== IMPACT SUMMARY ===");
console.log("Honest users (Brazil pickers):");
console.log(" Total deposited:", totalBrazilDeposits / 1 ether, "ETH");
console.log(" Total withdrawn: 0 ETH");
console.log(" Total lost:", totalBrazilDeposits / 1 ether, "ETH");
uint256 totalWithdrawn = frankReceived + graceReceived + friendReceived;
console.log("\nManipulated winners (Argentina pickers):");
console.log(" Total deposited:", totalArgentinaDeposits / 1 ether, "ETH");
console.log(" Total withdrawn:", totalWithdrawn / 1 ether, "ETH");
console.log(" Profit from manipulation:", (totalWithdrawn - totalArgentinaDeposits) / 1 ether, "ETH");
console.log("\nUsers affected: 5/8 (62.5%)");
console.log("Funds stolen from honest users:", totalBrazilDeposits / 1 ether, "ETH");
console.log("Owner/friends profit: ~", (totalWithdrawn - totalArgentinaDeposits) / 1 ether, "ETH");
console.log("Trust destroyed: Complete loss of protocol credibility");
}
}

Run with:

forge test --match-contract C03_ArbitraryWinnerSelection -vv

Test Output:

[PASS] test_ArbitraryWinnerSelection_OwnerManipulation() (gas: 6381457)
Logs:
=== POC: Arbitrary Winner Selection - Owner Manipulation ===
--- Step 1: Vault Deployment ---
Vault deployed
Event: FIFA World Cup 2026
countries[0]: Brazil
countries[1]: Argentina
--- Step 2: Users Participate ---
Real-world outcome: Brazil wins World Cup
Honest users picking Brazil (90% of pool):
Alice: 200 ETH -> Brazil
Bob: 300 ETH -> Brazil
Carol: 250 ETH -> Brazil
Dave: 150 ETH -> Brazil
Eve: 100 ETH -> Brazil
Owner and friends picking Argentina (10% of pool):
Frank: 50 ETH -> Argentina
Grace: 50 ETH -> Argentina
Owner's Friend: 100 ETH -> Argentina
--- Pool Breakdown ---
Total pool: 1200 ETH
Brazil pool: 1000 ETH
Argentina pool: 200 ETH
--- Step 3: Owner Manipulation (ATTACK) ---
Real-world outcome: Brazil wins
Owner action: Declares Argentina as winner
Declared winner: Argentina
Owner bypassed real-world verification!
--- Step 4: Honest Users Cannot Withdraw ---
Brazil pickers (CORRECT prediction):
Alice: REVERTED - Lost 200 ETH
Bob: REVERTED - Lost 300 ETH
Carol: REVERTED - Lost 250 ETH
Dave: REVERTED - Lost 150 ETH
Eve: REVERTED - Lost 100 ETH
Argentina pickers (INCORRECT but declared winners):
Frank: SUCCESS - Received 291 ETH
Grace: SUCCESS - Received 291 ETH
Owner's Friend: SUCCESS - Received 582 ETH
=== IMPACT SUMMARY ===
Honest users (Brazil pickers):
Total deposited: 1000 ETH
Total withdrawn: 0 ETH
Total lost: 1000 ETH
Manipulated winners (Argentina pickers):
Total deposited: 200 ETH
Total withdrawn: 1164 ETH
Profit from manipulation: 964 ETH
Users affected: 5/8 (62.5%)
Funds stolen from honest users: 1000 ETH
Owner/friends profit: ~ 964 ETH
Trust destroyed: Complete loss of protocol credibility
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 53.14ms (12.03ms CPU time)
  • The PoC test demonstrates a manipulated outcome, with honest majority losing all funds and the owner’s group profiting, confirming the centralization risk and unfairness.

Recommended Mitigation

- function setWinner(uint256 countryIndex) public onlyOwner {
- winner = teams[countryIndex];
- _setWinner = true;
- winnerCountryId = countryIndex;
- winnerTimestamp = block.timestamp;
- emit WinnerSet(winner, winnerCountryId);
- }
+ // Option A: Multi-Sig Ownership
+ // Remove single-owner control; move winner selection behind a Gnosis Safe (multi-sig wallet)
+ // Example (external tool): Use Safe transaction for setWinner
+ // Only allow setWinner if majority (e.g., 3 of 5 signers) approve
+ // Option B: Chainlink Oracle Integration
+ // Replace manual winner assignment with oracle-driven result fetch
+ // function setWinnerFromOracle() external onlyOwner {
+ // string memory oracleWinner = ChainlinkOracle.getResult(eventId);
+ // winner = oracleWinner;
+ // _setWinner = true;
+ // emit WinnerSet(winner, /* countryId from Oracle */);
+ // }
+ // Owner can only confirm/trigger oracle fetch, not choose arbitrarily
+ // Option C: Dispute Period
+ uint256 public winnerTimestamp;
+ uint256 constant DISPUTE_PERIOD = 7 days;
+
+ function setWinner(uint256 countryIndex) public onlyOwner {
+ winner = teams[countryIndex];
+ _setWinner = true;
+ winnerCountryId = countryIndex;
+ winnerTimestamp = block.timestamp;
+ emit WinnerSet(winner, winnerCountryId);
+ }
+
+ function withdraw() external winnerSet {
+ require(block.timestamp >= winnerTimestamp + DISPUTE_PERIOD, "Dispute period active");
+ // ... rest of withdrawal logic
+ }

Summary of Mitigations:

  • Multi-Sig Ownership: Removes unilateral owner power; winner is set only via majority multisig consensus.

  • Chainlink Oracle Integration: Ensures winner matches real-world event via tamper-proof oracle data.

  • Dispute Period: Adds time buffer for users to challenge or dispute an announced winner before withdrawals unlock.

Updates

Appeal created

bube Lead Judge 21 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

The winner is set by the owner

This is owner action and the owner is assumed to be trusted and to provide correct input arguments.

Support

FAQs

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

Give us feedback!