Root + Impact
Description
-
Each user should only be able to join the event once with their deposited amount. The totalWinnerShares should accurately reflect the sum of shares from all unique winners.
-
The joinEvent() function lacks protection against multiple calls by the same user. Each call pushes msg.sender to the usersAddress array without checking if they've already joined. When _getWinnerShares() is called during setWinner(), it iterates through usersAddress and adds the same user's shares multiple times to totalWinnerShares, artificially inflating the denominator. This causes all winners to receive less than their fair share, while the attacker can withdraw multiple times or receive inflated 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);
}
function _getWinnerShares () internal returns (uint256) {
for (uint256 i = 0; i < usersAddress.length; ++i){
address user = usersAddress[i];
totalWinnerShares += userSharesToCountry[user][winnerCountryId];
}
return totalWinnerShares;
}
Risk
Likelihood:
-
Any user can call joinEvent() multiple times before the event starts
-
No access control or state tracking prevents repeated calls
-
The vulnerability is trivial to exploit
Impact:
totalWinnerShares becomes inflated, reducing payouts for all legitimate winners
Attacker can manipulate the withdrawal calculation to steal funds
Complete loss of funds for honest participants who bet on the winning team
Protocol becomes insolvent as the math breaks down
Proof of Concept
function test_multipleJoinEventExploit() public {
vm.startPrank(owner);
briVault.setCountry(countries);
vm.stopPrank();
vm.startPrank(user1);
mockToken.approve(address(briVault), 5 ether);
briVault.deposit(5 ether, user1);
for(uint i = 0; i < 10; i++) {
briVault.joinEvent(10);
}
vm.stopPrank();
vm.startPrank(user2);
mockToken.approve(address(briVault), 5 ether);
briVault.deposit(5 ether, user2);
briVault.joinEvent(10);
vm.stopPrank();
vm.warp(eventEndDate + 1);
vm.startPrank(owner);
briVault.setWinner(10);
vm.stopPrank();
}
Recommended Mitigation
+ mapping(address => bool) public hasJoined;
function joinEvent(uint256 countryId) public {
if (stakedAsset[msg.sender] == 0) {
revert noDeposit();
}
if (countryId >= teams.length) {
revert invalidCountry();
}
if (block.timestamp > eventStartDate) {
revert eventStarted();
}
+ if (hasJoined[msg.sender]) {
+ revert("Already joined");
+ }
userToCountry[msg.sender] = teams[countryId];
uint256 participantShares = balanceOf(msg.sender);
userSharesToCountry[msg.sender][countryId] = participantShares;
usersAddress.push(msg.sender);
numberOfParticipants++;
totalParticipantShares += participantShares;
+ hasJoined[msg.sender] = true;
emit joinedEvent(msg.sender, countryId);
}