In the vault, users deposit ERC20 assets to mint ERC4626 shares, then join an event to bet on a team, snapshotting their share balance into userSharesToCountry[msg.sender][countryId] for aggregating totalWinnerShares. Withdrawals calculate payouts using the live balanceOf(msg.sender), which can be altered post-snapshot.
The core vulnerability stems from shares being fully transferable ERC20 tokens with no restrictions, even post-event when the winner is set. An attacker controlling multiple accounts can deposit on a non-joined account, join on another, and then transfer shares post-event to inflate the joined account's live balance before withdrawing. This exploits the ability to move shares dynamically, mismatching the live balance against the fixed totalWinnerShares, allowing over-claims. Transfers enable this stealthily after the event, when the outcome is known but before claims, potentially allowing sequenced manipulations across accounts.
This transfer-driven manipulation causes miscalculated payouts, with early claims (post-transfer) draining disproportionate assets and leading to insolvency for others.
Likelihood: High
Shares are standard ERC20 tokens and transferable at all times, including after event participation.
Users are financially incentivised to transfer shares to increase winning payouts.
The vault provides no restriction on transfers and no snapshot enforcement during reward settlement.
Impact: High
Winners can inflate withdrawal payouts and take more than intended.
Remaining winners fail to withdraw due to ERC20InsufficientBalance, causing a full or partial fund freeze.
Here's how the exploit takes place:
Setting up the base:
There will be 4 users involved in total: user1, user2, user3, and user4.
For this attack, we will assume that the account user1 and user2 are both held by the attacker itself. We know, contract doesn't restrict anyone to have more than one account.
All the users will be depositing 5 tokens for simplicity.
Pre-Event:
At this point, users are allowed to deposit as well as join the event by passing their favoured countryId
Both user1 and user2 deposited 5 tokens each. However, the attacker made sure that only user2 joins the event using countryId 10.
Both user3 and user4 deposits as well as joins the event using the same countryId 10.
Event Starts
Post-Event & Unexpected Transfer:
After the event ended, the owner set the countryId 10 as the winner (let's say), using setWinner() function.
That's where the attacker leverages unrestricted transfers: moving some shares (around 4.925 shares in the PoC) from user1 (non-joined) to user2 (joined), inflating balanceOf(user2).
The reason we aren't transferring the whole shares amount to user2 is that it will block the attacker's user2 withdrawal as well due to over-inflation. Here, such a value of 4.925 is attained by trial and error.
This transfer step is critical, as it allows dynamic inflation after the outcome is known, enabling the attacker to optimise claims.
Withdrawals:
Knowing the implications of his intended transfer, the attacker will definitely be the first one to call the withdraw using user2. Thus, grabbing a large share of the prize.
If math turned out lucky for user3, he might be able to get his fair share of the prize tho. Otherwise, he will be facing the same fate as of user4.
Well, some of the last withdrawers like user4 won't be able to get anything, since their share is eaten by the attacker in advance.
Add this test_SnapshotMismatch_ExploitsPayout to the briVault.t.sol, but there are some interesting things to note here:
This test can itself be turned into several variants by just commenting out some lines and adding them back.
The default variant is Exploit Variant, which will demonstrate the exact explanation of the attack written above.
The other variant is Lucky Variant (as the attacker spared them), just a representation of what might happen in normal scenarios.
Exploit Variant:
Lucky Variant:
Use the following command to run the test:
Logs:
Exploit Variant: We can see how User2 was able to get a larger amount, but most importantly, User4 gained nothing, since his withdraw call was simply reverted.
Lucky Variant: User1 and User2 collectively gained 9850000000000000000 , which is literally less than what an attacker might gain. Additionally, User4 gains his fair prize share too.
Option A — Restrict Transfers Post-Event
Add a modifier or hook to disable transfers after setWinner (e.g., override transfer/transferFrom to revert if _setWinner == true).
Option B — Make Shares Non-Transferable
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.