BriVault

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

Share Transfer Breaks Withdrawal Logic

Root + Impact

Description

  • Normal Behavior: Users who select the winning team should be able to withdraw their proportional share of the vault assets. The withdraw() function verifies the user selected the winning team via userToCountry[msg.sender] and calculates their withdrawal based on balanceOf(msg.sender).


  • Vulnerability: BTT shares are ERC20 tokens and fully transferable via the inherited transfer() function. When a user transfers their shares to another address, the userToCountry mapping is NOT updated, creating a mismatch between who the protocol thinks won and who actually holds the shares. This results in permanent fund lockup.

function withdraw() external winnerSet {
// ...
// @> Checks userToCountry mapping (set during joinEvent)
if (
keccak256(abi.encodePacked(userToCountry[msg.sender])) !=
keccak256(abi.encodePacked(winner))
) {
revert didNotWin();
}
// @> But uses current balanceOf for withdrawal calculation
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);
}

Risk:

Likelihood: HIGH

  • Users may legitimately transfer shares for various reasons (gifting, selling, portfolio management) without realizing it breaks withdrawal functionality.


  • The protocol inherits ERC20 transfer() with no restrictions, making share transfers a standard, expected feature


Impact: HIGH


  • Original winner cannot withdraw despite selecting the correct team (has 0 shares after transfer)

  • Share recipient cannot withdraw despite holding shares (never joined event, userToCountry is empty)

  • Funds permanently locked in vault with no recovery mechanism

  • Complete loss of deposited assets for affected users

Proof of Concept

You may copy and paste the below POC to the existing test suite.

function test_ShareTransferBreaksWithdrawal() public {
vm.startPrank(owner);
briVault.setCountry(countries);
vm.stopPrank();
address bob = makeAddr("bob");
address alice = makeAddr("alice");
mockToken.mint(bob, 10 ether);
// bob deposits and joins event with team index 10
vm.startPrank(bob);
mockToken.approve(address(briVault), type(uint256).max);
uint256 bobShares = briVault.deposit(10 ether, bob);
uint256 vaultBalance = mockToken.balanceOf(address(briVault));
console.log("The Vault now holds this much balance:", vaultBalance);
console.log();
console.log("bob's shares after the deposit: ", bobShares);
briVault.joinEvent(10);
string memory bobsCountry = briVault.userToCountry(bob);
assertEq(bobsCountry, briVault.teams(10));
// bob transfers BTT shares to alice (who never joined)
uint256 aliceSharesBefore = briVault.balanceOf(alice);
console.log("Alice shares: ", aliceSharesBefore);
console.log("Bob joined the event with index 10: ", bobsCountry);
console.log();
briVault.transfer(alice, bobShares);
uint256 bobsSharesAfter = briVault.balanceOf(bob);
uint256 aliceSharesAfter = briVault.balanceOf(alice);
console.log("bob's shares after the transfer: ", bobsSharesAfter);
console.log("Alice shares after the transfer: ", aliceSharesAfter);
assertEq(bobsSharesAfter, 0);
vm.stopPrank();
vm.warp(eventEndDate + 1);
// Owner sets winner to countryId 10
vm.startPrank(owner);
briVault.setWinner(10);
vm.stopPrank();
//bob withdraws:
vm.startPrank(bob);
briVault.withdraw();
// Bob cannot withdraw (has 0 shares)
uint256 bobBalanceAfterWithdraw = mockToken.balanceOf(bob);
console.log();
console.log("Bob's balance after withdrawal: ", bobBalanceAfterWithdraw);
console.log();
assertEq(bobBalanceAfterWithdraw, 0, "bob should recieve 0 tokens");
vm.stopPrank();
// Alice cannot withdraw (didn't join event, userToCountry[user2] is empty)
vm.startPrank(alice);
vm.expectRevert(abi.encodeWithSignature("didNotWin()"));
briVault.withdraw();
vm.stopPrank();
// Funds permanently locked
uint256 vaultBalanceAfter = mockToken.balanceOf(address(briVault));
console.log("The vault now still holds: ", vaultBalanceAfter, "but alice can't withdraw.");
}

Output Confirmation:

[PASS] test_ShareTransferBreaksWithdrawal() (gas: 1839597)
Logs:
The Vault now holds this much balance: 9850000000000000000
bob's shares after the deposit: 9850000000000000000
Alice shares: 0
Bob joined the event with index 10: Japan
bob's shares after the transfer: 0
Alice shares after the transfer: 9850000000000000000
Bob's balance after withdrawal: 0
The vault now still holds: 9850000000000000000 but alice can't withdraw.
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.64ms (888.59µs CPU time)

Recommended Mitigation

Use userSharesToCountry for withdrawal calculations instead of balanceOf():

function withdraw() external winnerSet {
// ...
- uint256 shares = balanceOf(msg.sender);
+ uint256 shares = userSharesToCountry[msg.sender][winnerCountryId];
uint256 vaultAsset = finalizedVaultAsset;
uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);
_burn(msg.sender, shares);
IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
}

Option 2: Disable share transfers during active events:

+ function _beforeTokenTransfer(address from, address to, uint256 amount) internal virtual override {
+ if (from != address(0) && to != address(0)) { // Not mint/burn
+ if (block.timestamp >= eventStartDate && !_setWinner) {
+ revert("Shares locked during event");
+ }
+ }
+ super._beforeTokenTransfer(from, to, amount);
+ }
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!