BriVault

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

When users deposit multiple times and then cancel his participation, the protocol refunds only the last deposit instead of the whole amount.

Root + Impact

Description

When users deposit multiple times the stakedAsset mapping is overwritten (not accumulated) and then when the user cancel his participation the refunded amount is less than deposit.

The Problem:

  • stakedAsset[receiver] is overwritten with each deposit

  • _mint() correctly accumulates shares

  • This creates a mismatch between tracked stake and actual shares

function deposit(uint256 assets, address receiver) public override returns (uint256) {
// ... validation ...
uint256 stakeAsset = assets - fee;
@> stakedAsset[receiver] = stakeAsset; // ← BUG: Should be +=
uint256 participantShares = _convertToShares(stakeAsset);
// ...
_mint(msg.sender, participantShares); // ← This accumulates correctly
// ...
}
function cancelParticipation () public {
@> uint256 refundAmount = stakedAsset[msg.sender]; // ← Only last deposit!
stakedAsset[msg.sender] = 0;
uint256 shares = balanceOf(msg.sender); // ← ALL shares
_burn(msg.sender, shares); // ← Burns all shares
@> IERC20(asset()).safeTransfer(msg.sender, refundAmount); // ← Refunds only last deposit
}

Risk

Likelihood: High

  • Every time when the user deposits multiple times and then cancel his participation

Impact: High

  • Users suffer unexpected loss

  • Breaks core accounting invariants

Proof of Concept

The Exploit:

  1. User deposits 5 ETH → stakedAsset = 4.925 ETH, shares = 4.925 ETH

  2. User deposits 5 ETH → stakedAsset = 4.925 ETH (overwritten!), shares = 9.85 ETH

  3. User deposits 5 ETH → stakedAsset = 4.925 ETH (overwritten!), shares = 14.775 ETH

  4. User calls cancelParticipation()

  5. User receives back: 4.925 ETH (only last deposit)

  6. User loses: 9.85 ETH (66.67% of total deposit)

  7. Vault keeps: 9.85 ETH as "ghost funds"

function test_MultipleDeposits_CancelTheft() public {
// User1 makes multiple deposits
vm.startPrank(user1);
mockToken.approve(address(briVault), 20 ether);
briVault.deposit(5 ether, user1);
briVault.deposit(5 ether, user1);
briVault.deposit(5 ether, user1);
uint256 userSharesBeforeCancel = briVault.balanceOf(user1);
uint256 trackedStake = briVault.stakedAsset(user1);
uint256 vaultBalanceBeforeCancel = mockToken.balanceOf(address(briVault));
uint256 userBalanceBeforeCancel = mockToken.balanceOf(user1);
// User cancels - gets only 4.925 ETH back but burns 14.775 ETH worth of shares
briVault.cancelParticipation();
uint256 userBalanceAfterCancel = mockToken.balanceOf(user1);
uint256 vaultBalanceAfterCancel = mockToken.balanceOf(address(briVault));
uint256 refundAmount = userBalanceAfterCancel - userBalanceBeforeCancel;
vm.stopPrank();
// Verify the exploit
assertEq(refundAmount, trackedStake, "Refund should only be tracked stake");
assertEq(briVault.balanceOf(user1), 0, "All shares should be burned");
assertLt(refundAmount, userSharesBeforeCancel, "Refund less than actual shares");
// The vault incorrectly keeps funds that should have been refunded
uint256 stolenFunds = vaultBalanceBeforeCancel - vaultBalanceAfterCancel;
uint256 unreturnedFunds = userSharesBeforeCancel - refundAmount;
assertGt(stolenFunds, 0, "Stolen funds > 0");
assertGt(vaultBalanceAfterCancel, 0, "Vault should have leftover funds");
assertGt(unreturnedFunds, 0, "User should lose funds");
}

Recommended Mitigation

Make stakedAsset mapping to accumulate instead to override the staked assets.

function deposit(uint256 assets, address receiver) public override returns (uint256) {
require(receiver != address(0), "Invalid receiver");
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
uint256 fee = _getParticipationFee(assets);
if (minimumAmount + fee > assets) {
revert lowFeeAndAmount();
}
uint256 stakeAsset = assets - fee;
- stakedAsset[receiver] = stakeAsset;
+ stakedAsset[receiver] += stakeAsset;
uint256 participantShares = _convertToShares(stakeAsset);
IERC20(asset()).safeTransferFrom(msg.sender, participationFeeAddress, fee);
IERC20(asset()).safeTransferFrom(msg.sender, address(this), stakeAsset);
_mint(msg.sender, participantShares);
emit deposited(receiver, stakeAsset);
return participantShares;
}
Updates

Appeal created

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

`stakedAsset` Overwritten on Multiple Deposits

Vault tracks only a single deposit slot per user and overwrites it on every call instead of accumulating the total.

Support

FAQs

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

Give us feedback!