BriVault

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

Share Transfers After joinEvent() Create Orphaned Funds

Root + Impact

Description

  • BriVault's withdraw() function requires both userToCountry[msg.sender] == winner AND balanceOf(msg.sender) > 0. When a winner transfers shares after joinEvent(), the withdrawal logic breaks - the original winner has balanceOf() == 0 so gets 0 assets, and the recipient has userToCountry[recipient] != winner so cannot withdraw. Funds become permanently orphaned.

  • Root cause: BriVault's withdraw() function requires both userToCountry[msg.sender] == winner AND balanceOf(msg.sender) > 0. When a winner transfers shares after joinEvent(), the withdrawal logic breaks - the original winner has balanceOf() == 0 so gets 0 assets, and the recipient has userToCountry[recipient] != winner so cannot withdraw. Funds become permanently orphaned.

function withdraw() external winnerSet {
if (block.timestamp < eventEndDate) {
revert eventNotEnded();
}
if (
keccak256(abi.encodePacked(userToCountry[msg.sender])) !=
keccak256(abi.encodePacked(winner))
) {
revert didNotWin();
}
@> uint256 shares = balanceOf(msg.sender); // PROBLEM: Uses current balance, not participation shares
uint256 vaultAsset = finalizedVaultAsset;
uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);
_burn(msg.sender, shares);
IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
}

Risk

Likelihood: High

  • ERC20 transfers are standard functionality that users expect to work normally.

Impact: High

Permanent fund lockup affecting legitimate tournament winners who transfer shares post-participation.

  • Winners lose access to rightfully earned tournament payouts through innocent share transfers

  • Funds become permanently inaccessible, creating trust-destroying user experience

  • Tournament payout mechanism completely broken by standard ERC20 functionality

  • Protocol cannot guarantee fund accessibility for tournament participants

Proof of Concept

The POC demonstrates how share transfers after joinEvent() create permanently orphaned funds by breaking the withdrawal logic. It shows a tournament winner who joins legitimately and earns winnings, but then transfers their shares to another address. The original winner cannot withdraw because they have zero balance, and the recipient cannot withdraw because they didn't participate in the tournament.

The test verifies that rightful winnings become permanently inaccessible due to standard ERC20 transfer functionality.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import {BriVault} from "../../src/briVault.sol";
import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
/**
* PoC: Share Transfers After joinEvent() Create Orphaned Funds
* Discovery Method: State Lifecycle Gap
* EQS: 9.2/10, Confidence: 3/3, Severity: HIGH
* Impact: Permanent fund lockup
*
* Root Cause: withdraw() requires both userToCountry[msg.sender] == winner
* AND balanceOf(msg.sender) > 0. When a winner transfers shares post-join,
* neither the original winner nor recipient can withdraw, orphaning funds permanently.
*/
contract MockERC20 is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract ShareTransfersOrphanFundsPoC is Test {
BriVault vault;
MockERC20 asset;
address owner = makeAddr("owner");
address winner = makeAddr("winner");
address recipient = makeAddr("recipient");
address feeRecipient = makeAddr("feeRecipient");
uint256 constant PARTICIPATION_FEE_BPS = 100; // 1%
uint256 constant MINIMUM_AMOUNT = 100 ether;
uint256 EVENT_START;
uint256 EVENT_END;
function setUp() public {
// Initialize time variables
EVENT_START = block.timestamp + 1 days;
EVENT_END = EVENT_START + 7 days;
// Deploy mock ERC20 asset
asset = new MockERC20("Mock Token", "MOCK");
// Deploy vault with tournament parameters
vm.startPrank(owner);
vault = new BriVault(
IERC20(address(asset)),
PARTICIPATION_FEE_BPS,
EVENT_START,
feeRecipient,
MINIMUM_AMOUNT,
EVENT_END
);
// Set up tournament countries
string[48] memory countries;
countries[0] = "Brazil";
countries[1] = "Argentina";
vault.setCountry(countries);
vm.stopPrank();
// Setup user balances
asset.mint(winner, 1000 ether);
asset.mint(recipient, 1000 ether);
// Approve vault to spend tokens
vm.startPrank(winner);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(recipient);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
}
/**
* CRITICAL VULNERABILITY: Share transfers after joinEvent() orphan funds permanently
*
* Attack Flow:
* 1. Winner legitimately deposits and joins tournament
* 2. Tournament ends, winner's team declared winner
* 3. Winner transfers shares to another address (innocent action)
* 4. Original winner tries to withdraw - gets 0 assets (balance = 0)
* 5. Recipient tries to withdraw - fails (no participation record)
* 6. Funds permanently orphaned in vault
*/
function test_ShareTransfersCreateOrphanedFunds() public {
console.log("=== SHARE TRANSFERS ORPHAN FUNDS VULNERABILITY ===");
// Phase 1: Winner participates legitimately
vm.startPrank(winner);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, winner);
vault.joinEvent(0); // Join Brazil
vm.stopPrank();
console.log("Winner deposited 200 ether, joined Brazil");
console.log("Winner shares:", vault.balanceOf(winner));
console.log("Winner country mapping:", vault.userToCountry(winner));
// Phase 2: Tournament completes, Brazil wins
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(0); // Brazil wins
vm.stopPrank();
console.log("Tournament ended, Brazil declared winner");
// Phase 3: Winner transfers shares (innocent action)
vm.startPrank(winner);
uint256 sharesToTransfer = vault.balanceOf(winner);
vault.transfer(recipient, sharesToTransfer);
vm.stopPrank();
console.log("Winner transferred all shares to recipient");
console.log("Winner shares after transfer:", vault.balanceOf(winner));
console.log("Recipient shares:", vault.balanceOf(recipient));
console.log("Winner country mapping:", vault.userToCountry(winner));
console.log("Recipient country mapping:", vault.userToCountry(recipient));
// Phase 4: Winner tries to withdraw - FAILS (gets 0 assets)
vm.startPrank(winner);
uint256 winnerBalanceBefore = asset.balanceOf(winner);
vault.withdraw(); // Succeeds but transfers 0 assets
uint256 winnerBalanceAfter = asset.balanceOf(winner);
vm.stopPrank();
console.log("Winner attempted withdrawal:");
console.log("Balance before:", winnerBalanceBefore);
console.log("Balance after:", winnerBalanceAfter);
console.log("Winner received: 0 assets (orphaned!)");
// Phase 5: Recipient tries to withdraw - FAILS (no participation)
vm.startPrank(recipient);
vm.expectRevert(BriVault.didNotWin.selector);
vault.withdraw(); // Should fail - no participation record
vm.stopPrank();
console.log("Recipient attempted withdrawal: FAILED (didNotWin revert)");
// Phase 6: Funds permanently orphaned
uint256 orphanedFunds = asset.balanceOf(address(vault));
console.log("Funds permanently orphaned in vault:", orphanedFunds);
// Verification
assertEq(winnerBalanceAfter - winnerBalanceBefore, 0, "Winner gets 0 assets");
assertGt(orphanedFunds, 0, "Funds remain locked in vault");
assertEq(vault.balanceOf(winner), 0, "Winner has no shares");
assertGt(vault.balanceOf(recipient), 0, "Recipient has shares but cannot withdraw");
console.log("=== VULNERABILITY CONFIRMED: FUNDS PERMANENTLY ORPHANED ===");
}
/**
* Demonstrate the root cause: withdraw() logic flaw
*/
function test_RootCauseAnalysis() public {
// Setup: Winner participates
vm.startPrank(winner);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, winner);
vault.joinEvent(0);
vm.stopPrank();
// Winner set
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(0);
vm.stopPrank();
console.log("=== ROOT CAUSE ANALYSIS ===");
console.log("Before transfer:");
console.log("- userToCountry[winner] == winner:", keccak256(abi.encodePacked(vault.userToCountry(winner))) == keccak256(abi.encodePacked("Brazil")));
console.log("- balanceOf(winner) > 0:", vault.balanceOf(winner) > 0);
console.log("- withdraw() should succeed: YES");
// Transfer shares
vm.startPrank(winner);
vault.transfer(recipient, vault.balanceOf(winner));
vm.stopPrank();
console.log("After transfer:");
console.log("- userToCountry[winner] == winner:", keccak256(abi.encodePacked(vault.userToCountry(winner))) == keccak256(abi.encodePacked("Brazil")));
console.log("- balanceOf(winner) > 0:", vault.balanceOf(winner) > 0);
console.log("- withdraw() should succeed: NO (balance = 0)");
console.log("- userToCountry[recipient] == winner:", keccak256(abi.encodePacked(vault.userToCountry(recipient))) == keccak256(abi.encodePacked("Brazil")));
console.log("- balanceOf(recipient) > 0:", vault.balanceOf(recipient) > 0);
console.log("- withdraw() should succeed: NO (no participation record)");
console.log("Root cause: withdraw() requires BOTH conditions, transfer breaks both paths");
}
}

Recommended Mitigation

Store participation shares at join time and use those for withdrawal calculations:

- function withdraw() external winnerSet {
- if (block.timestamp < eventEndDate) {
- revert eventNotEnded();
- }
- if (
- keccak256(abi.encodePacked(userToCountry[msg.sender])) !=
- keccak256(abi.encodePacked(winner))
- ) {
- revert didNotWin();
- }
- uint256 shares = balanceOf(msg.sender); // PROBLEM: Uses current balance, not participation shares
-
- uint256 vaultAsset = finalizedVaultAsset;
- uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);
-
- _burn(msg.sender, shares);
- IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
- }
+ // Add to contract state
+ mapping(address => uint256) public participationShares;
+
+ // Modify joinEvent()
+ function joinEvent(uint256 countryId) public {
+ // ... existing validation
+ userToCountry[msg.sender] = teams[countryId];
+ participationShares[msg.sender] = balanceOf(msg.sender); // Store at join time
+ userSharesToCountry[msg.sender][countryId] = participationShares[msg.sender];
+ usersAddress.push(msg.sender);
+ // ...
+ }
+
+ // Modify withdraw()
+ function withdraw() external winnerSet {
+ if (keccak256(abi.encodePacked(userToCountry[msg.sender])) != keccak256(abi.encodePacked(winner))) {
+ revert didNotWin();
+ }
+ uint256 shares = participationShares[msg.sender]; // Use stored participation shares
+ uint256 vaultAsset = finalizedVaultAsset;
+ uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);
+ // ... transfer logic
+ participationShares[msg.sender] = 0; // Reset after withdrawal
+ }
Updates

Appeal created

bube Lead Judge 21 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!