There's a core functionality of this contract that ensures that only winners are allowed to withdraw their assets (using briVault::withdraw()), since their team won, on which they made the lucky bet. On the other hand, the rest of the users — losers, specifically — aren't allowed to withdraw their assets at all. And, this totally makes sense.
However, the contract left the standard ERC-4626 exit entrypoints unguarded. The inherited ERC4626::redeem() and ERC4626::withdraw(uint256,address,address) are callable and are not aligned with the tournament logic. As a result, non-winners (or any caller with shares) can use these standard functions to burn shares and pull underlying assets immediately, bypassing the tournament settlement logic and locking invariants.
Because ERC-4626’s standard redeem / withdraw are still available and not overridden, a participant can exit early by calling them — removing funds that the tournament settlement expects to be locked.
Likelihood: High
The ERC-4626 public entrypoints remain callable by design (unless overridden). Anyone with shares can call them; no extra approvals or unusual conditions are required when calling for one’s own shares.
Impact: High
Allows participants to drain their share of vault resources during the tournament, leaving insufficient funds at settlement for winners and breaking the vault’s core invariant.
Erodes trust, completely disrupts the system.
Here are some points about the exploit such that it is understood well. Although the comments within the test do an amazing job, it's still better to have more context:
Considering 3 users, all depositing the same number of assets, i.e. 5.
User1 picks countryId 10, User2 picks 11, and User3 picks 12. Here, countryId 10 will win, thus user2 and user3 act as exploiters. And, user1 is the unlucky winner.
We will be using the ERC4626::redeem() function for its simplicity as well as the way it easily fits into this scenario. One can also use ERC4626::withdraw() if he/she is willing to do some math stuff.
At the end of this test, you will notice how user1 was meant to get 14775000000000000000 amount of assets, but only 4925000000000000000 was left in the vault, due to which his call to briVault::withdraw() reverts.
Add this test_UsersCanWithdrawsSharesOnceTheyLost test to briVault.t.sol:
Run it using the following command:
Logs:
The contract should override the ERC4626::_withdraw() function, and put the relevant logic in. It's recommended to override the internal functions, rather than the public-facing ones. Plus, this will fix both the entry points of redeem as well as withdraw. Definitely, check out this note from ERC4626.sol, as it explicitly mentions this: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/extensions/ERC4626.sol#L52-L58
Alternatively, you could also override both the ERC4626::redeem() and ERC4626::withdraw() functions, and thus revert any call to them. With this, every user has to call the favoured briVault::withdraw() function to withdraw any assets.
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.