BriVault

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

HIGH-01: Withdraw Uses Current balanceOf Instead of Locked Shares

Root + Impact

The withdraw() function uses balanceOf(msg.sender) to determine payout, which reflects the current share balance that can change via transfers. This should use the locked userSharesToCountry value captured at joinEvent time.

Description

Normal behavior expects that a user's payout is determined by their shares at the time they joined the event, not their current balance which can be manipulated through transfers.

The current implementation uses balanceOf(msg.sender) which can be freely transferred after joining the event. Users can game the system by transferring shares after joining, or receiving shares from others.

function joinEvent(uint256 countryId) public {
// ... validation ...
uint256 participantShares = balanceOf(msg.sender);
// @> Shares locked at join time
userSharesToCountry[msg.sender][countryId] = participantShares;
// ... rest of function ...
}
function withdraw() external winnerSet {
if (block.timestamp < eventEndDate) {
revert eventNotEnded();
}
if (
keccak256(abi.encodePacked(userToCountry[msg.sender])) !=
keccak256(abi.encodePacked(winner))
) {
revert didNotWin();
}
// @> Uses current balance, not locked shares from joinEvent
uint256 shares = balanceOf(msg.sender);
uint256 vaultAsset = finalizedVaultAsset;
uint256 assetToWithdraw = Math.mulDiv(
shares,
vaultAsset,
totalWinnerShares
);
_burn(msg.sender, shares);
IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
emit Withdraw(msg.sender, assetToWithdraw);
}

Risk

Likelihood:

  • ERC20 shares are transferable by default with no restrictions

  • Users can transfer shares to other addresses freely

  • Multiple users can coordinate to manipulate their withdrawals

Impact:

  • User joins with 1000 shares for Team A

  • User transfers away 500 shares to another address

  • User withdraws based on remaining 500 shares only

  • The transferred 500 shares can be used by recipient who didn't join

  • Creates double-counting if recipient also joined

  • Allows users to split winnings incorrectly

  • Recipient of transferred shares may not have joined winning team but can still benefit

  • Breaks the fundamental rule that only original participants should win

Proof of Concept

// Alice deposits 1000 tokens and joins Team A
alice.deposit(1000e18, alice);
alice.joinEvent(0); // Team A
// Bob deposits 1000 tokens and joins Team B (losing team)
bob.deposit(1000e18, bob);
bob.joinEvent(1); // Team B
// Team A wins
vault.setWinner(0);
// EXPLOIT: Alice transfers her shares to Bob
vm.prank(alice);
vault.transfer(bob, vault.balanceOf(alice));
// Now:
// - alice.balanceOf() = 0
// - bob.balanceOf() = ~2000 shares
// - alice.userSharesToCountry[0] = 990 (still tracked)
// - bob.userToCountry = "Team B" (loser)
// Alice tries to withdraw: FAILS (no shares)
vm.prank(alice);
vm.expectRevert(); // Burns 0 shares, likely reverts
vault.withdraw();
// Bob can't withdraw: FAILS (wrong team)
vm.prank(bob);
vm.expectRevert(abi.encodeWithSelector(BriVault.didNotWin.selector));
vault.withdraw();
// Alice's rightful winnings are now stuck!
// ----
// Alternative exploit: Transfer between winners
// Alice and Bob both bet on winning Team A
// Alice transfers shares to Bob after winning
// Bob withdraws DOUBLE (his original + Alice's)
// Alice can't withdraw (no shares left)

Recommended Mitigation

function withdraw() external winnerSet {
if (block.timestamp < eventEndDate) {
revert eventNotEnded();
}
if (
keccak256(abi.encodePacked(userToCountry[msg.sender])) !=
keccak256(abi.encodePacked(winner))
) {
revert didNotWin();
}
- uint256 shares = balanceOf(msg.sender);
+ // Use locked shares from when user joined
+ uint256 shares = userSharesToCountry[msg.sender][winnerCountryId];
+
+ // Prevent double withdrawal
+ require(shares > 0, "Already withdrawn or no shares");
+ userSharesToCountry[msg.sender][winnerCountryId] = 0;
uint256 vaultAsset = finalizedVaultAsset;
uint256 assetToWithdraw = Math.mulDiv(
shares,
vaultAsset,
totalWinnerShares
);
- _burn(msg.sender, shares);
+ // Burn shares from whoever holds them now
+ // Only burn if user still has them
+ uint256 currentBalance = balanceOf(msg.sender);
+ if (currentBalance >= shares) {
+ _burn(msg.sender, shares);
+ } else if (currentBalance > 0) {
+ _burn(msg.sender, currentBalance);
+ }
IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
emit Withdraw(msg.sender, assetToWithdraw);
}

Better approach: Make shares non-transferable after joining

+mapping(address => bool) public hasJoinedEvent;
function joinEvent(uint256 countryId) public {
// ... existing checks ...
+ hasJoinedEvent[msg.sender] = true;
// ... rest of function ...
}
+// Override transfer functions to prevent transfers after joining
+function transfer(address to, uint256 amount) public virtual override returns (bool) {
+ require(!hasJoinedEvent[msg.sender], "Cannot transfer after joining event");
+ return super.transfer(to, amount);
+}
+
+function transferFrom(address from, address to, uint256 amount) public virtual override returns (bool) {
+ require(!hasJoinedEvent[from], "Cannot transfer after joining event");
+ return super.transferFrom(from, to, amount);
+}
Updates

Appeal created

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

Inflation attack

Support

FAQs

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

Give us feedback!