BriVault

First Flight #52
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Impact: high
Likelihood: medium
Invalid

Invalid Event Date Configuration Leads to Permanent Fund Lock

Invalid Event Date Configuration Leads to Permanent Fund Lock

Description

  • The BriVault contract is designed to allow users to deposit funds before an event starts (eventStartDate), participate by selecting a team, and withdraw winnings after the event ends (eventEndDate) once the owner sets the winner. The normal behavior expects eventEndDate to occur after eventStartDate, creating a valid event timeline.

  • The constructor fails to validate that eventEndDate > eventStartDate, allowing deployment with a backwards timeline. Additionally, the _setFinallizedVaultBalance() function incorrectly validates against eventStartDate instead of eventEndDate. This combination creates a scenario where setWinner() can never be successfully called, permanently locking all deposited funds.

constructor(
IERC20 _asset,
uint256 _participationFeeBsp,
uint256 _eventStartDate,
address _participationFeeAddress,
uint256 _minimumAmount,
uint256 _eventEndDate
) ERC4626(_asset) ERC20("BriTechLabs", "BTT") Ownable(msg.sender) {
if (_participationFeeBsp > PARTICIPATIONFEEBSPMAX) {
revert limiteExceede();
}
participationFeeBsp = _participationFeeBsp;
@> eventStartDate = _eventStartDate; // No validation
@> eventEndDate = _eventEndDate; // No validation that eventEndDate > eventStartDate
participationFeeAddress = _participationFeeAddress;
minimumAmount = _minimumAmount;
_setWinner = false;
}
function _setFinallizedVaultBalance() internal returns (uint256) {
@> if (block.timestamp <= eventStartDate) { // Should check eventEndDate
revert eventNotStarted();
}
return finalizedVaultAsset = IERC20(asset()).balanceOf(address(this));
}

Risk

Likelihood: Medium

  • Deployment with eventEndDate < eventStartDate can occur through human error when setting Unix timestamps during contract initialization

  • No deployment scripts or frontend validation exists to prevent this misconfiguration

  • The vulnerability activates automatically upon deployment with invalid parameters, requiring no additional attacker action

Impact: High

  • All user deposits become permanently locked with no recovery mechanism, as setWinner() will always revert regardless of the current timestamp

  • Users who deposited funds cannot withdraw as the winnerSet modifier prevents access until _setWinner is true, which can never be achieved

  • No emergency withdrawal function exists to recover locked funds, affecting 100% of participants

  • The contract becomes permanently bricked and unusable for its intended purpose

Proof of Concept

Add the following test to your test suite:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {BriVault} from "../src/briVault.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {MockERC20} from "./MockErc20.t.sol";
/**
* @title BriVault Audit POC Tests
* @notice This file contains Proof of Concept tests for identified vulnerabilities
* @dev Each test demonstrates a specific attack vector or vulnerability
*/
contract BriVaultAuditPOCTest is Test {
uint256 public participationFeeBsp;
uint256 public eventStartDate;
uint256 public eventEndDate;
address public participationFeeAddress;
uint256 public minimumAmount;
// Vault contract
BriVault public briVault;
MockERC20 public mockToken;
// Users
address owner = makeAddr("owner");
address user1 = makeAddr("user1");
address user2 = makeAddr("user2");
address user3 = makeAddr("user3");
address user4 = makeAddr("user4");
address user5 = makeAddr("user5");
address attacker = makeAddr("attacker");
string[48] countries = [
"United States", "Canada", "Mexico", "Argentina", "Brazil",
"Ecuador", "Uruguay", "Colombia", "Peru", "Chile",
"Japan", "South Korea", "Australia", "Iran", "Saudi Arabia",
"Qatar", "Uzbekistan", "Jordan", "France", "Germany",
"Spain", "Portugal", "England", "Netherlands", "Italy",
"Croatia", "Belgium", "Switzerland", "Denmark", "Poland",
"Serbia", "Sweden", "Austria", "Morocco", "Senegal",
"Nigeria", "Cameroon", "Egypt", "South Africa", "Ghana",
"Algeria", "Tunisia", "Ivory Coast", "New Zealand", "Costa Rica",
"Panama", "United Arab Emirates", "Iraq"
];
function setUp() public {
participationFeeBsp = 150; // 1.5%
eventStartDate = block.timestamp + 2 days;
eventEndDate = eventStartDate + 31 days;
participationFeeAddress = makeAddr("participationFeeAddress");
minimumAmount = 0.0002 ether;
mockToken = new MockERC20("Mock Token", "MTK");
mockToken.mint(owner, 100 ether);
mockToken.mint(user1, 100 ether);
mockToken.mint(user2, 100 ether);
mockToken.mint(user3, 100 ether);
mockToken.mint(user4, 100 ether);
mockToken.mint(user5, 100 ether);
mockToken.mint(attacker, 100 ether);
}
/**
* @notice POC: Attack Vector - No validation that eventEndDate > eventStartDate
* @dev Severity: CRITICAL
* @dev Description: If eventEndDate < eventStartDate, setWinner() can never be called
* @dev Impact: All deposited funds become permanently locked with no recovery mechanism
* @dev Exploit Steps:
* 1. Deploy with eventEndDate < eventStartDate
* 2. Users deposit funds thinking they have time
* 3. Time passes beyond eventEndDate
* 4. Owner tries to call setWinner() - REVERTS with eventNotStarted()
* 5. Users cannot withdraw - FUNDS PERMANENTLY LOCKED
*/
function test_POC_eventEndDateBeforeStartDate_Exploit() public {
// Step 1: Deploy contract with malicious/accidental invalid dates
// eventEndDate < eventStartDate (backwards timeline)
uint256 maliciousEventStartDate = block.timestamp + 1000000; // Far future
uint256 maliciousEventEndDate = block.timestamp + 500000; // Before start date!
console.log("=== POC: eventEndDate < eventStartDate Vulnerability ===");
console.log("Deployment timestamp:", block.timestamp);
console.log("eventStartDate:", maliciousEventStartDate);
console.log("eventEndDate:", maliciousEventEndDate);
console.log(
"eventEndDate < eventStartDate:",
maliciousEventEndDate < maliciousEventStartDate
);
vm.startPrank(owner);
BriVault maliciousVault = new BriVault(
IERC20(address(mockToken)),
participationFeeBsp,
maliciousEventStartDate, // 1000000 seconds in future
participationFeeAddress,
minimumAmount,
maliciousEventEndDate // 500000 seconds in future (BEFORE start!)
);
vm.stopPrank();
// Verify the contract was deployed with invalid dates
assertLt(
maliciousVault.eventEndDate(),
maliciousVault.eventStartDate(),
"Vulnerability: eventEndDate should be less than eventStartDate"
);
// Step 2: Users deposit thinking they have time
console.log("\n--- Step 2: Users deposit funds ---");
uint256 user1Deposit = 5 ether;
uint256 user2Deposit = 3 ether;
uint256 user3Deposit = 4 ether;
// User1 deposits
vm.startPrank(user1);
mockToken.approve(address(maliciousVault), user1Deposit);
maliciousVault.deposit(user1Deposit, user1);
uint256 user1BalanceAfterDeposit = mockToken.balanceOf(user1);
console.log("User1 deposited:", user1Deposit);
console.log("User1 balance after deposit:", user1BalanceAfterDeposit);
vm.stopPrank();
// User2 deposits
vm.startPrank(user2);
mockToken.approve(address(maliciousVault), user2Deposit);
maliciousVault.deposit(user2Deposit, user2);
uint256 user2BalanceAfterDeposit = mockToken.balanceOf(user2);
console.log("User2 deposited:", user2Deposit);
console.log("User2 balance after deposit:", user2BalanceAfterDeposit);
vm.stopPrank();
// User3 deposits
vm.startPrank(user3);
mockToken.approve(address(maliciousVault), user3Deposit);
maliciousVault.deposit(user3Deposit, user3);
uint256 user3BalanceAfterDeposit = mockToken.balanceOf(user3);
console.log("User3 deposited:", user3Deposit);
console.log("User3 balance after deposit:", user3BalanceAfterDeposit);
vm.stopPrank();
// Verify deposits were successful
uint256 vaultBalance = mockToken.balanceOf(address(maliciousVault));
console.log("Vault balance after deposits:", vaultBalance);
assertGt(vaultBalance, 0, "Vault should have received deposits");
// Users join the event
vm.startPrank(owner);
maliciousVault.setCountry(countries);
vm.stopPrank();
vm.startPrank(user1);
maliciousVault.joinEvent(10); // Japan
console.log("User1 joined event for country 10");
vm.stopPrank();
vm.startPrank(user2);
maliciousVault.joinEvent(20); // France
console.log("User2 joined event for country 20");
vm.stopPrank();
// Step 3: Move time to after eventEndDate (but before eventStartDate)
console.log("\n--- Step 3: Moving to eventEndDate timestamp ---");
vm.warp(maliciousEventEndDate + 1);
console.log("Current timestamp:", block.timestamp);
console.log("eventEndDate:", maliciousVault.eventEndDate());
console.log("eventStartDate:", maliciousVault.eventStartDate());
assertGe(
block.timestamp,
maliciousVault.eventEndDate(),
"Should be past eventEndDate"
);
assertLt(
block.timestamp,
maliciousVault.eventStartDate(),
"But still before eventStartDate"
);
// Step 4: Owner tries to call setWinner() - THIS WILL FAIL
console.log("\n--- Step 4: Owner calls setWinner immediately ---");
vm.startPrank(owner);
// This should FAIL because _setFinallizedVaultBalance checks eventStartDate
// block.timestamp (500001) <= eventStartDate (1000000) → REVERT
vm.expectRevert(abi.encodeWithSignature("eventNotStarted()"));
maliciousVault.setWinner(10);
console.log("setWinner() REVERTED with eventNotStarted()");
vm.stopPrank();
// Step 5: Verify the impact - FUNDS ARE PERMANENTLY LOCKED
console.log("\n--- Step 5: Verify Impact - PERMANENT FUND LOCK ---");
// Try to withdraw - should fail because winner not set
vm.startPrank(user1);
vm.expectRevert(abi.encodeWithSignature("winnerNotSet()"));
maliciousVault.withdraw();
console.log("User1 withdrawal BLOCKED: winnerNotSet");
vm.stopPrank();
// Verify funds are stuck in vault
uint256 lockedFunds = mockToken.balanceOf(address(maliciousVault));
console.log("\n=== EXPLOIT IMPACT ===");
console.log("Total funds locked in vault:", lockedFunds);
console.log("User1 balance after deposit:", user1BalanceAfterDeposit);
console.log("User2 balance after deposit:", user2BalanceAfterDeposit);
console.log("User3 balance after deposit:", user3BalanceAfterDeposit);
console.log("\n✗ setWinner() CANNOT be called");
console.log("✗ Users CANNOT withdraw");
console.log("✗ Users CANNOT cancel (past start time check)");
console.log("✗ NO recovery mechanism exists");
console.log("\n🔴 RESULT: ALL FUNDS PERMANENTLY LOCKED");
assertGt(lockedFunds, 0, "Funds are locked in vault");
assertEq(maliciousVault._setWinner(), false, "Winner was never set");
}
}

Run with:

forge test test/briVaultAuditPOC.t.sol --via-ir -vv

Expected Output:

Ran 1 test for test/briVaultAuditPOC.t.sol:BriVaultAuditPOCTest
[FAIL: eventNotStarted()] test_POC_eventEndDateBeforeStartDate_Exploit() (gas: 5610111)
Logs:
=== POC: eventEndDate < eventStartDate Vulnerability ===
Deployment timestamp: 1
eventStartDate: 1000001
eventEndDate: 500001
eventEndDate < eventStartDate: true
--- Step 2: Users deposit funds ---
User1 deposited: 5000000000000000000
User1 balance after deposit: 95000000000000000000
User2 deposited: 3000000000000000000
User2 balance after deposit: 97000000000000000000
User3 deposited: 4000000000000000000
User3 balance after deposit: 96000000000000000000
Vault balance after deposits: 11820000000000000000
User1 joined event for country 10 ✓
User2 joined event for country 20 ✓
--- Step 3: Moving to eventEndDate timestamp ---
Current timestamp: 500002
eventEndDate: 500001
eventStartDate: 1000001
--- Step 4: Owner calls setWinner immediately ---
❌ REVERTED: eventNotStarted()
Suite result: FAILED. 0 passed; 1 failed; 0 skipped
Failing tests:
Encountered 1 failing test in test/briVaultAuditPOC.t.sol:BriVaultAuditPOCTest
[FAIL: eventNotStarted()] test_POC_eventEndDateBeforeStartDate_Exploit()
Encountered a total of 1 failing tests, 0 tests succeeded

Analysis:

Test correctly demonstrates the vulnerability:

  • Contract deployed with eventEndDate (500001) < eventStartDate (1000001)

  • Users successfully deposited 11.82 ETH into vault

  • Users joined their selected countries

  • Time warped to 500002 (past eventEndDate)

  • Owner attempted setWinner()REVERTED with eventNotStarted()

  • The test "fails" because setWinner() cannot be called

  • This proves all 11.82 ETH is permanently locked

Why the test "fails":
The test fails with [FAIL: eventNotStarted()] because the setWinner() call reverts, which is the expected behavior that proves the bug. In this case, a "failing" test is actually a successful demonstration of the vulnerability.

Recommended Mitigation

constructor(
IERC20 _asset,
uint256 _participationFeeBsp,
uint256 _eventStartDate,
address _participationFeeAddress,
uint256 _minimumAmount,
uint256 _eventEndDate
) ERC4626(_asset) ERC20("BriTechLabs", "BTT") Ownable(msg.sender) {
if (_participationFeeBsp > PARTICIPATIONFEEBSPMAX) {
revert limiteExceede();
}
+ require(_eventEndDate > _eventStartDate, "End date must be after start date");
+ require(_eventStartDate > block.timestamp, "Start date must be in future");
participationFeeBsp = _participationFeeBsp;
eventStartDate = _eventStartDate;
eventEndDate = _eventEndDate;
participationFeeAddress = _participationFeeAddress;
minimumAmount = _minimumAmount;
_setWinner = false;
}
function _setFinallizedVaultBalance() internal returns (uint256) {
- if (block.timestamp <= eventStartDate) {
- revert eventNotStarted();
+ if (block.timestamp <= eventEndDate) {
+ revert eventNotEnded();
}
return finalizedVaultAsset = IERC20(asset()).balanceOf(address(this));
}

Additional Safety Recommendation - Emergency Recovery Function:

+ error EmergencyDelayNotPassed();
+ error WinnerAlreadySet();
+
+ uint256 public constant EMERGENCY_DELAY = 30 days;
+
+ /// @notice Emergency withdrawal in case of critical contract failure
+ /// @dev Can only be called 30 days after eventEndDate if winner was never set
+ function emergencyWithdrawAll() external onlyOwner {
+ if (block.timestamp <= eventEndDate + EMERGENCY_DELAY) {
+ revert EmergencyDelayNotPassed();
+ }
+ if (_setWinner) {
+ revert WinnerAlreadySet();
+ }
+
+ uint256 balance = IERC20(asset()).balanceOf(address(this));
+ IERC20(asset()).safeTransfer(owner(), balance);
+
+ emit EmergencyWithdrawal(owner(), balance);
+ }
+
+ event EmergencyWithdrawal(address indexed owner, uint256 amount);
Updates

Appeal created

bube Lead Judge 19 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Missing Constructor Validation

This is owner action and the owner is assumed to be trusted and to provide correct input arguments.

Support

FAQs

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

Give us feedback!