BakerFi Invitational
Findings & Analysis Report

2025-02-26

Table of contents

Overview

About C4

Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.

A C4 audit is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.

During the audit outlined in this document, C4 conducted an analysis of the BakerFi smart contract system written in Solidity. The audit took place from December 11, 2024 to December 31, 2024.

This audit was judged by Dravee.

Final report assembled by Code4rena.

Following the C4 audit, 2 wardens (0xlemon and shaflow2) reviewed the mitigations for all identified issues; the mitigation review report is appended below the audit report.

Summary

The C4 analysis yielded an aggregated total of 23 unique vulnerabilities. Of these vulnerabilities, 7 received a risk rating in the category of HIGH severity and 16 received a risk rating in the category of MEDIUM severity.

Additionally, C4 analysis included 1 report detailing issues with a risk rating of LOW severity or non-critical.

All of the issues presented here are linked back to their original finding.

Scope

The code under review can be found within the C4 BakerFi repository, and is composed of 23 smart contracts written in the Solidity programming language and includes 1,910 lines of Solidity code.

Severity Criteria

C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.

High-level considerations for vulnerabilities span the following key areas when conducting assessments:

  • Malicious Input Handling
  • Escalation of privileges
  • Arithmetic
  • Gas use

For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.

High Risk Findings (7)

[H-01] Users may encounter losses on assets deposited through StrategySupplyERC4626

Submitted by 0xpiken, also found by 0xlemon, klau5, klau5, MrPotatoMagic, and shaflow2

https://github.com/code-423n4/2024-12-bakerfi/blob/main/contracts/core/strategies/StrategySupplyERC4626.sol#L44

https://github.com/code-423n4/2024-12-bakerfi/blob/main/contracts/core/strategies/StrategySupplyERC4626.sol#L51

https://github.com/code-423n4/2024-12-bakerfi/blob/main/contracts/core/strategies/StrategySupplyERC4626.sol#L58

Finding description and impact

The _deploy(), _undeploy(), and _getBalance() functions of StrategySupplyERC4626 currently return the amount of shares instead of the amount of the underlying asset. This mistake leads to incorrect calculations of user assets within any BakerFi Vault that utilizes StrategySupplyERC4626.

Proof of Concept

When a user deposits a certain amount of asset (deployedAmount) into a BakerFi vault, it is deployed into the vault’s underlying strategies. In return, the user receives a corresponding number of shares:

    function _depositInternal(uint256 assets, address receiver) private returns (uint256 shares) {
        if (receiver == address(0)) revert InvalidReceiver();
        // Fetch price options from settings
        // Get the total assets and total supply
        Rebase memory total = Rebase(totalAssets(), totalSupply());

        // Check if the Rebase is uninitialized or both base and elastic are positive
        if (!((total.elastic == 0 && total.base == 0) || (total.base > 0 && total.elastic > 0))) {
            revert InvalidAssetsState();
        }

        // Check if deposit exceeds the maximum allowed per wallet
        uint256 maxDepositLocal = getMaxDeposit();
        if (maxDepositLocal > 0) {
            uint256 depositInAssets = (balanceOf(msg.sender) * _ONE) / tokenPerAsset();
            uint256 newBalance = assets + depositInAssets;
            if (newBalance > maxDepositLocal) revert MaxDepositReached();
        }

@>      uint256 deployedAmount = _deploy(assets);

        // Calculate shares to mint
@>      shares = total.toBase(deployedAmount, false);

        // Prevent inflation attack for the first deposit
        if (total.base == 0 && shares < _MINIMUM_SHARE_BALANCE) {
            revert InvalidShareBalance();
        }

        // Mint shares to the receiver
        _mint(receiver, shares);

        // Emit deposit event
        emit Deposit(msg.sender, receiver, assets, shares);
    }

To withdraw their deployed assets from a BakerFi vault, users must burn a corresponding number of shares to receive a certain amount of assets:

    function _redeemInternal(
        uint256 shares,
        address receiver,
        address holder,
        bool shouldRedeemETH
    ) private returns (uint256 retAmount) {
        if (shares == 0) revert InvalidAmount();
        if (receiver == address(0)) revert InvalidReceiver();
        if (balanceOf(holder) < shares) revert NotEnoughBalanceToWithdraw();

        // Transfer shares to the contract if sender is not the holder
        if (msg.sender != holder) {
            if (allowance(holder, msg.sender) < shares) revert NoAllowance();
            transferFrom(holder, msg.sender, shares);
        }

        // Calculate the amount to withdraw based on shares
        uint256 withdrawAmount = (shares * totalAssets()) / totalSupply();
        if (withdrawAmount == 0) revert NoAssetsToWithdraw();

@>      uint256 amount = _undeploy(withdrawAmount);
        uint256 fee = 0;
        uint256 remainingShares = totalSupply() - shares;

        // Ensure a minimum number of shares are maintained to prevent ratio distortion
        if (remainingShares < _MINIMUM_SHARE_BALANCE && remainingShares != 0) {
            revert InvalidShareBalance();
        }

@>      _burn(msg.sender, shares);

        // Calculate and handle withdrawal fees
        if (getWithdrawalFee() != 0 && getFeeReceiver() != address(0)) {
            fee = amount.mulDivUp(getWithdrawalFee(), PERCENTAGE_PRECISION);

            if (shouldRedeemETH && _asset() == wETHA()) {
                unwrapETH(amount);
                payable(receiver).sendValue(amount - fee);
                payable(getFeeReceiver()).sendValue(fee);
            } else {
                IERC20Upgradeable(_asset()).transfer(receiver, amount - fee);
                IERC20Upgradeable(_asset()).transfer(getFeeReceiver(), fee);
            }
        } else {
            if (shouldRedeemETH) {
                unwrapETH(amount);
                payable(receiver).sendValue(amount);
            } else {
                IERC20Upgradeable(_asset()).transfer(receiver, amount);
            }
        }

        emit Withdraw(msg.sender, receiver, holder, amount - fee, shares);
        retAmount = amount - fee;
    }

As we can see, the return values of _deploy() and _undeploy() should represent the amount of asset. In addition, _totalAssets() should also return the amount of asset. The implementation of the above functions within the Vault contract is as follows:

    function _deploy(uint256 assets) internal virtual override returns (uint256 deployedAmount) {
        // Approve the strategy to spend assets
        IERC20Upgradeable(_strategyAsset).safeApprove(address(_strategy), assets);
        // Deploy assets via the strategy
        deployedAmount = _strategy.deploy(assets); // Calls the deploy function of the strategy
    }

    function _undeploy(uint256 assets) internal virtual override returns (uint256 retAmount) {
        retAmount = _strategy.undeploy(assets); // Calls the undeploy function of the strategy
    }

    function _totalAssets() internal view virtual override returns (uint256 amount) {
        amount = _strategy.totalAssets(); // Calls the totalAssets function of the strategy
    }

It is obvious that the return value should represent the amount of assets when _strategy.deploy(), _strategy.undeploy() or _strategy.totalAssets() is called.

However, the functions in StrategySupplyERC4626 mistakenly return the number of shares other than the amount of underlying asset:

    /**
     * @inheritdoc StrategySupplyBase
     */
    function _deploy(uint256 amount) internal override returns (uint256) {
        return _vault.deposit(amount, address(this));
    }

    /**
     * @inheritdoc StrategySupplyBase
     */
    function _undeploy(uint256 amount) internal override returns (uint256) {
        return _vault.withdraw(amount, address(this), address(this));
    }

    /**
     * @inheritdoc StrategySupplyBase
     */
    function _getBalance() internal view override returns (uint256) {
        return _vault.balanceOf(address(this));
    }

This issue could lead to a scenario where a portion of user assets are permanently locked within the BakerFi vault. Create ERC4626Mock contract with below codes:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract ERC4626Mock is ERC4626 {
    constructor(IERC20 asset_) ERC4626(asset_) ERC20("Mock Vault", "MV") {
    }
}

Create StrategySupplyERC4626.ts with below codes and run npm run test:

import { describeif } from '../../common';

import '@nomicfoundation/hardhat-ethers';
import { loadFixture } from '@nomicfoundation/hardhat-network-helpers';
import { expect } from 'chai';
import { ethers, network } from 'hardhat';
import {
  deployVaultRegistry,
  deployStEth,
  deployAaveV3,
  deployWETH,
  deployVault
} from '../../../scripts/common';
describeif(network.name === 'hardhat')('Strategy Supply ERC4626', function () {
  // Fixture to deploy contracts before each test
  async function deployStrategySupplyERC4626() {
    const [owner, otherAccount] = await ethers.getSigners();

    const BakerFiProxyAdmin = await ethers.getContractFactory('BakerFiProxyAdmin');
    const proxyAdmin = await BakerFiProxyAdmin.deploy(owner.address);
    await proxyAdmin.waitForDeployment();

    const MAX_SUPPLY = ethers.parseUnits('1000000000');
    const WETH = await ethers.getContractFactory('WETH');
    const weth = await WETH.deploy();
    await weth.waitForDeployment();
    // Deposit ETH
    await weth.deposit?.call('', { value: ethers.parseUnits('100', 18) });
    const ERC4626Vault = await ethers.getContractFactory("ERC4626Mock");
    const erc4626Vault = await ERC4626Vault.deploy(weth.getAddress());
    await erc4626Vault.waitForDeployment();

    // Deploy StrategySupply contract
    const StrategySupply = await ethers.getContractFactory('StrategySupplyERC4626');
    const strategy = await StrategySupply.deploy(
      owner.address,
      await weth.getAddress(),
      await erc4626Vault.getAddress(),
    );
    await strategy.waitForDeployment();

    const { proxy: vaultProxy } = await deployVault(
        owner.address,
        'Bread ETH',
        'brETH',
        await strategy.getAddress(),
        await weth.getAddress(),
        proxyAdmin,
    );
    await strategy.transferOwnership(await vaultProxy.getAddress());
    const vault = await ethers.getContractAt('Vault', await vaultProxy.getAddress());
    return { weth, strategy, owner, otherAccount, erc4626Vault, vault};
  }

  it.only('Deposit 10 ether but 5 ether can be withdrawn', async () => {
    const { weth, strategy, owner, otherAccount, erc4626Vault, vault } = await loadFixture(deployStrategySupplyERC4626);
    const tenEther  = ethers.parseEther('10');
    const fiveEther = ethers.parseEther('5');
    //@audit-info owner deposits 10e18 WETH into erc4626Vault
    await weth.approve(erc4626Vault.getAddress(), tenEther);
    await erc4626Vault.deposit(tenEther, owner.address);
    //@audit-info 10e18 shares are minted to owner
    expect(await erc4626Vault.balanceOf(owner.address)).to.equal(tenEther);
    //@audit-info transfer 10e18 WETH to erc4626Vault  
    await weth.transfer(erc4626Vault.getAddress(), tenEther);
    //@audit-info erc4646Vault has 20e18 WETH with total supply of 10e18 shares
    expect(await weth.balanceOf(erc4626Vault.getAddress())).to.equal(ethers.parseEther('20'));
    expect(await erc4626Vault.totalSupply()).to.equal(ethers.parseEther('10'));
    //@audit-info owner deposits 10e18 WETH into vault
    await vault.enableAccount(owner.address, true);
    await weth.approve(vault.getAddress(), tenEther);
    await vault.deposit(tenEther, owner.address);
    //@audit-info however, only 5e18 WETH can be withdrawn, the rest 5e18 WETH will be locked forever
    expect(await vault.maxWithdraw(owner.address)).to.equal(fiveEther);
  });
});

As we can see that only 5e18 WETH can be withdrawn within 10e18 WETH deployed. The rest 5e18 WETH are permanently locked within the BakerFi vault. The amount of locked asset can be calculated as below:

Note: please see scenario in warden’s original submission.

Update StrategySupplyERC4626 to return correct value:

    function _deploy(uint256 amount) internal override returns (uint256) {
-       return _vault.deposit(amount, address(this));
+      _vault.deposit(amount, address(this));
+      return amount;
    }

    /**
     * @inheritdoc StrategySupplyBase
     */
    function _undeploy(uint256 amount) internal override returns (uint256) {
-       return _vault.withdraw(amount, address(this), address(this));
+       _vault.withdraw(amount, address(this), address(this));
+       return amount;
    }

    /**
     * @inheritdoc StrategySupplyBase
     */
    function _getBalance() internal view override returns (uint256) {
-       return _vault.balanceOf(address(this));
+       return _vault.convertToAssets(_vault.balanceOf(address(this)));
    }

chefkenji (BakerFi) confirmed

BakerFi mitigated:

PR-17

Status: Mitigation confirmed. Full details in reports from shaflow2 and 0xlemon.


[H-02] Anyone can call StrategySupplyBase.harvest, allowing users to avoid paying performance fees on interest

Submitted by klau5, also found by 0xlemon, 0xpiken, and shaflow2

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/strategies/StrategySupplyBase.sol#L90

Finding description and impact

Since StrategySupplyBase.harvest can be called by anyone, users can front-run the rebalance call or regularly call harvest to avoid paying protocol fees on interest. This allows users to receive more interest than they should.

Proof of Concept

When there are profits in the Strategy, the administrator calls rebalance to settle protocol fees(performance fee). This calls Strategy.harvest to update the total deployed asset amount including interest and returns the amount of newly generated interest. Then n% of the interest is taken as protocol fees.

function _harvestAndMintFees() internal {
    uint256 currentPosition = _totalAssets();
    if (currentPosition == 0) {
        return;
    }
@>  int256 balanceChange = _harvest();
    if (balanceChange > 0) {
        address feeReceiver = getFeeReceiver();
        uint256 performanceFee = getPerformanceFee();
        if (feeReceiver != address(this) && feeReceiver != address(0) && performanceFee > 0) {
            uint256 feeInEth = uint256(balanceChange) * performanceFee;
            uint256 sharesToMint = feeInEth.mulDivUp(totalSupply(), currentPosition * PERCENTAGE_PRECISION);
@>          _mint(feeReceiver, sharesToMint);
        }
    }
}

function _harvest() internal virtual override returns (int256 balanceChange) {
@>  return _strategy.harvest(); // Calls the harvest function of the strategy
}

However, StrategySupplyBase.harvest can be called by anyone. By front-running the rebalance request or regularly calling this function, users can avoid paying protocol fees on interest. This allows users to receive more interest than they should.

@>  function harvest() external returns (int256 balanceChange) {
        // Get Balance
        uint256 newBalance = getBalance();

@>      balanceChange = int256(newBalance) - int256(_deployedAmount);

        if (balanceChange > 0) {
            emit StrategyProfit(uint256(balanceChange));
        } else if (balanceChange < 0) {
            emit StrategyLoss(uint256(-balanceChange));
        }
        if (balanceChange != 0) {
            emit StrategyAmountUpdate(newBalance);
        }
@>      _deployedAmount = newBalance;
    }

This is PoC. It demonstrates that anyone can call StrategySupplyBase.harvest. This can be run by adding it to the StrategySupplyAAVEv3.ts file.

it('PoC - anyone can call harvest', async () => {
  const { owner, strategySupply, stETH, aave3Pool, otherAccount } = await loadFixture(
    deployStrategySupplyFixture,
  );
  const deployAmount = ethers.parseEther('10');
  await stETH.approve(await strategySupply.getAddress(), deployAmount);
  await strategySupply.deploy(deployAmount);

  //artificial profit
  await aave3Pool.mintAtokensArbitrarily(await strategySupply.getAddress(), deployAmount);

  await expect(strategySupply.connect(otherAccount).harvest())
    .to.emit(strategySupply, 'StrategyProfit')
    .to.emit(strategySupply, 'StrategyAmountUpdate');
});

Add the onlyOwner modifier to StrategySupplyBase.harvest to restrict access.

chefkenji (BakerFi) confirmed

BakerFi mitigated:

PR-15

Status: Mitigation confirmed. Full details in reports from 0xlemon and shaflow2.


[H-03] _deployedAmount not updated on StrategySupplyBase.undeploy, preventing performance fees from being collected

Submitted by klau5, also found by 0xlemon, 0xpiken, MrPotatoMagic, pfapostol, and shaflow2

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/strategies/StrategySupplyBase.sol#L110

Finding description and impact

StrategySupplyBase.undeploy does not update _deployedAmount. As a result, if a withdrawal occurs, even if interest is generated, the protocol cannot collect performance fees through rebalance.

Proof of Concept

StrategySupplyBase.undeploy does not update _deployedAmount. It should subtract the amount of withdrawn asset tokens.

function undeploy(uint256 amount) external nonReentrant onlyOwner returns (uint256 undeployedAmount) {
    if (amount == 0) revert ZeroAmount();

    // Get Balance
    uint256 balance = getBalance();
    if (amount > balance) revert InsufficientBalance();

    // Transfer assets back to caller
    uint256 withdrawalValue = _undeploy(amount);

    // Check withdrawal value matches the initial amount
    // Transfer assets to user
    ERC20(_asset).safeTransfer(msg.sender, withdrawalValue);

    balance -= amount;
    emit StrategyUndeploy(msg.sender, withdrawalValue);
    emit StrategyAmountUpdate(balance);

    return amount;
}

As a result, if a withdrawal occurs, even if interest is generated, the protocol cannot collect performance fees through rebalance. This is because if the withdrawal amount is greater than the interest earned, the Strategy is considered to have a loss and no fee is taken.

  • _deployedAmount: A
  • Interest generated, getBalance returns A + profit
  • Request to withdraw amount B

    • _deployedAmount is still A
    • getBalance returns A + profit - B
  • During rebalance, balanceChange is (A + profit - B) - A

    • That is, if profit <= B, the Strategy is considered to have a loss.
function harvest() external returns (int256 balanceChange) {
    // Get Balance
    uint256 newBalance = getBalance();

@>  balanceChange = int256(newBalance) - int256(_deployedAmount);

    if (balanceChange > 0) {
        emit StrategyProfit(uint256(balanceChange));
    } else if (balanceChange < 0) {
        emit StrategyLoss(uint256(-balanceChange));
    }
    if (balanceChange != 0) {
        emit StrategyAmountUpdate(newBalance);
    }
    _deployedAmount = newBalance;
}

This is PoC. This shows that when harvested after withdrawal, the Strategy is considered to have a loss. This can be executed by adding it to the StrategySupplyAAVEv3.ts file.

it('PoC - harvest returns loss after undeloy', async () => {
  const { owner, strategySupply, stETH, aave3Pool, otherAccount } = await loadFixture(
    deployStrategySupplyFixture,
  );
  const deployAmount = ethers.parseEther('10');
  await stETH.approve(await strategySupply.getAddress(), deployAmount);
  await strategySupply.deploy(deployAmount);

  //artificial profit
  const profit = ethers.parseEther('1');
  await aave3Pool.mintAtokensArbitrarily(await strategySupply.getAddress(), profit);

  // Undeploy
  const undeployAmount = ethers.parseEther('2');
  await strategySupply.undeploy(undeployAmount);

  await expect(strategySupply.harvest())
    .to.emit(strategySupply, 'StrategyLoss')
    .to.emit(strategySupply, 'StrategyAmountUpdate');
});

Update _deployedAmount by the withdrawal amount in StrategySupplyBase.undeploy.

chefkenji (BakerFi) confirmed

BakerFi mitigated:

PR-12

Status: Mitigation confirmed. Full details in reports from shaflow2 and 0xlemon.


[H-04] There are multiple issues with the decimal conversions between the vault and the strategy

Submitted by shaflow2, also found by 0xlemon, 0xpiken, ABAIKUNANBAEV, klau5, and shaflow2

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/strategies/StrategyLeverage.sol#L234

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/strategies/StrategyLeverage.sol#L347

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/strategies/StrategyLeverage.sol#L359

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/strategies/StrategyLeverage.sol#L673

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/strategies/StrategyLeverage.sol#L640

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/strategies/StrategySupplyBase.sol#L110

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/strategies/StrategySupplyBase.sol#L69

Finding description and impact

The StrategyLeverage contract has multiple incorrect decimal handling issues, causing the system to not support tokens with decimals other than 18.

Proof of Concept

First, the vault contract’s share decimal is set to 18, as recommended by the ERC4626 standard. Ideally, the vault’s share decimal should reflect the underlying token’s decimal. Otherwise, conversions through convertToShares and convertToAssets would be required.

In StrategyLeverage, we can see that all calls to totalAssets() are converted to 18 decimals for share calculations.

Under the above premise, the contract has multiple decimal handling errors, making it incompatible with tokens that use decimals other than 18:

  1. The _deploy function should return the amount in the system’s 18-decimal format, rather than the token’s native decimal format.

    function _depositInternal(uint256 assets, address receiver) private returns (uint256 shares) {
        ...
        uint256 deployedAmount = _deploy(assets);
    
        // Calculate shares to mint
        shares = total.toBase(deployedAmount, false);
    
        // Prevent inflation attack for the first deposit
        if (total.base == 0 && shares < _MINIMUM_SHARE_BALANCE) {
            revert InvalidShareBalance();
        }
    
        // Mint shares to the receiver
        _mint(receiver, shares);
    
        // Emit deposit event
        emit Deposit(msg.sender, receiver, assets, shares);
    }

    The _deploy function is used to calculate shares, so it should return the amount in the system’s 18-decimal format. However, the strategy always returns the amount in the token’s native decimal format. To address this, the _pendingAmount in the _supplyBorrow function should be converted to 18-decimal format.

  2. In the _redeemInternal process, the withdrawAmount passed to _undeploy is in 18-decimal format (since totalAssets returns 18-decimal values).

    function _redeemInternal(
        uint256 shares,
        address receiver,
        address holder,
        bool shouldRedeemETH
    ) private returns (uint256 retAmount) {
        if (shares == 0) revert InvalidAmount();
        if (receiver == address(0)) revert InvalidReceiver();
        if (balanceOf(holder) < shares) revert NotEnoughBalanceToWithdraw();
    
        // Transfer shares to the contract if sender is not the holder
        if (msg.sender != holder) {
            if (allowance(holder, msg.sender) < shares) revert NoAllowance();
            transferFrom(holder, msg.sender, shares); 
        }
    
        // Calculate the amount to withdraw based on shares
        uint256 withdrawAmount = (shares * totalAssets()) / totalSupply();
        if (withdrawAmount == 0) revert NoAssetsToWithdraw();

@> uint256 amount = _undeploy(withdrawAmount); …

Therefore, in the `undeploy` process, `deltaCollateralAmount` is in 18-decimal format. It is directly packed into `data` and passed to `_repayAndWithdraw` during the callback.  

As a result, the `_withdraw` functions in `StrategyLeverageAAVEv3` and `StrategyLeverageMorphoBlue` should convert the input `amount` from 18-decimal format to the token's actual decimal format. Otherwise, the wrong amount will be withdrawn.

3. In the `_undeploy` process, `deltaDebt` and fees should be converted from 18-decimal format to the `debtToken`'s actual decimal format.

4. The `_convertToCollateral` and `_convertToDebt` functions expect the `amount` parameter to be in 18-decimal format, as required for calculations by `_toDebt` and `_toCollateral` using the oracle. However, before proceeding with the swap, the amount needs to be converted to the respective token's actual decimal format.
Additionally, `_convertToCollateral` receives the token's original decimal `amount` during the deploy process, leading to incorrect calculations by the oracle.
```solidity
    /**
     * @dev Internal function to convert the specified amount from Debt Token to the underlying collateral asset cbETH, wstETH, rETH.
     *
     * This function is virtual and intended to be overridden in derived contracts for customized implementation.
     *
     * @param amount The amount to convert from debtToken.
     * @return uint256 The converted amount in the underlying collateral.
     */
    function _convertToCollateral(uint256 amount) internal virtual returns (uint256) {
        uint256 amountOutMinimum = 0;

        if (getMaxSlippage() > 0) {
            uint256 wsthETHAmount = _toCollateral(
                IOracle.PriceOptions({maxAge: getPriceMaxAge(), maxConf: getPriceMaxConf()}),
                amount,
                false
            );
            amountOutMinimum = (wsthETHAmount * (PERCENTAGE_PRECISION - getMaxSlippage())) / PERCENTAGE_PRECISION;
        }
        // 1. Swap Debt Token -> Collateral Token
        (, uint256 amountOut) = swap(
            ISwapHandler.SwapParams(
                _debtToken, // Asset In
                _collateralToken, // Asset Out
                ISwapHandler.SwapType.EXACT_INPUT, // Swap Mode
                amount, // Amount In
                amountOutMinimum, // Amount Out
                bytes("") // User Payload
            )
        );
        return amountOut;
    }

    /**
     * @dev Internal function to convert the specified amount to Debt Token from the underlying collateral.
     *
     * This function is virtual and intended to be overridden in derived contracts for customized implementation.
     *
     * @param amount The amount to convert to Debt Token.
     * @return uint256 The converted amount in Debt Token.
     */
    function _convertToDebt(uint256 amount) internal virtual returns (uint256) {
        uint256 amountOutMinimum = 0;
        if (getMaxSlippage() > 0) {
            uint256 ethAmount = _toDebt(
                IOracle.PriceOptions({maxAge: getPriceMaxAge(), maxConf: getPriceMaxConf()}),
                amount,
                false
            );
            amountOutMinimum = (ethAmount * (PERCENTAGE_PRECISION - getMaxSlippage())) / PERCENTAGE_PRECISION;
        }
        // 1.Swap Colalteral -> Debt Token
        (, uint256 amountOut) = swap(
            ISwapHandler.SwapParams(
                _collateralToken, // Asset In
                _debtToken, // Asset Out
                ISwapHandler.SwapType.EXACT_INPUT, // Swap Mode
                amount, // Amount In
                amountOutMinimum, // Amount Out
                bytes("") // User Payload
            )
        );
        return amountOut;
    }
  1. The _convertToCollateral and _convertToDebt functions default to returning the amount in the token’s actual decimal format. However, certain parts of the code assume they return the amount in 18-decimal format, leading to potential miscalculations.
  2. The _adjustDebt function should convert the flash loan amount from 18-decimal format to the token’s original decimal format.
  3. The _payDebt function will receive an amount in 18-decimal format, but when performing the swap, the amount is not converted to the token’s actual decimal format. This can lead to incorrect calculations during the swap process.

It is recommended to align the vault’s decimals with the underlying token’s decimals instead of using 18 decimals. This alignment can significantly reduce the complexity of decimal conversions throughout the system.

chefkenji (BakerFi) confirmed

BakerFi mitigated:

PR-24

Status: Mitigation confirmed. Full details in reports from 0xlemon and shaflow2.


[H-05] The implementation of pullTokensWithPermit poses a risk, allowing malicious actors to steal tokens

Submitted by shaflow2, also found by 0xlemon and MrPotatoMagic

https://github.com/code-423n4/2024-12-bakerfi/blob/3873b82ae8b321473f3afaf08727e97be0635be9/contracts/core/hooks/UsePermitTransfers.sol#L31

Finding description and impact

In batch operations interacting with the router, users are allowed to input tokens into the router using the permit method. This approach may be vulnerable to frontrunning attacks, allowing malicious actors to steal the user’s tokens.

Proof of Concept

    function pullTokensWithPermit(
        IERC20Permit token,
        uint256 amount,
        address owner,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) internal virtual {
        // Permit the VaultRouter to spend tokens on behalf of the owner
        IERC20Permit(token).permit(owner, address(this), amount, deadline, v, r, s);

        // Transfer the tokens from the owner to this contract
        IERC20(address(token)).safeTransferFrom(owner, address(this), amount);
    }

Users can deposit tokens into the router via the pullTokensWithPermit function. However, the router contract does not validate the caller’s information, making it possible for a malicious actor to frontrun the user and exploit their permit signature to steal tokens.

Consider the following scenario:

  1. The user interacts with the router contract:

    • Step 1: Calls pullTokensWithPermit to transfer 1000 tokens to the router.
    • Step 2: Deposits the tokens into a designated vault.
  2. A malicious actor observes the user’s transaction in the mempool and constructs a malicious transaction to steal the user’s tokens:

    • Step 1: The attacker calls pullTokensWithPermit using the user’s permit signature, causing the user to transfer 1000 tokens to the router.
    • Step 2: The attacker immediately calls sweepTokens to transfer the tokens to their own account. When the user’s original transaction is executed:
  3. The permit signature has already been used, causing the user’s transaction to fail. As a result, the user loses 1000 tokens.

Additionally, an attacker could frontrun the permit function without using the router and then call pushTokenFrom directly to steal tokens.

The current router is not suitable for integrating permit to handle token input.

chefkenji (BakerFi) confirmed

BakerFi mitigated:

PR-23

Status: Mitigation confirmed. Full details in reports from 0xlemon and shaflow2.


[H-06] Malicious actors can exploit user-approved allowances on VaultRouter to drain their ERC20 tokens

Submitted by 0xpiken, also found by 0xlemon, MrPotatoMagic, and shaflow2

https://github.com/code-423n4/2024-12-bakerfi/blob/main/contracts/core/VaultRouter.sol#L186-L202

https://github.com/code-423n4/2024-12-bakerfi/blob/main/contracts/core/VaultRouter.sol#L234-L252

Finding description and impact

Once a user approves VaultRouter to spend their ERC20 tokens, anyone could call VaultRouter#execute() to drain the user’s ERC20 assets.

Proof of Concept

The VaultRouter#execute() function allows users to perform multiple commands within a single transaction. One such use case involves depositing ERC20 tokens into VaultRouter using the PULL_TOKEN command. Subsequently, these tokens can be further processed within the same transaction through other commands, such as V3_UNISWAP_SWAP for token swaps or ERC4626_VAULT_DEPOSIT for depositing into an ERC4626 vault. Before depositing ERC20 tokens into VaultRouter using the PULL_TOKEN command, the user must approve VaultRouter to spend their ERC20 token in advance. However, a malicious actor can exploit this approval to drain the user’s ERC20 token through VaultRouter with PULL_TOKEN_FROM or PUSH_TOKEN_FROM commands:

  • A malicious actor can call PULL_TOKEN_FROM to transfer ERC20 token from the user into VaultRouter, then use PUSH_TOKEN command to transfer drained token from VaultRouter to specified address.
  • A malicious actor can call PUSH_TOKEN_FROM command transfer ERC20 token from the user to any address directly.

The root cause is that either PULL_TOKEN_FROM or PUSH_TOKEN_FROM command allows anyone to transfer a user’s ERC20 token as long as VaultRouter is approved to spend their assets: https://github.com/code-423n4/2024-12-bakerfi/blob/main/contracts/core/hooks/UseTokenActions.sol

    function pullTokenFrom(IERC20 token, address from, uint256 amount) internal virtual {
        // Check if the token address is valid
        if (address(token) == address(0)) revert InvalidToken();

        if (token.allowance(from, address(this)) < amount) revert NotEnoughAllowance();
        // Use SafeERC20 to transfer tokens from the specified address to this contract
@>      IERC20(token).safeTransferFrom(from, address(this), amount);
    }

    function pushTokenFrom(IERC20 token, address from, address to, uint256 amount) internal virtual {
        // Check if the token address is valid
        if (address(token) == address(0)) revert InvalidToken();
        // Check if the recipient address is valid
        if (address(to) == address(0)) revert InvalidRecipient();

        if (token.allowance(from, address(this)) < amount) revert NotEnoughAllowance();
        // Use SafeERC20 to transfer tokens from the specified address to another specified address
@>      IERC20(token).safeTransferFrom(from, to, amount);
    }

Copy below codes to VaultRouter.ts and run npm run test:

  it.only('Drain all WETH from owner', async function () {
    const { vaultRouter, weth, owner, otherAccount } = await deployFunction();
    //@audit-info owner has 10000e18 WETH
    expect(await weth.balanceOf(owner.address)).to.equal(ethers.parseUnits('10000', 18));
    //@audit-info owner approves vaultRouter to spend their WETH
    await weth.approve(await vaultRouter.getAddress(), ethers.parseUnits('10000', 18));
    let iface = new ethers.Interface(VaultRouterABI);
    const commands = [
      [
        VAULT_ROUTER_COMMAND_ACTIONS.PUSH_TOKEN_FROM,
        '0x' +
          iface
            .encodeFunctionData('pushTokenFrom', [await weth.getAddress(), owner.address, otherAccount.address, ethers.parseUnits('10000', 18)])
            .slice(10),
      ],
    ];
    //@audit-info otherAccount drains owner's WETH
    await vaultRouter.connect(otherAccount).execute(commands);
    expect(await weth.balanceOf(owner.address)).to.equal(0);
    expect(await weth.balanceOf(otherAccount.address)).to.equal(ethers.parseUnits('10000', 18));
  });

As we can see, owner’s all WETH was drained.

To protect users from potential exploitation, the PULL_TOKEN_FROM and PUSH_TOKEN_FROM commands should be executed only when msg.sender is from.

chefkenji (BakerFi) confirmed

BakerFi mitigated:

PR-20

Status: Mitigation confirmed. Full details in reports from 0xlemon and shaflow2.


[H-07] Malicious actors can exploit user-approved allowances on VaultRouter to drain their ERC4626 tokens

Submitted by 0xpiken, also found by MrPotatoMagic

https://github.com/code-423n4/2024-12-bakerfi/blob/main/contracts/core/VaultRouter.sol#L120

https://github.com/code-423n4/2024-12-bakerfi/blob/main/contracts/core/VaultRouter.sol#L122

Finding description and impact

Once a user approves VaultRouter to spend their ERC4626 shares, anyone could call VaultRouter#execute() to drain the user’s ERC4626 shares.

Proof of Concept

The VaultRouter#execute() function allows users to perform multiple commands within a single transaction. A user can redeem their ERC4626 shares for underlying assets through VaultRouter using the ERC4626_VAULT_REDEEM command. Subsequently, the redeemed underlying assets can be further processed within the same transaction through other commands, such as V3_UNISWAP_SWAP for token swaps or PUSH_TOKEN for token transferrings. Redeem ERC4626 shares for underlying assets:

    function _handleVaultRedeem(
        bytes calldata data,
        uint256[] memory callStack,
        uint32 inputMapping,
        uint32 outputMapping
    ) private returns (bytes memory) {
        IERC4626 vault;
        uint256 shares;
        address receiver;
        address owner;
        assembly {
            vault := calldataload(data.offset)
            shares := calldataload(add(data.offset, 0x20))
            receiver := calldataload(add(data.offset, 0x40))
            owner := calldataload(add(data.offset, 0x60))
        }
        shares = Commands.pullInputParam(callStack, shares, inputMapping, 1);
        uint256 assets = redeemVault(vault, shares, receiver, owner);
        Commands.pushOutputParam(callStack, assets, outputMapping, 1);
        return abi.encodePacked(assets);
    }

Withdraw underlying assets by burning shares:

    function _handleVaultWithdraw(
        bytes calldata data,
        uint256[] memory callStack,
        uint32 inputMapping,
        uint32 outputMapping
    ) private returns (bytes memory) {
        IERC4626 vault;
        uint256 assets;
        address receiver;
        address owner;
        assembly {
            vault := calldataload(data.offset)
            assets := calldataload(add(data.offset, 0x20))
            receiver := calldataload(add(data.offset, 0x40))
            owner := calldataload(add(data.offset, 0x60))
        }
        assets = Commands.pullInputParam(callStack, assets, inputMapping, 1);
        uint256 shares = withdrawVault(vault, assets, receiver, owner);
        Commands.pushOutputParam(callStack, shares, outputMapping, 1);
        return abi.encodePacked(shares);
    }

To allow VaultRouter to redeem ERC4626 shares on behalf of a user, the user must approve VaultRouter to spend their shares in advance. However, the caller can be anyone when handling ERC4626 shares redeeming / underlying asset withdrawing, a malicious actor can exploit this approval to drain the user’s ERC4626 shares.

Copy below codes to VaultRouter.ts and run npm run test:

  it.only('Drain ERC4626 shares', async function () {
    const { vaultRouter, weth, vault, owner, otherAccount } = await deployFunction();
    await weth.approve(await vault.getAddress(), ethers.parseUnits('10000', 18));
    //@audit-info deposit 10000e18 WETH into vault for 10000e18 shares
    await vault.deposit(ethers.parseUnits('10000', 18), owner.address);
    expect(await vault.balanceOf(owner.address)).to.equal(ethers.parseUnits('10000', 18));
    // Approve the VaultRouter to spend vault shares from owner
    await vault.approve(await vaultRouter.getAddress(), ethers.parseUnits('10000', 18));

    let iface = new ethers.Interface(VaultRouterABI);

    const commands = [
      [
        VAULT_ROUTER_COMMAND_ACTIONS.ERC4626_VAULT_REDEEM,
        '0x' +
          iface
            .encodeFunctionData('redeemVault', [
              await vault.getAddress(),
              ethers.parseUnits('10000', 18),
              await otherAccount.getAddress(),
              owner.address,
            ])
            .slice(10),
      ],
    ];
    //@audit-info otherAccount crafts commands to drain owner's vault shares
    await vaultRouter.connect(otherAccount).execute(commands);
    //@audit-info all shares are drained for 10000e18 WETH and transferred to the malicious user
    expect(await vault.balanceOf(owner.address)).to.equal(0);
    expect(await weth.balanceOf(otherAccount.address)).to.equal(ethers.parseUnits('10000', 18));
  });

As we can see, all owner’s vault shares were drained.

Both ERC4626_VAULT_REDEEM and ERC4626_VAULT_WITHDRAW commands should only handle the caller’s ERC4626 shares:

    function _handleVaultRedeem(
        bytes calldata data,
        uint256[] memory callStack,
        uint32 inputMapping,
        uint32 outputMapping
    ) private returns (bytes memory) {
        IERC4626 vault;
        uint256 shares;
        address receiver;
        address owner;
        assembly {
            vault := calldataload(data.offset)
            shares := calldataload(add(data.offset, 0x20))
            receiver := calldataload(add(data.offset, 0x40))
-           owner := calldataload(add(data.offset, 0x60))
        }
+       owner = msg.sender;
        shares = Commands.pullInputParam(callStack, shares, inputMapping, 1);
        uint256 assets = redeemVault(vault, shares, receiver, owner);
        Commands.pushOutputParam(callStack, assets, outputMapping, 1);
        return abi.encodePacked(assets);
    }

    function _handleVaultWithdraw(
        bytes calldata data,
        uint256[] memory callStack,
        uint32 inputMapping,
        uint32 outputMapping
    ) private returns (bytes memory) {
        IERC4626 vault;
        uint256 assets;
        address receiver;
        address owner;
        assembly {
            vault := calldataload(data.offset)
            assets := calldataload(add(data.offset, 0x20))
            receiver := calldataload(add(data.offset, 0x40))
-           owner := calldataload(add(data.offset, 0x60))
        }
+       owner = msg.sender;
        assets = Commands.pullInputParam(callStack, assets, inputMapping, 1);
        uint256 shares = withdrawVault(vault, assets, receiver, owner);
        Commands.pushOutputParam(callStack, shares, outputMapping, 1);
        return abi.encodePacked(shares);
    }

chefkenji (BakerFi) confirmed

BakerFi mitigated:

PR-19

Status: Mitigation confirmed. Full details in reports from 0xlemon and shaflow2.


Medium Risk Findings (16)

[M-01] VaultBase is not ERC4626 compliant

Submitted by klau5, also found by 0xpiken, 0xpiken, shaflow2, and shaflow2

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/VaultBase.sol#L188

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/VaultBase.sol#L155

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/VaultBase.sol#L278

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/VaultBase.sol#L337

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/VaultBase.sol#L164

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/VaultBase.sol#L287

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/VaultBase.sol#L346

Finding description and impact

VaultBase is not compliant with the must-have specs in ERC4626. This can break external integrations.

Proof of Concept

The official ERC4626 specifications can be found on this page. Several required specifications are not being followed. Here are the incorrectly implemented functions:

maxDeposit

Maximum amount of the underlying asset that can be deposited into the Vault for the receiver, through a deposit call. MUST return the maximum amount of assets deposit would allow to be deposited for receiver and not cause a revert, which MUST NOT be higher than the actual maximum that would be accepted (it should underestimate if necessary). This assumes that the user has infinite assets, i.e. MUST NOT rely on balanceOf of asset. MUST factor in both global and user-specific limits, like if deposits are entirely disabled (even temporarily) it MUST return 0.

VaultBase.maxDeposit always returns type(uint256).max. However, _depositInternal uses getMaxDeposit to enforce limits. This means VaultBase.maxDeposit returns incorrect values if getMaxDeposit was set. Additionally, it doesn’t return 0 when the contract is paused.

function maxDeposit(address) external pure override returns (uint256 maxAssets) {
@>  return type(uint256).max;
}

function _depositInternal(uint256 assets, address receiver) private returns (uint256 shares) {
    ...
    // Check if deposit exceeds the maximum allowed per wallet
@>  uint256 maxDepositLocal = getMaxDeposit();
    if (maxDepositLocal > 0) {
        uint256 depositInAssets = (balanceOf(msg.sender) * _ONE) / tokenPerAsset();
        uint256 newBalance = assets + depositInAssets;
        if (newBalance > maxDepositLocal) revert MaxDepositReached();
    }
    ...
}

maxMint

Maximum amount of shares that can be minted from the Vault for the receiver, through a mint call. MUST return the maximum amount of shares mint would allow to be deposited to receiver and not cause a revert, which MUST NOT be higher than the actual maximum that would be accepted (it should underestimate if necessary). This assumes that the user has infinite assets, i.e. MUST NOT rely on balanceOf of asset. MUST factor in both global and user-specific limits, like if mints are entirely disabled (even temporarily) it MUST return 0.

VaultBase.maxMint always returns type(uint256).max. However, _depositInternal uses getMaxDeposit to enforce limits. This means VaultBase.maxMint returns incorrect values if getMaxDeposit was set. Additionally, it doesn’t return 0 when the contract is paused.

function maxMint(address) external pure override returns (uint256 maxShares) {
@>  return type(uint256).max;
}

function _depositInternal(uint256 assets, address receiver) private returns (uint256 shares) {
    ...

    // Check if deposit exceeds the maximum allowed per wallet
@>  uint256 maxDepositLocal = getMaxDeposit();
    if (maxDepositLocal > 0) {
        uint256 depositInAssets = (balanceOf(msg.sender) * _ONE) / tokenPerAsset();
        uint256 newBalance = assets + depositInAssets;
        if (newBalance > maxDepositLocal) revert MaxDepositReached();
    }

    ...
}

maxWithdraw

MUST factor in both global and user-specific limits, like if withdrawals are entirely disabled (even temporarily) it MUST return 0.

VaultBase.maxWithdraw doesn’t return 0 when the contract is paused.

function maxWithdraw(address shareHolder) external view override returns (uint256 maxAssets) {
    maxAssets = this.convertToAssets(balanceOf(shareHolder));
}

maxRedeem

MUST factor in both global and user-specific limits, like if redemption is entirely disabled (even temporarily) it MUST return 0.

VaultBase.maxRedeem doesn’t return 0 when the contract is paused.

function maxRedeem(address shareHolder) external view override returns (uint256 maxShares) {
    maxShares = balanceOf(shareHolder);
}

previewMint

MUST return as close to and no fewer than the exact amount of assets that would be deposited in a mint call in the same transaction. i.e. mint should return the same or fewer assets as previewMint if called in the same transaction.

If (1) it’s calculating the amount of shares a user has to supply to receive a given amount of the underlying tokens or (2) it’s calculating the amount of underlying tokens a user has to provide to receive a certain amount of shares, it should round up.

It is recommended that previewMint rounds up the results. (reference) It should return no less than the amount of assets the user actually should pay. But VaultBase.previewMint rounds down results.

function previewMint(uint256 shares) external view override returns (uint256 assets) {
    assets = this.convertToAssets(shares);
}

previewWithdraw

MUST return as close to and no fewer than the exact amount of Vault shares that would be burned in a withdraw call in the same transaction. i.e. withdraw should return the same or fewer shares as previewWithdraw if called in the same transaction. MUST be inclusive of withdrawal fees. Integrators should be aware of the existence of withdrawal fees.

If (1) it’s calculating the amount of shares a user has to supply to receive a given amount of the underlying tokens or (2) it’s calculating the amount of underlying tokens a user has to provide to receive a certain amount of shares, it should round up.

VaultBase takes a fee when you withdraw, but VaultBase.previewWithdraw doesn’t count the fee. previewWithdraw should return the share including the fee. Also, previewWithdraw is recommended to round up the result (reference), but VaultBase.previewWithdraw is rounding down the calculation.

function previewWithdraw(uint256 assets) external view override returns (uint256 shares) {
@>  shares = this.convertToShares(assets);
}

function convertToShares(uint256 assets) external view override returns (uint256 shares) {
    Rebase memory total = Rebase(totalAssets(), totalSupply());
@>  shares = total.toBase(assets, false);
}

function _redeemInternal(
    uint256 shares,
    address receiver,
    address holder,
    bool shouldRedeemETH
) private returns (uint256 retAmount) {
    ...

    // Calculate and handle withdrawal fees
    if (getWithdrawalFee() != 0 && getFeeReceiver() != address(0)) {
@>      fee = amount.mulDivUp(getWithdrawalFee(), PERCENTAGE_PRECISION);

        if (shouldRedeemETH && _asset() == wETHA()) {
            unwrapETH(amount);
@>          payable(receiver).sendValue(amount - fee);
            payable(getFeeReceiver()).sendValue(fee);
        } else {
@>          IERC20Upgradeable(_asset()).transfer(receiver, amount - fee);
            IERC20Upgradeable(_asset()).transfer(getFeeReceiver(), fee);
        }
    } else {
        ...
    }

    emit Withdraw(msg.sender, receiver, holder, amount - fee, shares);
    retAmount = amount - fee;
}

previewRedeem

MUST return as close to and no more than the exact amount of assets that would be withdrawn in a redeem call in the same transaction. i.e. redeem should return the same or more assets as previewRedeem if called in the same transaction. MUST be inclusive of withdrawal fees. Integrators should be aware of the existence of withdrawal fees.

VaultBase charges withdrawal fees but VaultBase.previewRedeem doesn’t include fees. It should deduct the asset amount paid as fees.

Additionally, when withdrawing from MultiStrategyVault, amounts to undeploy are calculated per Strategy with rounding down. Since previewRedeem’s result shouldn’t exceed actual withdrawal amount, it should simulate multi-Strategy withdrawals to include losses.

function previewRedeem(uint256 shares) external view override returns (uint256 assets) {
    assets = this.convertToAssets(shares);
}

Modify to comply with the specification of ERC4626. For MultiStrategyVault, override ERC4626 view functions to simulate multi-Strategy deposits/withdrawals and include rounding loss.

chefkenji (BakerFi) confirmed

BakerFi mitigated:

PR-27

Status: Unmitigated. Full details in reports from shaflow2, and also included in the Mitigation Review section below.


[M-02] New strategy can not work due to insufficient allowance

Submitted by 0xpiken, also found by 0xlemon, klau5, and shaflow2

When a new strategy is added through MultiStrategy#addStrategy(), it was not approved to spend the asset in MultiStrategyVault. Any functions that call newStrategy#deploy() may revert and result in MultiStrategyVault being DoS’ed.

Proof of Concept

MultiStrategyVault is used to manage multiple investment strategies. A new strategy can be added through MultiStrategy#addStrategy():

107:    function addStrategy(IStrategy strategy) external onlyRole(VAULT_MANAGER_ROLE) {
108:        if (address(strategy) == address(0)) revert InvalidStrategy();
109:        _strategies.push(strategy);
110:        _weights.push(0);
111:        emit AddStrategy(address(strategy));
112:    }

However, the new strategy was not approved to spend the asset in MultiStrategyVault, resulting MultiStrategyVault being DoS’ed.

Copy below codes to VaultMultiStrategy.ts and run npm run test:

  it.only('Add Strategy - no allowance', async () => {
    const { vault, usdc, owner, otherAccount } = await loadFixture(deployMultiStrategyVaultFixture);

    //@audit-info deploy a new strategy
    const Strategy = await ethers.getContractFactory('StrategySupplyAAVEv3');
    const strategy = await Strategy.deploy(
      owner.address,
      await usdc.getAddress(),
      otherAccount.address,
    );
    await strategy.waitForDeployment();
    //@audit-info add the new strategy to vault
    await vault.addStrategy(await strategy.getAddress());
    //@audit-info the new strategy has been added
    expect(await vault.strategies()).to.include(await strategy.getAddress());
    expect(await vault.asset()).to.equal(await usdc.getAddress());
    //@audit-info however, the new strategy was not approved to spend asset of vault
    expect(await usdc.allowance(vault.target, strategy.target)).to.equal(0);
  });

As we can see, the new strategy has zero allowance.

The new strategy should be approved with max allowance when added:

    function addStrategy(IStrategy strategy) external onlyRole(VAULT_MANAGER_ROLE) {
        if (address(strategy) == address(0)) revert InvalidStrategy();
        _strategies.push(strategy);
        _weights.push(0);
+       IERC20(strategy.asset()).approve(address(strategy), type(uint256).max);
        emit AddStrategy(address(strategy));
    }

chefkenji (BakerFi) confirmed

BakerFi mitigated:

PR-13

Status: Mitigation confirmed. Full details in reports from 0xlemon and shaflow2.


[M-03] MultiStrategy#removeStrategy() cannot remove leverage strategies that still have deployed assets

Submitted by 0xpiken, also found by 0xlemon, pfapostol, and shaflow2

https://github.com/code-423n4/2024-12-bakerfi/blob/main/contracts/core/MultiStrategy.sol#L265

Finding description and impact

A leverage strategy with deployed assets can not be removed from MultiStrategyVault due to insufficient assets

Proof of Concept

MultiStrategyVault is used to manage multiple investment strategies. The vault manager can remove an existing strategy by calling MultiStrategy#removeStrategy():

    function removeStrategy(uint256 index) external onlyRole(VAULT_MANAGER_ROLE) {
        // Validate the index to ensure it is within bounds
        if (index >= _strategies.length) revert InvalidStrategyIndex(index);

        // Retrieve the total assets managed by the strategy to be removed
        uint256 strategyAssets = _strategies[index].totalAssets();

        // Update the total weight and mark the weight of the removed strategy as zero
        _totalWeight -= _weights[index];
        _weights[index] = 0;

        // If the strategy has assets, undeploy them and allocate accordingly
        if (strategyAssets > 0) {
@>          IStrategy(_strategies[index]).undeploy(strategyAssets);
@>          _allocateAssets(strategyAssets);
        }

        // Move the last strategy to the index of the removed strategy to maintain array integrity
        uint256 lastIndex = _strategies.length - 1;
        if (index < lastIndex) {
            _strategies[index] = _strategies[lastIndex];
            _weights[index] = _weights[lastIndex];
        }

        emit RemoveStrategy(address(_strategies[lastIndex]));
        // Remove the last strategy and weight from the arrays
        _strategies.pop();
        _weights.pop();
    }

If the strategy to be removed has deployed assets, it will be undeployed first and then allocated to other strategies. It is expected that the equivalent amount of assets will be received when calling IStrategy.undeploy():

          IStrategy(_strategies[index]).undeploy(strategyAssets);
          _allocateAssets(strategyAssets);

However, the amount of received assets could be less than strategyAssets if the strategy is a leverage strategy.

When a leverage strategy is used to undeploy assets:

  1. deltaDebt of debt token will be borrowed from flashLender
  2. Then the borrowed debt token is repaid to the lending protocol to withdraw deltaCollateralAmount of collateral
  3. The withdrawn collateral is swapped to debt token
  4. A certain number (deltaDebt + fee) of debt token will be paid to flashLender

    function _undeploy(uint256 amount, address receiver) private returns (uint256 receivedAmount) {
        // Get price options from settings
        IOracle.PriceOptions memory options = IOracle.PriceOptions({
            maxAge: getPriceMaxAge(),
            maxConf: getPriceMaxConf()
        });
    
        // Fetch collateral and debt balances
        (uint256 totalCollateralBalance, uint256 totalDebtBalance) = getBalances();
        uint256 totalCollateralInDebt = _toDebt(options, totalCollateralBalance, false);
    
        // Ensure the position is not in liquidation state
        if (totalCollateralInDebt <= totalDebtBalance) revert NoCollateralMarginToScale();
    
        // Calculate percentage to burn to accommodate the withdrawal
        uint256 percentageToBurn = (amount * PERCENTAGE_PRECISION) / (totalCollateralInDebt - totalDebtBalance);
    
        // Calculate delta position (collateral and debt)
        (uint256 deltaCollateralInDebt, uint256 deltaDebt) = _calcDeltaPosition(
            percentageToBurn,
            totalCollateralInDebt,
            totalDebtBalance
        );
        // Convert deltaCollateralInDebt to deltaCollateralAmount
        uint256 deltaCollateralAmount = _toCollateral(options, deltaCollateralInDebt, true);
    
        // Calculate flash loan fee
        uint256 fee = flashLender().flashFee(_debtToken, deltaDebt);
    
        // Approve the flash lender to spend the debt amount plus fee
        if (!IERC20Upgradeable(_debtToken).approve(flashLenderA(), deltaDebt + fee)) {
            revert FailedToApproveAllowance();
        }
    
        // Prepare data for flash loan execution
        bytes memory data = abi.encode(deltaCollateralAmount, receiver, FlashLoanAction.PAY_DEBT_WITHDRAW);
        _flashLoanArgsHash = keccak256(abi.encodePacked(address(this), _debtToken, deltaDebt, data));
    
        // Execute flash loan
        if (!flashLender().flashLoan(IERC3156FlashBorrowerUpgradeable(this), _debtToken, deltaDebt, data)) {
            _flashLoanArgsHash = 0;
            revert FailedToRunFlashLoan();
        }
        // The amount of Withdrawn minus the repay ampunt
        emit StrategyUndeploy(msg.sender, deltaCollateralInDebt - deltaDebt);
    
        // Reset hash after successful flash loan
        _flashLoanArgsHash = 0;
    
        // Update deployed assets after withdrawal
        receivedAmount = _pendingAmount;
        uint256 undeployedAmount = deltaCollateralInDebt - deltaDebt;
        _deployedAssets = _deployedAssets > undeployedAmount ? _deployedAssets - undeployedAmount : 0;
    
        // Emit strategy update and reset pending amount
        emit StrategyAmountUpdate(_deployedAssets);
        // Pending amount is not cleared to save gas
        //_pendingAmount = 0;
    }

    The amount of received assets can be calculated as below:

Note: please see scenario in warden’s original submission.

When removing a strategy from MultiStrategyVault, ensure the amount of assets to be re-allocated is same as the received amount:

    function removeStrategy(uint256 index) external onlyRole(VAULT_MANAGER_ROLE) {
        // Validate the index to ensure it is within bounds
        if (index >= _strategies.length) revert InvalidStrategyIndex(index);

        // Retrieve the total assets managed by the strategy to be removed
        uint256 strategyAssets = _strategies[index].totalAssets();

        // Update the total weight and mark the weight of the removed strategy as zero
        _totalWeight -= _weights[index];
        _weights[index] = 0;

        // If the strategy has assets, undeploy them and allocate accordingly
        if (strategyAssets > 0) {
-           IStrategy(_strategies[index]).undeploy(strategyAssets);
-           _allocateAssets(strategyAssets);
+           _allocateAssets(IStrategy(_strategies[index]).undeploy(strategyAssets));
        }

        // Move the last strategy to the index of the removed strategy to maintain array integrity
        uint256 lastIndex = _strategies.length - 1;
        if (index < lastIndex) {
            _strategies[index] = _strategies[lastIndex];
            _weights[index] = _weights[lastIndex];
        }

        emit RemoveStrategy(address(_strategies[lastIndex]));
        // Remove the last strategy and weight from the arrays
        _strategies.pop();
        _weights.pop();
    }

chefkenji (BakerFi) confirmed

BakerFi mitigated:

PR-16

Status: Mitigation confirmed. Full details in reports from 0xlemon and shaflow2.


[M-04] Sending tokens to a Strategy when totalSupply is 0 can permanently make the Vault unavailable

Submitted by klau5, also found by 0xlemon, MrPotatoMagic, and shaflow2

https://github.com/code-423n4/2024-12-bakerfi/blob/3873b82ae8b321473f3afaf08727e97be0635be9/contracts/core/VaultBase.sol#L244

Finding description and impact

Before the first deposit or when all shares have been withdrawn making totalSupply zero, an attacker can manipulate totalAssets by directly sending tokens to the Strategy, making the Vault permanently unusable. Additionally, in normal usage scenarios, small amounts of assets remaining in the Strategy can cause the same issue.

Proof of Concept

When depositing assets into the Vault, VaultBase._depositInternal is called. This function only allows cases where both totalAssets and totalSupply are either zero or both positive. However, it’s possible to make totalAssets non-zero while totalSupply is zero. This prevents any user from depositing tokens into the Vault.

function _depositInternal(uint256 assets, address receiver) private returns (uint256 shares) {
    if (receiver == address(0)) revert InvalidReceiver();
    // Fetch price options from settings
    // Get the total assets and total supply
@>  Rebase memory total = Rebase(totalAssets(), totalSupply());

    // Check if the Rebase is uninitialized or both base and elastic are positive
@>  if (!((total.elastic == 0 && total.base == 0) || (total.base > 0 && total.elastic > 0))) {
        revert InvalidAssetsState();
    }
    ...
}

The totalAssets function returns the value of _totalAssets, which is defined in both Vault and MultiStrategyVault. These return strategy.totalAssets

// Vault._totalAssets
function _totalAssets() internal view virtual override returns (uint256 amount) {
    amount = _strategy.totalAssets(); // Calls the totalAssets function of the strategy
}

// MultiStrategyVault._totalAssets
function _totalAssets() internal view virtual override(VaultBase, MultiStrategy) returns (uint256 assets) {
    assets = MultiStrategy._totalAssets(); // Calls the totalAssets function from MultiStrategy to get the total assets
}
// MultiStrategy._totalAssets
function _totalAssets() internal view virtual returns (uint256 assets) {
    for (uint256 i = 0; i < _strategies.length; ) {
@>      assets += IStrategy(_strategies[i]).totalAssets();
        unchecked {
            i++;
        }
    }
    return assets;
}

For example, looking at StrategyLeverageAAVEv3.getBalances, if an attacker deposits aTokens into the StrategyLeverageAAVEv3 contract before any Vault deposits, totalAssets will become greater than zero.

function totalAssets() external view returns (uint256 totalOwnedAssetsInDebt) {
    IOracle.PriceOptions memory priceOptions = IOracle.PriceOptions({maxAge: 0, maxConf: 0});
@>  (uint256 totalCollateral, uint256 totalDebt) = getBalances();
    uint256 totalCollateralInDebt = _toDebt(priceOptions, totalCollateral, false);
@>  totalOwnedAssetsInDebt = totalCollateralInDebt > totalDebt ? (totalCollateralInDebt - totalDebt) : 0;
}

function getBalances() public view virtual override returns (uint256 collateralBalance, uint256 debtBalance) {
    DataTypes.ReserveData memory debtReserve = (aaveV3().getReserveData(_debtToken));
@>  DataTypes.ReserveData memory collateralReserve = (aaveV3().getReserveData(_collateralToken));
    debtBalance = ERC20(debtReserve.variableDebtTokenAddress).balanceOf(address(this));
    uint8 debtDecimals = ERC20(debtReserve.variableDebtTokenAddress).decimals();
@>  uint8 collateralDecimals = ERC20(collateralReserve.aTokenAddress).decimals();
@>  collateralBalance = ERC20(collateralReserve.aTokenAddress).balanceOf(address(this));
    debtBalance = debtBalance.toDecimals(debtDecimals, SYSTEM_DECIMALS);
@>  collateralBalance = collateralBalance.toDecimals(collateralDecimals, SYSTEM_DECIMALS);
}

Furthermore, this issue can also occur in normal usage scenarios. When all shares are withdrawn from the Vault, totalSupply returns to zero. However, when calculating the number of asset tokens to give to users, the result rounds down. Therefore, a small amount of asset tokens might remain unwithdrawn in the Strategy. Subsequently, this Vault becomes unusable.

function _redeemInternal(
    uint256 shares,
    address receiver,
    address holder,
    bool shouldRedeemETH
) private returns (uint256 retAmount) {
    ...

    // Calculate the amount to withdraw based on shares
@>  uint256 withdrawAmount = (shares * totalAssets()) / totalSupply();
    if (withdrawAmount == 0) revert NoAssetsToWithdraw();

This is the PoC. You can run it by adding it to the Vault.ts file.

it('PoC - DoS before first deposit', async function () {
  const { owner, vault, strategy, aave3Pool, cbETH, otherAccount } = await loadFixture(deployFunction);

    // attacker deposit cbETH to the aave3Pool and get aToken
    const amount = '10'; // 10 wei
    await cbETH.transfer(await otherAccount.getAddress(), amount);
    await cbETH.connect(otherAccount).approve(await aave3Pool.getAddress(), amount);
    const tx = await aave3Pool.connect(otherAccount).supply(await cbETH.getAddress(), amount, await otherAccount.getAddress(), 0);

    await expect(tx)
      // @ts-ignore
      .to.emit(aave3Pool, 'Supply')
      .withArgs(
        await cbETH.getAddress(),
        await otherAccount.getAddress(),
        await otherAccount.getAddress(),
        10,
        0,
      );
    
    // send the aToken to the strategy
    const res = await aave3Pool.getReserveData(await cbETH.getAddress());
    const aTokenAddress = res[8];
    const aToken = await ethers.getContractAt('IERC20', aTokenAddress);
    await aToken.connect(otherAccount).transfer(await strategy.getAddress(), 10);

    expect(await vault.totalAssets()).to.be.greaterThan(0);
    
    // first deposit failed, because the strategy has a balance
    await expect(
      vault.depositNative(owner.address, {
        value: ethers.parseUnits('10', 18),
      }),
    ).to.be.revertedWithCustomError(vault, 'InvalidAssetsState');
});

Create a function that can withdraw assets when totalSupply is zero but totalAssets is non-zero. Call this function in _depositInternal to clean up the Strategy.

chefkenji (BakerFi) acknowledged and commented:

This issue was already reported in previous audits. We have decided to seed the vaults to now allow the minimum number of shares to be achieved and prevent first depositor attacks,

https://github.com/code-423n4/2024-05-bakerfi-findings/issues/39


[M-05] Permit doesn’t work with DAI

Submitted by 0xlemon, also found by MrPotatoMagic

https://github.com/code-423n4/2024-12-bakerfi/blob/main/contracts/core/VaultRouter.sol#L114

Vulnerability Details

VaultRouter allows users to use permit transactions for convenience. This router is supposed to work with any ERC20 tokens. We can see in pullTokensWithPermit how the permit is utilized:

    function pullTokensWithPermit(
        IERC20Permit token,
        uint256 amount,
        address owner,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) internal virtual {
        // Permit the VaultRouter to spend tokens on behalf of the owner
@->        IERC20Permit(token).permit(owner, address(this), amount, deadline, v, r, s);

        // Transfer the tokens from the owner to this contract
        IERC20(address(token)).safeTransferFrom(owner, address(this), amount);
    }

However this .permit doesn’t work with DAI tokens because DAI token’s permit signature is different. From the contract at address 0x6B175474E89094C44Da98b954EedeAC495271d0F, we see the permit function:

function permit(address holder, address spender, uint256 nonce, uint256 expiry, bool allowed, uint8 v, bytes32 r, bytes32 s) external

The nonce and allowed arguments are added to DAI’s permit that means calling pullTokensWithPermit where DAI is the token will revert.

Impact

Permit cannot be used with DAI tokens

For the special case of DAI token, allow a different implementation of the permit function which allows nonce and allowed variables.

chefkenji (BakerFi) acknowledged


[M-06] Even when the Vault contract is paused, the rebalance function is not paused

Submitted by klau5

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/Vault.sol#L177

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/MultiStrategyVault.sol#L174

Finding description and impact

When the contract is paused, rebalance is not paused. While users cannot withdraw, performance fees can still be collected from interest.

Proof of Concept

The rebalance should not be callable when paused (according to the documentation), but it can still be called even when paused. This means that while users cannot withdraw their investments from the Vault when paused, it’s still possible to collect performance fees on interest through the rebalance function. Also, MultiStrategyVault has the same issue.

    function rebalance(
        IVault.RebalanceCommand[] calldata commands
@>  ) external override nonReentrant onlyRole(VAULT_MANAGER_ROLE) returns (bool success) {
        success = true;
        uint256 numCommands = commands.length;
        for (uint256 i = 0; i < numCommands; ) {
            if (commands[i].action == HARVEST_VAULT) {
                _harvestAndMintFees();
            }
            unchecked {
                i++;
            }
        }
    }

Add the whenNotPaused modifier to the rebalance function.

chefkenji (BakerFi) confirmed

BakerFi mitigated:

PR-3

Status: Mitigation confirmed. Full details in reports from 0xlemon and shaflow2.


[M-07] Depositor can bypass the max deposit limit

Submitted by 0xlemon, also found by MrPotatoMagic and shaflow2

https://github.com/code-423n4/2024-12-bakerfi/blob/main/contracts/core/VaultBase.sol#L251

Summary

Depositors can easily bypass the maximum deposit limit by specifying another recipient address than the one they are depositing from

Vulnerability Details

VaultBase::_depositInternal() limits the amount of assets a single depositor (wallet) can deposit into the vault if _maxDeposit > 0. We can see the following checks being performed:

    function _depositInternal(uint256 assets, address receiver) private returns (uint256 shares) {
        //...

        // Check if deposit exceeds the maximum allowed per wallet
        uint256 maxDepositLocal = getMaxDeposit();
        if (maxDepositLocal > 0) {
@->            uint256 depositInAssets = (balanceOf(msg.sender) * _ONE) / tokenPerAsset();
            uint256 newBalance = assets + depositInAssets;
            if (newBalance > maxDepositLocal) revert MaxDepositReached();
        }
        //...
    }

The problem here is that we take the shares of msg.sender which can be different from the recipient specified when depositing. This means that a single depositor can deposit as much as he wants, he just has to specify a different recipient address.

Attack path

  1. Bob has never deposited in the vault so his shares are 0 - balanceOf(Bob) = 0
  2. He creates a second wallet to recieve the shares that will be minted to him on deposit
  3. Bob calls Vault::deposit() specifying his second wallet as a recipient
  4. At the end of the deposit transaction balanceOf(Bob) is still 0 because we mint the shares to the recipient (Bob’s second wallet)
  5. Bob repeats this process as much as he wants and his depositInAssets will always be 0 so he can never reach the deposit limit.

Impact

Bypassing the deposit limit

Introduce a mapping that keeps track of the deposits of msg.sender

chefkenji (BakerFi) confirmed

BakerFi mitigated:

PR-4

Status: Unmitigated. Full details in report from shaflow2, and also included in the Mitigation Review section below.


[M-08] The dispatch function of the VaultRouter, does not work as intended, with PULL_TOKEN action

Submitted by pfapostol, also found by MrPotatoMagic

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/VaultRouter.sol#L99

Finding description and impact

The dispatch function of the VaultRouter contract handles the execution of actions from the MultiCommand.execute call.
Actions are encoded as follows (Right-to-Left):

  1. Bits (0-32): The action.
  2. Bits (32-63): The input mapping.
  3. Bits (64-95): The output mapping.

While most commands are handled using the corresponding action stored in the actionToExecute variable:

        uint32 actionToExecute = uint32(action & Commands.THIRTY_TWO_BITS_MASK);

        // Extract input mapping from bits 32-63 by right shifting 32 bits and masking
        uint32 inputMapping = uint16((action >> 32) & Commands.THIRTY_TWO_BITS_MASK);

        // Extract output mapping from bits 64-95 by right shifting 64 bits and masking
        uint32 outputMapping = uint16(((action >> 64) & Commands.THIRTY_TWO_BITS_MASK));
...
        } else if (actionToExecute == Commands.PULL_TOKEN_FROM) {
            output = _handlePullTokenFrom(data, callStack, inputMapping);
        } else if (actionToExecute == Commands.PUSH_TOKEN) {
            output = _handlePushToken(data, callStack, inputMapping);
        } else if (actionToExecute == Commands.PUSH_TOKEN_FROM) {
            output = _handlePushTokenFrom(data, callStack, inputMapping);
        } else if (actionToExecute == Commands.SWEEP_TOKENS) {
...

There is one exception. Likely due to a typo, the action “tuple” is used instead of actionToExecute:

Impact

If an inputMapping is supplied with actionToExecute equal PULL_TOKEN, the execution will revert with InvalidCommand(uint256 action).

Proof of Concept

This PoC follows these steps:

  1. Deploy the VaultRouter.
  2. Transfer some WETH to the USER1 and approve the VaultRouter on the USER1’s behalf.
  3. Execute two commands:

    1. Demonstrate that the issue does not occur when inputMapping is not supplied.

      │   │   ├─ [29211] VaultRouter::dispatch(228, <unknown>, [0, 0, 0, 0, 0, 0, 0, 0])
      │   │   │   ├─ [28962] VaultRouter::_handlePullToken(<unknown>, [0, 0, 0, 0, 0, 0, 0, 0], 0)
      ...
      │   │   │   │   │   │   ├─ [26048] WETH::transferFrom(USER1: [0x856243F11eFbE357db89aeb2DC809768cC055b1B], VaultRouter: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a], 1000000000000000000 [1e18])
      ...
      │   │   │   └─ ← true, 0x
    2. Trigger the issue: when inputMapping is supplied, the execution reverts with:

      │   │   └─ ← [Revert] InvalidCommand(4294967298 [4.294e9])
// SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.24;

import {Test} from "lib/forge-std/src/Test.sol";
import {console} from "lib/forge-std/src/console.sol";
import {MockERC20} from "lib/forge-std/src/mocks/MockERC20.sol";

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {BakerFiProxy} from "contracts/proxy/BakerFiProxy.sol";
import {BakerFiProxyAdmin} from "contracts/proxy/BakerFiProxyAdmin.sol";
import {VaultRouter} from "contracts/core/VaultRouter.sol";
import {Command} from "contracts/core/MultiCommand.sol";
import {Commands} from "contracts/core/router/Commands.sol";

contract PoCs is Test {
    address immutable DEPLOYER = makeAddr("DEPLOYER");
    address immutable USER1 = makeAddr("USER1");

    address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
    address constant WstETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0;

    function setUp() public {
        vm.label(WETH, "WETH");
        vm.label(WstETH, "WstETH");

        vm.createSelectFork("https://rpc.ankr.com/eth");
    }

    function test_dispatch_action_impossible() public {
        BakerFiProxyAdmin bakerFiProxyAdmin = new BakerFiProxyAdmin(DEPLOYER);
        BakerFiProxy bakerFiProxy = new BakerFiProxy(
            address(new VaultRouter()),
            address(bakerFiProxyAdmin),
            abi.encodeWithSelector(
                VaultRouter.initialize.selector,
                DEPLOYER,
                WETH
            )
        );
        VaultRouter vaultRouter = VaultRouter(payable(address(bakerFiProxy)));
        vm.label(address(vaultRouter), "VaultRouter");

        vm.startPrank(USER1);
        deal(WETH, USER1, 3 ether);
        vm.deal(USER1, 3 ether);
        IERC20(WETH).approve(address(vaultRouter), 3 ether);

        Command[] memory commands = new Command[](2);

        commands[0] = Command({
            action: Commands.PULL_TOKEN,
            data: abi.encode(WETH, 1 ether)
        });
        commands[1] = Command({
            action: ((uint256(uint8(1)) << 32) | uint256(Commands.PULL_TOKEN)),
            data: abi.encode(WETH, 1 ether)
        });

        vaultRouter.execute{value: 3 ether}(commands);
    }
}

Use correct variable:

} else if (actionToExecute == Commands.PULL_TOKEN) {

chefkenji (BakerFi) confirmed

BakerFi mitigated:

PR-11

Status: Mitigation confirmed. Full details in reports from 0xlemon and shaflow2.


[M-09] Non-whitelisted recipient can receive shares

Submitted by 0xlemon, also found by 0xlemon and MrPotatoMagic

https://github.com/code-423n4/2024-12-bakerfi/blob/main/contracts/core/VaultBase.sol#L237-L271

Summary

The recipient of the vault shares isn’t checked to be in the whitelist. This means that a non-whitelisted user can receive shares and then withdraw/redeem them throught the VaultRouter.

Vulnerability Details

If we look at VaultBase deposit/mint/withdraw/redeem functions have a onlyWhiteListed modifier that means they can only be called by someone who is within the _enabledAccounts. However the protocol doesn’t check if the receiver is included in that whitelist. This allows non-whitelisted people to receive shares and they can later easily withdraw them through the VaultRouter.

Impact

Bypass of the whitelist

Check if the receiver of the vault shares is whitelisted

chefkenji (BakerFi) confirmed

BakerFi mitigated:

PR-26

Status: Mitigation confirmed. Full details in reports from shaflow2 and 0xlemon.


[M-10] The withdrawal of Multi strategies vault could be DoSed while asset deposits remain unaffected

Submitted by 0xpiken, also found by klau5

https://github.com/code-423n4/2024-12-bakerfi/blob/main/contracts/core/MultiStrategy.sol#L173

Finding description and impact

The MultiStrategy#_deallocateAssets() function will be DoSed if IStrategy#undeploy(0) is called.

Proof of Concept

When withdrawing user assets from a multi-strategies vault, it will be withdrawn pro-rata from the strategies base on their deployed assets: https://github.com/code-423n4/2024-12-bakerfi/blob/main/contracts/core/MultiStrategyVault.sol:

    function _undeploy(uint256 assets) internal virtual override returns (uint256 undeployedAmount) {
        undeployedAmount = _deallocateAssets(assets); // Deallocates assets from the strategies and returns the undeployed amount
    }

https://github.com/code-423n4/2024-12-bakerfi/blob/main/contracts/core/MultiStrategy.sol:

    function _deallocateAssets(uint256 amount) internal returns (uint256 totalUndeployed) {
        uint256[] memory currentAssets = new uint256[](_strategies.length);

        uint256 totalAssets = 0;
        uint256 strategiesLength = _strategies.length;

        for (uint256 i = 0; i < strategiesLength; i++) {
            currentAssets[i] = IStrategy(_strategies[i]).totalAssets();
            totalAssets += currentAssets[i];
        }
        totalUndeployed = 0;
        for (uint256 i = 0; i < strategiesLength; i++) {
            uint256 fractAmount = (amount * currentAssets[i]) / totalAssets;
            totalUndeployed += IStrategy(_strategies[i]).undeploy(fractAmount);
        }
    }

If a strategy has not yet deployed any assets, its totalAssets() call will return zero. Subsequently, the system will attempt to execute IStrategy.undeploy(0). However, this call is likely to revert, potentially leading to a denial-of-service condition for the entire withdrawal function: StrategySupplyBase.sol reverts ZeroAmount() when amount is 0:

    function undeploy(uint256 amount) external nonReentrant onlyOwner returns (uint256 undeployedAmount) {
@>      if (amount == 0) revert ZeroAmount();

        // Get Balance
        uint256 balance = getBalance();
        if (amount > balance) revert InsufficientBalance();

        // Transfer assets back to caller
        uint256 withdrawalValue = _undeploy(amount);

        // Check withdrawal value matches the initial amount
        // Transfer assets to user
        ERC20(_asset).safeTransfer(msg.sender, withdrawalValue);

        balance -= amount;
        emit StrategyUndeploy(msg.sender, withdrawalValue);
        emit StrategyAmountUpdate(balance);

        return amount;
    }

StrategyLeverage.sol reverts NoCollateralMarginToScale() because totalCollateralInDebt is equal to totalDebtBalance(both of them are 0):

    function undeploy(uint256 amount) external onlyOwner nonReentrant returns (uint256 undeployedAmount) {
        undeployedAmount = _undeploy(amount, payable(msg.sender));
    }

    function _undeploy(uint256 amount, address receiver) private returns (uint256 receivedAmount) {
        // Get price options from settings
        IOracle.PriceOptions memory options = IOracle.PriceOptions({
            maxAge: getPriceMaxAge(),
            maxConf: getPriceMaxConf()
        });

        // Fetch collateral and debt balances
        (uint256 totalCollateralBalance, uint256 totalDebtBalance) = getBalances();
        uint256 totalCollateralInDebt = _toDebt(options, totalCollateralBalance, false);

        // Ensure the position is not in liquidation state
@>      if (totalCollateralInDebt <= totalDebtBalance) revert NoCollateralMarginToScale();

        // Calculate percentage to burn to accommodate the withdrawal
        uint256 percentageToBurn = (amount * PERCENTAGE_PRECISION) / (totalCollateralInDebt - totalDebtBalance);

        // Calculate delta position (collateral and debt)
        (uint256 deltaCollateralInDebt, uint256 deltaDebt) = _calcDeltaPosition(
            percentageToBurn,
            totalCollateralInDebt,
            totalDebtBalance
        );
        // Convert deltaCollateralInDebt to deltaCollateralAmount
        uint256 deltaCollateralAmount = _toCollateral(options, deltaCollateralInDebt, true);

        // Calculate flash loan fee
        uint256 fee = flashLender().flashFee(_debtToken, deltaDebt);

        // Approve the flash lender to spend the debt amount plus fee
        if (!IERC20Upgradeable(_debtToken).approve(flashLenderA(), deltaDebt + fee)) {
            revert FailedToApproveAllowance();
        }

        // Prepare data for flash loan execution
        bytes memory data = abi.encode(deltaCollateralAmount, receiver, FlashLoanAction.PAY_DEBT_WITHDRAW);
        _flashLoanArgsHash = keccak256(abi.encodePacked(address(this), _debtToken, deltaDebt, data));

        // Execute flash loan
        if (!flashLender().flashLoan(IERC3156FlashBorrowerUpgradeable(this), _debtToken, deltaDebt, data)) {
            _flashLoanArgsHash = 0;
            revert FailedToRunFlashLoan();
        }
        // The amount of Withdrawn minus the repay ampunt
        emit StrategyUndeploy(msg.sender, deltaCollateralInDebt - deltaDebt);

        // Reset hash after successful flash loan
        _flashLoanArgsHash = 0;

        // Update deployed assets after withdrawal
        receivedAmount = _pendingAmount;
        uint256 undeployedAmount = deltaCollateralInDebt - deltaDebt;
        _deployedAssets = _deployedAssets > undeployedAmount ? _deployedAssets - undeployedAmount : 0;

        // Emit strategy update and reset pending amount
        emit StrategyAmountUpdate(_deployedAssets);
        // Pending amount is not cleared to save gas
        //_pendingAmount = 0;
    }

Copy below codes to VaultMultiStrategy.ts and run npm run test:

  it.only('Withdraw is DoSed', async () => {
    const { vault, usdc, owner, park1, park2 } = await loadFixture(deployMultiStrategyVaultFixture);
    const amount = 10000n * 10n ** 18n;
    await usdc.approve(vault.target, amount);
    await vault.deposit(amount, owner.address);

    //@audit-info deploy a erc4626 vault for StrategySupplyERC4626
    const ERC4626Vault = await ethers.getContractFactory("ERC4626VaultMock");
    const erc4626Vault = await ERC4626Vault.deploy(usdc.getAddress());
    await erc4626Vault.waitForDeployment();

    // Deploy StrategySupply contract
    const StrategySupply = await ethers.getContractFactory('StrategySupplyERC4626');
    const strategy = await StrategySupply.deploy(
      owner.address,
      await usdc.getAddress(),
      await erc4626Vault.getAddress(),
    );
    await strategy.waitForDeployment();
    await strategy.transferOwnership(await vault.getAddress());

    //@audit-info add the new strategy to vault
    await vault.addStrategy(await strategy.getAddress());
    //@audit-info the new strategy has been added
    expect(await vault.strategies()).to.include(await strategy.getAddress());
    //@audit-info withdrawal is DoSed
    await expect(vault.withdraw(amount, owner.address, owner.address)).to.be.revertedWithCustomError(
      strategy,
      'ZeroAmount',
    );
  });

Check if the amount is 0 before undeploying it:

    function _deallocateAssets(uint256 amount) internal returns (uint256 totalUndeployed) {
        uint256[] memory currentAssets = new uint256[](_strategies.length);

        uint256 totalAssets = 0;
        uint256 strategiesLength = _strategies.length;

        for (uint256 i = 0; i < strategiesLength; i++) {
            currentAssets[i] = IStrategy(_strategies[i]).totalAssets();
            totalAssets += currentAssets[i];
        }
        totalUndeployed = 0;
        for (uint256 i = 0; i < strategiesLength; i++) {
            uint256 fractAmount = (amount * currentAssets[i]) / totalAssets;
+           if (fractAmount == 0) continue;
            totalUndeployed += IStrategy(_strategies[i]).undeploy(fractAmount);
        }
    }

chefkenji (BakerFi) confirmed

MrPotatoMagic (warden) commented:

There is no DOS issue here. The VAULT_MANAGER can simply remove the strategy in that case. Having a strategy without any assets deposited means the strategy is unused and should be removed.

0xpiken (warden) commented:

VAULT_MANAGER may add a new strategy with no assets allocated yet. All withdrawals since then will be DoS’ed.

MrPotatoMagic (warden) commented:

add a new strategy with no assets allocated yet. - You’ve added it to allow users to deposit. I do not see this being any more than Low/Info finding. The availability of the protocol is only impacted till the time you deposit assets into the new strategy.

Dravee (judge) commented:

First and foremost: this was confirmed by the sponsor. Let’s now discuss about the severity.

The availability of the protocol is only impacted till the time you deposit assets into the new strategy.

The protocol’s availability and functionality is indeed impacted unexpectedly. But there exist a workaround for this not to be permanent. Still, users’ assets can be affected quite badly (all withdrawals). This is an edge case, but it indeed qualifies as a Medium.

BakerFi mitigated:

PR-5

Status: Mitigation confirmed. Full details in reports from 0xlemon and shaflow2.


[M-11] The calculation of assetsMax is incorrect.

Submitted by shaflow2

https://github.com/code-423n4/2024-12-bakerfi/blob/3873b82ae8b321473f3afaf08727e97be0635be9/contracts/core/strategies/StrategySupplyMorpho.sol#L78

Finding description and impact

In the _undeploy function, assetsMax is incorrectly calculated because the contract directly retrieves totalSupplyAssets and totalSupplyShares from _morpho storage without accounting for the accrued interest over time. This leads to an underestimation of assetsMax, which may allow users to withdraw more assets than they should, causing losses to other users.

Proof of Concept

    /// @notice Retrieves the current balance of the managed asset in the Morpho protocol.
    /// @return The current balance of the managed asset.
    function _getBalance() internal view override returns (uint256) {
        return _morpho.expectedSupplyAssets(_marketParams, address(this));
    }

In the StrategySupplyMorpho contract, when retrieving assets, the expectedSupplyAssets function is used, which considers accrued interest and fees from the elapsed time since the last update. This ensures that the withdraw and redeem functions calculate assets, including unaccounted interest.

    function _undeploy(uint256 amount) internal override returns (uint256) {
        Id id = _marketParams.id();
        uint256 assetsWithdrawn = 0;
        uint256 totalSupplyAssets = _morpho.totalSupplyAssets(id);
        uint256 totalSupplyShares = _morpho.totalSupplyShares(id);

        uint256 shares = _morpho.supplyShares(id, address(this));
        uint256 assetsMax = shares.toAssetsDown(totalSupplyAssets, totalSupplyShares);

        if (amount >= assetsMax) {
            (assetsWithdrawn, ) = _morpho.withdraw(_marketParams, 0, shares, address(this), address(this));
        } else {
            (assetsWithdrawn, ) = _morpho.withdraw(_marketParams, amount, 0, address(this), address(this));
        }

        return assetsWithdrawn;
    }

However, in the _undeploy function, the calculation of assetsMax does not account for the accrued interest and fees over time. This may result in assetsMax being underestimated compared to its actual value. On the other hand, the amount parameter includes accrued interest and fees, which can lead to the function entering the wrong branch. If the function mistakenly enters the second branch, it may incorrectly convert all _morpho shares in the strategy to assets and send them to the withdrawer. In this case, the strategy’s assets will be 0, but the vault shares will still remain in the vault. The remaining shareholders in the vault will not be able to normally claim assets.

Example:

  1. Initial Deposits:
    user1 deposits 1e16 assets into the vault and receives 1e16 shares. user2 deposits 1e20 assets and receives 1e20 shares.
  2. Interest Accumulation:
    After some time, the _morpho strategy generates interest. The vault’s totalAssets() now returns 1e20 + 1e16 + 1e17 assets.
  3. Redeem by user2:
    user2 decides to redeem all their shares. When calculating withdrawAmount = (shares * totalAssets()) / totalSupply(), totalAssets() includes the interest. The calculated withdrawAmount is passed to the _undeploy function. In _undeploy, the maximum amount of assets that can be converted from the current _morpho shares (assetsMax) is calculated. However, since the calculation of assetsMax does not account for the interest, assetsMax < withdrawAmount.

    As a result, the strategy withdraws all _morpho shares, converts them into assets, and sends them to user2. This means user2 inadvertently receives both their own principal and interest as well as user1’s principal and interest.

  4. Abnormal State:
    Now, the strategy holds no _morpho shares, so totalAssets() returns 0. However, the vault still has user1’s 1e16 shares. This creates an abnormal state in the vault.

When calculating assetsMax, consider the accrued interest and fees that have not been updated.

    function _undeploy(uint256 amount) internal override returns (uint256) {
        Id id = _marketParams.id();
        uint256 assetsWithdrawn = 0;
-       uint256 totalSupplyAssets = _morpho.totalSupplyAssets(id);
-       uint256 totalSupplyShares = _morpho.totalSupplyShares(id);

-       uint256 shares = _morpho.supplyShares(id, address(this));
-       uint256 assetsMax = shares.toAssetsDown(totalSupplyAssets, totalSupplyShares);
+       uint256 assetsMax = _morpho.expectedSupplyAssets(_marketParams, address(this));

        if (amount >= assetsMax) {
            (assetsWithdrawn, ) = _morpho.withdraw(_marketParams, 0, shares, address(this), address(this));
        } else {
            (assetsWithdrawn, ) = _morpho.withdraw(_marketParams, amount, 0, address(this), address(this));
        }

        return assetsWithdrawn;
    }

chefkenji (BakerFi) confirmed

BakerFi mitigated:

PR-22

Status: Mitigation confirmed. Full details in reports from 0xlemon and shaflow2.


[M-12] Cannot withdraw tokens from all strategies in MultiStrategyVault when one third party is paused

Submitted by klau5

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/MultiStrategy.sol#L148

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/MultiStrategy.sol#L173

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/MultiStrategy.sol#L226

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/MultiStrategy.sol#L264

Finding description and impact

When even one third party integrated with MultiStrategyVault is paused, withdrawals become impossible from all Strategies. There is no way to remove the paused third party (Strategy).

Proof of Concept

Third parties integrated with this project can be paused according to their circumstances. For example, when the AAVE V3 pool is paused, transactions attempting deposits or withdrawals from this pool will revert (reference). The audit scope explicitly states that third party pausability is included.

While it makes sense for a single strategy vault to be paused when an integrated third party is paused, a multi strategy vault should not halt operations with other third parties just because one is paused. This is because funds invested in other strategies could be put at risk due to a single third party.

Therefore, there needs to be a way to temporarily exclude paused third parties (Strategies). Without this ability, transactions will revert because it attempts deposits/withdrawals from the paused third party.

  • For deposits, the weight of the strategy linked to that third party can be temporarily set to 0 to exclude it from deposit targets.
  • However, for withdrawals, it attempts to withdraw based on the percentage of totalAssets deposited, not weight, so even if weight is 0, it attempts to withdraw to the paused third party. This transaction will be reverted, so the user will not be able to withdraw.
  • Since withdrawals are impossible, asset reallocation through rebalance is also impossible.
  • Attempting to remove the strategy connected to that third party using removeStrategy will try to withdraw asset tokens for redistribution to other strategies. This will revert, making it impossible to remove the problematic strategy.

In other words, while a paused third party can be excluded from deposits, it cannot be excluded from withdrawals. If that third party’s pool becomes permanently paused, all tokens deposited in the MultiStrategyVault will be permanently locked.

function _allocateAssets(uint256 amount) internal returns (uint256 totalDeployed) {
    totalDeployed = 0;
    for (uint256 i = 0; i < _strategies.length; ) {
@>      uint256 fractAmount = (amount * _weights[i]) / _totalWeight;
        if (fractAmount > 0) {
@>          totalDeployed += IStrategy(_strategies[i]).deploy(fractAmount);
        }
        unchecked {
            i++;
        }
    }
}

function _deallocateAssets(uint256 amount) internal returns (uint256 totalUndeployed) {
    uint256[] memory currentAssets = new uint256[](_strategies.length);

    uint256 totalAssets = 0;
    uint256 strategiesLength = _strategies.length;

    for (uint256 i = 0; i < strategiesLength; i++) {
@>      currentAssets[i] = IStrategy(_strategies[i]).totalAssets();
        totalAssets += currentAssets[i];
    }
    totalUndeployed = 0;
    for (uint256 i = 0; i < strategiesLength; i++) {
@>      uint256 fractAmount = (amount * currentAssets[i]) / totalAssets;
@>      totalUndeployed += IStrategy(_strategies[i]).undeploy(fractAmount);
    }
}

function _rebalanceStrategies(uint256[] memory indexes, int256[] memory deltas) internal {
    ...
    // Iterate through each strategy to adjust allocations
    for (uint256 i = 0; i < totalStrategies; i++) {
        // if the delta is 0, we don't need to rebalance the strategy
        if (deltas[i] == 0) continue;

        // if the delta is positive, we need to deploy the strategy
        if (deltas[i] > 0) {
            uint256 balanceOf = IERC20(_strategies[indexes[i]].asset()).balanceOf(address(this));
            uint256 amount = uint256(deltas[i]) > balanceOf ? balanceOf : uint256(deltas[i]);
            IStrategy(_strategies[indexes[i]]).deploy(amount);
            // if the delta is negative, we need to undeploy the strategy
        } else if (deltas[i] < 0) {
@>          IStrategy(_strategies[indexes[i]]).undeploy(uint256(-deltas[i]));
        }
    }
    ...
}

function removeStrategy(uint256 index) external onlyRole(VAULT_MANAGER_ROLE) {
    ...
    // If the strategy has assets, undeploy them and allocate accordingly
@>  if (strategyAssets > 0) {
@>      IStrategy(_strategies[index]).undeploy(strategyAssets);
        _allocateAssets(strategyAssets);
    }
    ...
}

We need a way to exclude third parties (Strategies) from withdrawals if they are unavailable. We need to be able to exclude a strategy without making a withdrawal request.

chefkenji (BakerFi) acknowledged


[M-13] The Vault Manager is unable to delete the last strategy from MultiStrategyVault

Submitted by pfapostol

The removeStrategy function in the MultiStrategy contract allows the removal of a strategy and redistributes the withdrawn funds among the remaining strategies.

  • Refered code:

        if (strategyAssets > 0) {
            IStrategy(_strategies[index]).undeploy(strategyAssets);
            _allocateAssets(strategyAssets);
        }

The issue arises when the last strategy is removed. The weight (_weights[index]) of the last strategy is first subtracted from _totalWeight, which results in _totalWeight being zero, and it is then set to zero.

Later, when _allocateAssets is called: for each of the active _strategies (the last strategy has not yet been removed), it attempts to calculate the fraction of the input amount. However, since _totalWeight is zero, the execution is reverted with a “panic: division or modulo by zero” error.

  • Vulnerable logic:

    function _allocateAssets(uint256 amount) internal returns (uint256 totalDeployed) {
        totalDeployed = 0;
        for (uint256 i = 0; i < _strategies.length; ) {
            uint256 fractAmount = (amount * _weights[i]) / _totalWeight;
            if (fractAmount > 0) {
                totalDeployed += IStrategy(_strategies[i]).deploy(fractAmount);
            }
            unchecked {
                i++;
            }
        }
    }

Impact

The VAULT_MANAGER_ROLE would be unable to delete the last strategy.

Proof of Concept

This PoC follows these steps:

  1. Deploy the StrategyUniV3SwapAnd strategy (or any other strategy) and initialize the MultiStrategyVault with this strategy to emulate the state where only one strategy remains in the vault.
  2. Deposit a certain amount into the vault.
  3. The VAULT_MANAGER_ROLE, in this case the same person as the DEPLOYER, attempts to remove the strategy.

However, the execution fails with:

    │   ├─ [109194] MultiStrategyVault::removeStrategy(0) [delegatecall]
...
    │   │   └─ ← [Revert] panic: division or modulo by zero (0x12)
    │   └─ ← [Revert] panic: division or modulo by zero (0x12)    
// SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.24;

import {Test} from "lib/forge-std/src/Test.sol";
import {console} from "lib/forge-std/src/console.sol";
import {MockERC20} from "lib/forge-std/src/mocks/MockERC20.sol";

import {Command} from "contracts/core/MultiCommand.sol";
import {MultiStrategy} from "contracts/core/MultiStrategy.sol";
import {MultiStrategyVault} from "contracts/core/MultiStrategyVault.sol";
import {BakerFiProxy} from "contracts/proxy/BakerFiProxy.sol";
import {BakerFiProxyAdmin} from "contracts/proxy/BakerFiProxyAdmin.sol";
import {StrategyUniV3SwapAnd} from "contracts/core/strategies/StrategyUniV3SwapAnd.sol";
import {OracleMock} from "contracts/mocks/OracleMock.sol";
import {UniV3RouterMock} from "contracts/mocks/UniV3RouterMock.sol";
import {StrategyPark} from "contracts/core/strategies/StrategyPark.sol";
import {IStrategy} from "contracts/interfaces/core/IStrategy.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {UseUnifiedSwapper} from "contracts/core/hooks/swappers/UseUnifiedSwapper.sol";

contract PoCs is Test {
    address immutable DEPLOYER = makeAddr("DEPLOYER");
    address immutable USER1 = makeAddr("USER1");

    address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;

    address constant WstETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0;

    function setUp() public {
        vm.label(WETH, "WETH");
        vm.label(WstETH, "WstETH");

        vm.createSelectFork("https://rpc.ankr.com/eth");
    }

    function test_removeStrategy_last_not_removed() public {
        vm.startPrank(DEPLOYER);
        address bakerFiProxyAdmin = address(new BakerFiProxyAdmin(DEPLOYER));

        // @note strategy deployment logic from test/core/strategies/StrategyUniV3SwapPark.ts
        uint256 WETH_SUPPLY = 100000 ether;
        deal(address(WETH), DEPLOYER, WETH_SUPPLY);

        uint256 WstETH_SUPPLY = 30000000 ether;
        deal(address(WstETH), DEPLOYER, WstETH_SUPPLY);

        OracleMock mockOracle = new OracleMock();
        mockOracle.setDecimals(18);
        mockOracle.setLatestPrice(1e16);

        UniV3RouterMock mockUniV3 = new UniV3RouterMock(
            IERC20(address(WETH)),
            IERC20(address(WstETH))
        );

        MockERC20(WETH).transfer(address(mockUniV3), WETH_SUPPLY);
        MockERC20(WstETH).transfer(address(mockUniV3), WstETH_SUPPLY);
        mockUniV3.setPrice(1e11);

        StrategyPark strategyPark = new StrategyPark(DEPLOYER, address(WstETH));

        StrategyUniV3SwapAnd strategyUniV3SwapAnd = new StrategyUniV3SwapAnd(
            DEPLOYER,
            IERC20(address(WETH)),
            strategyPark,
            mockOracle,
            mockUniV3,
            3000
        );

        strategyPark.transferOwnership(address(strategyUniV3SwapAnd));
        vm.label(address(strategyUniV3SwapAnd), "StrategyUniV3SwapAnd");
        // @note End of strategy deployment

        strategyUniV3SwapAnd.setMaxSlippage(1e8 /* 10% */);

        address impl = address(new MultiStrategyVault());

        IStrategy[] memory istrategies = new IStrategy[](1);
        istrategies[0] = strategyUniV3SwapAnd;
        uint16[] memory iweights = new uint16[](1);
        iweights[0] = 100;

        MultiStrategyVault msVault = MultiStrategyVault(
            payable(
                address(
                    new BakerFiProxy(
                        impl,
                        bakerFiProxyAdmin,
                        abi.encodeWithSelector(
                            MultiStrategyVault.initialize.selector,
                            DEPLOYER,
                            "AuditPoC",
                            "APC",
                            WETH,
                            istrategies,
                            iweights,
                            WETH
                        )
                    )
                )
            )
        );

        vm.startPrank(DEPLOYER);
        deal(address(WETH), DEPLOYER, 10 ether);
        MockERC20(WETH).approve(address(msVault), 10 ether);
        msVault.deposit(10 ether, DEPLOYER);

        msVault.removeStrategy(0);
    }
}

There are several ways to improve the code to fix the issue (such as adding a check for zero, etc.). However, the most straightforward and direct approach is to remove the strategy from _strategies before calling _allocateAssets:

    function removeStrategy(
        uint256 index
    ) external onlyRole(VAULT_MANAGER_ROLE) {
        // Validate the index to ensure it is within bounds
        if (index >= _strategies.length) revert InvalidStrategyIndex(index);

        // Retrieve the total assets managed by the strategy to be removed
        uint256 strategyAssets = _strategies[index].totalAssets();

        // Update the total weight and mark the weight of the removed strategy as zero
        _totalWeight -= _weights[index];
        _weights[index] = 0;

        IStrategy cache_strategy = _strategies[index];

        // Move the last strategy to the index of the removed strategy to maintain array integrity
        uint256 lastIndex = _strategies.length - 1;
        if (index < lastIndex) {
            _strategies[index] = _strategies[lastIndex];
            _weights[index] = _weights[lastIndex];
        }

        emit RemoveStrategy(address(_strategies[lastIndex]));
        // Remove the last strategy and weight from the arrays
        _strategies.pop();
        _weights.pop();

        // If the strategy has assets, undeploy them and allocate accordingly
        if (strategyAssets > 0) {
            IStrategy(cache_strategy).undeploy(strategyAssets);
            _allocateAssets(strategyAssets);
        }
    }3

chefkenji (BakerFi) confirmed

0xpiken (warden) commented:

The last strategy should not be allowed to be removed since MultiStrategyVault allocates assets accordingly to its strategies. Removing the last strategy will leads DoS on MultiStrategyVault. The worse is that no user can withdraw their assets since _deallocateAssets() will return totalUndeployed as 0.

pfapostol (warden) commented:

Yes, but that sounds more like a second problem, unrelated to this one. The order of operations in the function is clearly incorrect.

Dravee (judge) commented:

Per the sponsor:

I understand both points but for me is an issue because it leaves the vault on a weird state (funds are waiting for a rebalance) that could only be unlocked by the vault manager with a rebalance

This finding is still valid.

BakerFi mitigated:

PR-18

Status: Mitigation confirmed. Full details in reports from shaflow2 and 0xlemon.


[M-14] The StrategySupplyMorpho allow to use wrong token in _asset

Submitted by pfapostol

The StrategySupplyMorpho is designed to supply tokens to a specific market in MorphoBlue. To achieve this, it defines a _marketParams structure that stores all the necessary information for the target market, including loanToken, which is transferred from the strategy during the IMorpho.supply call.

However, the strategy also allows the deployer to specify a different token via the _asset variable. This token is used to pull tokens from users during deposits and transfer tokens to users during withdrawals.

It is unclear whether this behavior is intended by design (e.g., treating _asset as a “collateral” token) or if _asset is always meant to be the same as loanToken in _marketParams. Regardless, the protocol will not function if _asset is different from loanToken.

The main issue is that the _asset variable is used to approve MorphoBlue:

    constructor(
        address initialOwner,
        address asset_,
        address morphoBlue,
        Id morphoMarketId
    ) StrategySupplyBase(initialOwner, asset_) {
...
        if (!ERC20(asset_).approve(morphoBlue, type(uint256).max)) {
    constructor(address initialOwner, address asset_) ReentrancyGuard() Ownable() {
...
        _asset = asset_;

But later, in the _deploy call, the loanToken is used to supply to a Morpho position:

    function _deploy(uint256 amount) internal override returns (uint256) {
        (uint256 deployedAmount, ) = _morpho.supply(_marketParams, amount, 0, address(this), hex"");
    function supply(
        MarketParams memory marketParams,
        uint256 assets,
        uint256 shares,
        address onBehalf,
        bytes calldata data
    ) external returns (uint256, uint256) {
...
        IERC20(marketParams.loanToken).safeTransferFrom(msg.sender, address(this), assets);

Impact

If these two tokens are different, the strategy will be unusable. Even if the design intends for _asset to act as collateral for loanTokens supplied externally (e.g., an initial supply), the protocol will still fail due to the absence of allowance for loanToken in the Morpho market.

Proof of Concept

This PoC demonstrates the following:

  1. Create two strategies:

    • One in a healthy state.
    • Another broken due to token mismatch.
  2. Approve the _asset token (in this case, WETH) for both strategies.
  3. Call deploy on both strategies:

    • The first strategy succeeds because the token pulled from the user and supplied to Morpho is the same.
    • The second strategy fails because, although WETH is pulled from the user, Morpho does not have allowance for USDC.

      ├─ [101109] StrategySupplyMorpho_Broken::deploy(10000000000000000000 [1e19])
      ...
      │   │   │   │   ├─ [26048] WETH::transferFrom(DEPLOYER: [0x3A383B39c10856a75B9E3f6eda6fCC8fC3334050], StrategySupplyMorpho_Broken: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 10000000000000000000 [1e19])
      │   │   │   │   │   ├─ emit Transfer(from: DEPLOYER: [0x3A383B39c10856a75B9E3f6eda6fCC8fC3334050], to: StrategySupplyMorpho_Broken: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], value: 10000000000000000000 [1e19])
      ...
      │   ├─ [71962] 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb::supply(MarketParams({ loanToken: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, collateralToken: 0x14d60E7FDC0D71d8611742720E4C50E7a974020c, oracle: 0x68066D2891254F1F3285Cac0bB16B65B28EE3cAb, irm: 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC, lltv: 915000000000000000 [9.15e17] }), 10000000000000000000 [1e19], 0, StrategySupplyMorpho_Broken: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 0x)
      ...
      │   │   ├─ [8384] USDC::transferFrom(StrategySupplyMorpho_Broken: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb, 10000000000000000000 [1e19])
      │   │   │   ├─ [7573] 0x43506849D7C04F9138D1A2050bbF3A0c054402dd::transferFrom(StrategySupplyMorpho_Broken: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb, 10000000000000000000 [1e19]) [delegatecall]
      │   │   │   │   └─ ← [Revert] revert: ERC20: transfer amount exceeds allowance
      │   │   │   └─ ← [Revert] revert: ERC20: transfer amount exceeds allowance
// SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.24;

import {Test} from "lib/forge-std/src/Test.sol";
import {console} from "lib/forge-std/src/console.sol";
import {MockERC20} from "lib/forge-std/src/mocks/MockERC20.sol";

import {StrategySupplyMorpho, StrategySupplyBase} from "contracts/core/strategies/StrategySupplyMorpho.sol";
import {IMorpho, Id} from "node_modules/@morpho-org/morpho-blue/src/interfaces/IMorpho.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract PoCs is Test {
    address immutable DEPLOYER = makeAddr("DEPLOYER");
    address immutable USER1 = makeAddr("USER1");

    address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
    address constant WstETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0;
    address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;

    address constant MORPHO_BLUE = 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb;
    Id constant USCC_USDC_MARKET =
        Id.wrap(
            bytes32(
                0x1a9ccaca2dba9469cd9cba3d077466761b05f465c412d2bf2c71614c4963dd84
            )
        );
    Id constant wstETH_WETH_MARKET =
        Id.wrap(
            bytes32(
                0xb8fc70e82bc5bb53e773626fcc6a23f7eefa036918d7ef216ecfb1950a94a85e
            )
        );

    function setUp() public {
        vm.label(WETH, "WETH");
        vm.label(WstETH, "WstETH");

        vm.createSelectFork("https://rpc.ankr.com/eth");
    }

    function test_wrong_token_for_market() public {
        StrategySupplyMorpho _strategy_normal = new StrategySupplyMorpho(
            DEPLOYER,
            WETH,
            MORPHO_BLUE,
            wstETH_WETH_MARKET
        );

        StrategySupplyMorpho _strategy_broken = new StrategySupplyMorpho(
            DEPLOYER,
            WETH,
            MORPHO_BLUE,
            USCC_USDC_MARKET
        );
        vm.label(address(_strategy_normal), "StrategySupplyMorpho_Normal");
        vm.label(address(_strategy_broken), "StrategySupplyMorpho_Broken");
        vm.label(USDC, "USDC");

        vm.startPrank(DEPLOYER);
        deal(WETH, DEPLOYER, 20 ether);
        deal(USDC, address(_strategy_broken), 1000 ether);
        IERC20(WETH).approve(address(_strategy_normal), 10 ether);
        IERC20(WETH).approve(address(_strategy_broken), 10 ether);

        _strategy_normal.deploy(10 ether);
        _strategy_broken.deploy(10 ether);
    }
}

In the constructor, either:

  1. Set the _asset token to match the loanToken from MarketParams:
        _marketParams = _morpho.idToMarketParams(morphoMarketId);

        _asset = _marketParams.loanToken;

        // Allowance approval
        if (!ERC20(_marketParams.loanToken).approve(morphoBlue, type(uint256).max)) {
            revert FailedToApproveAllowanceForMorpho();
        }
  1. Validate that _asset matches the loanToken:
if (asset_ != _marketParams.loanToken) revert();

chefkenji (BakerFi) confirmed

BakerFi mitigated:

PR-6

Status: Mitigation confirmed. Full details in reports from 0xlemon and shaflow2.


[M-15] VaultRouter cannot be used for deposits when it reaches the maximum deposit limit

Submitted by 0xlemon

https://github.com/code-423n4/2024-12-bakerfi/blob/main/contracts/core/VaultBase.sol#L251

https://github.com/code-423n4/2024-12-bakerfi/blob/main/contracts/core/VaultRouter.sol#L116

Summary

VaultRouter cannot be used for deposits when it reaches the maximum deposit limit because this contract is the msg.sender to the vault and it is treated as a depositor who has a limit.

Vulnerability Details

When doing deposits to a vault from the VaultRouter the router does an external call to the vault meaning that in Vault’s case msg.sender will be the router itself. The protocol, however, enforces a max deposit limit for depositors. This means that after the VaultRouter reaches the vault’s getMaxDeposit() no one will be able to deposit to the vault using the router.

Since the vault looks at balanceOf(msg.sender) for the deposit limit, an attacker can use the router to deposit to the vault specifying the recipient to be the router itself and then immediately withdrawing in the same transaction so that his tokens won’t be stolen. He can do that to reach VaultRouter deposit limit and now no one will be able to deposit through the router.

    function _depositInternal(uint256 assets, address receiver) private returns (uint256 shares) {
        //...

        // Check if deposit exceeds the maximum allowed per wallet
        uint256 maxDepositLocal = getMaxDeposit();
        if (maxDepositLocal > 0) {
@->            uint256 depositInAssets = (balanceOf(msg.sender) * _ONE) / tokenPerAsset();
            uint256 newBalance = assets + depositInAssets;
            if (newBalance > maxDepositLocal) revert MaxDepositReached();
        }
        //...
    }

Impact

DoS of the router’s deposit functionality

You can try to enforce the same deposit limit on the router level and give the router unlimited deposit limit

klau5 (warden) commented:

Same root cause different impact. S-2 mitigation would work.

Dravee (judge) commented:

While the finding here is interesting, according to the Supreme Court decisions, this should be a duplicate.

0xlemon (warden) commented:

@Dravee - the recommended mitigation in S-2 is absolutely wrong. As I said previously it is a deposit limit so taking the balanceOf (receiver) does absolutely nothing but limit the amount a single user can “hold”.

The correct way to fix S-2 would be to implement some kind of a mapping to account for any deposit and limit the amount msg.sender can deposit. This, however, doesn’t solve this finding. The root cause is not the same because here it is shown the interactions between VaultRouter and the Vault and it blocks the VaultRouter from depositing.

Dravee (judge) commented:

0xlemon, I agree. Additionally, making S-15 as primary instead of S-2 due to the wrong mitigation.

shaflow2 (warden) commented:

  1. The current implementation of the deposit limit in the system is incorrect. This report is based on the issue that the sponsor implemented an erroneous mitigation measure. Therefore, this issue should be categorized under implementation problems related to the deposit limit. Relevant historical judgments:https://github.com/code-423n4/2024-08-chakra-findings/issues/33
  2. In report s-2, it was mentioned:

    “Additionally, considering the whitelist mechanism, if you want to limit the deposit amount for whitelisted addresses, it is recommended to create a mapping to store the deposit amount for each address, rather than checking the balanceOf.”

    This point was not elaborated further because managing the deposit limit mapping is challenging. For example, if an address has a deposit limit of 1000, and it deposits 1000, increasing depositLimit[addr1] by 1000. If this address then transfers shares to another address addr2, and addr2 withdraws, with depositLimit[addr2] = 0, how should the deposit limit be deducted? If the deposit limit is not reduced, under the condition that the whitelist is not increased, the total assets in the system will only decrease over time.

0xlemon (warden) commented:

shaflow2 - the problem in this issue is that the VaultRouter is the msg.sender and since we are limiting msg.sender it will get blocked after it reaches the limit. The problem in S-15 is that balanceOf was used to account for the deposit limit. Do you see the difference? In my problem it doesn’t matter how this deposit limit is implemented when it limits msg.sender as it can be seen.

For the mapping part first of all it would be highly unlikely for a user to just give (transfer) his tokens away to someone but even if they do why do we need to deduct the limit when transfering? As I said it doesn’t matter how much a single user holds, we only need to make sure he doesn’t deposit more than maxDepositLocal

MrPotatoMagic (warden) commented:

@Dravee, I agree with klau5 and shaflow2. This issue should not be considered as separate.

According to the SC ruling here, if fixing the root cause (in a reasonable manner) resolves the issue, they’re dups. It is important to focus on the point of fixing the issue in reasonable manner here.

How can the issue actually be mitigated?

  • If msg.sender is the router, take in another parameter routerMsgSender to the deposit()/_depositInternal() function that stores the msg.sender of the router contract.
  • Implement an if check in deposit()/_depositInternal() that when msg.sender == router is true, we utilize the parameter routerMsgSender to check the limit as per the method suggested by S-2. I.e., “it is recommended to create a mapping to store the deposit amount for each address”.

Both the issues are two sides of the same coin. A universal mitigation as mentioned above resolves both of them.

Dravee (judge) commented:

I’m agreeing with 0xlemon on this one, as reasonably fixing the other issue (by strictly fixing the other issue) would leave this current issue unfixed (unless already aware of this finding, which would be unreasonable).

chefkenji (BakerFi) disputed and commented:

Duplicate issue

BakerFi mitigated:

PR-4

Status: Mitigation confirmed. Full details in the report from 0xlemon.


[M-16] StrategySupplyBase.undeploy does not return the amount of assets actually undeployed, which can cause a withdrawal to fail

Submitted by klau5

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/strategies/StrategySupplyBase.sol#L128

Finding description and impact

The StrategySupplyBase.undeploy function returns the user’s requested amount instead of the actual withdrawn asset amount. When attempting to transfer more tokens than what was actually withdrawn, the transaction will revert, preventing users from withdrawing their tokens.

Proof of Concept

The StrategySupplyBase.undeploy function should return the actual amount of asset tokens withdrawn. However, it just returns the amount parameter requested by the user.

function undeploy(uint256 amount) external nonReentrant onlyOwner returns (uint256 undeployedAmount) {
    if (amount == 0) revert ZeroAmount();

    // Get Balance
    uint256 balance = getBalance();
    if (amount > balance) revert InsufficientBalance();

    // Transfer assets back to caller
@>  uint256 withdrawalValue = _undeploy(amount);

    // Check withdrawal value matches the initial amount
    // Transfer assets to user
@>  ERC20(_asset).safeTransfer(msg.sender, withdrawalValue);

    balance -= amount;
    emit StrategyUndeploy(msg.sender, withdrawalValue);
    emit StrategyAmountUpdate(balance);

@>  return amount;
}

The actual withdrawn asset amount may not match the user’s requested amount. For example, in StrategySupplyMorpho._undeploy, if the user requests more assets than what can be withdrawn from Morpho, it will only withdraw the maximum available amount and return that value. However, StrategySupplyBase.undeploy ignores this and returns the user’s requested amount.

function _undeploy(uint256 amount) internal override returns (uint256) {
    Id id = _marketParams.id();
    uint256 assetsWithdrawn = 0;
    uint256 totalSupplyAssets = _morpho.totalSupplyAssets(id);
    uint256 totalSupplyShares = _morpho.totalSupplyShares(id);

    uint256 shares = _morpho.supplyShares(id, address(this));
    uint256 assetsMax = shares.toAssetsDown(totalSupplyAssets, totalSupplyShares);

@>  if (amount >= assetsMax) {
        (assetsWithdrawn, ) = _morpho.withdraw(_marketParams, 0, shares, address(this), address(this));
    } else {
@>      (assetsWithdrawn, ) = _morpho.withdraw(_marketParams, amount, 0, address(this), address(this));
    }

@>  return assetsWithdrawn;
}

StrategySupplyBase._redeemInternal assumes that the amount returned by StrategySupplyBase.undeploy is the actual withdrawn token amount and attempts to transfer this to the user. However, this amount could be larger than what was actually withdrawn. Attempting to transfer more tokens will cause the transaction to revert, preventing users from withdrawing their tokens.

function _redeemInternal(
    uint256 shares,
    address receiver,
    address holder,
    bool shouldRedeemETH
) private returns (uint256 retAmount) {
    ...

@>  uint256 amount = _undeploy(withdrawAmount);
    ...

    // Calculate and handle withdrawal fees
    if (getWithdrawalFee() != 0 && getFeeReceiver() != address(0)) {
        fee = amount.mulDivUp(getWithdrawalFee(), PERCENTAGE_PRECISION);

        if (shouldRedeemETH && _asset() == wETHA()) {
            unwrapETH(amount);
@>          payable(receiver).sendValue(amount - fee);
@>          payable(getFeeReceiver()).sendValue(fee);
        } else {
@>          IERC20Upgradeable(_asset()).transfer(receiver, amount - fee);
@>          IERC20Upgradeable(_asset()).transfer(getFeeReceiver(), fee);
        }
    } else {
        if (shouldRedeemETH) {
            unwrapETH(amount);
@>          payable(receiver).sendValue(amount);
        } else {
@>          IERC20Upgradeable(_asset()).transfer(receiver, amount);
        }
    }

    emit Withdraw(msg.sender, receiver, holder, amount - fee, shares);
    retAmount = amount - fee;
}

Use the actual amount of asset tokens withdrawn.

function undeploy(uint256 amount) external nonReentrant onlyOwner returns (uint256 undeployedAmount) {
    if (amount == 0) revert ZeroAmount();

    // Get Balance
    uint256 balance = getBalance();
    if (amount > balance) revert InsufficientBalance();

    // Transfer assets back to caller
    uint256 withdrawalValue = _undeploy(amount);

    // Check withdrawal value matches the initial amount
    // Transfer assets to user
    ERC20(_asset).safeTransfer(msg.sender, withdrawalValue);

-   balance -= amount;
+   balance -= withdrawalValue;
    emit StrategyUndeploy(msg.sender, withdrawalValue);
    emit StrategyAmountUpdate(balance);

-   return amount;
+   return withdrawalValue;
}

chefkenji (BakerFi) confirmed

BakerFi mitigated:

PR-28

Status: Unmitigated. Full details in the report from shaflow2, and also included in the Mitigation Review section below.


Low Risk and Non-Critical Issues

For this audit, 1 report was submitted by a warden detailing low risk and non-critical issues. The report highlighted below by klau5 received the top score from the judge.

[01] Allowance not set to maximum value in enableRoute

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/hooks/swappers/UseUnifiedSwapper.sol#L71-L73

Finding description and impact

Allowance not set to maximum value in enableRoute. When tokens are transferred, allowance needs to be updated every time, which can unnecessarily waste gas costs or exhaust the allowance.

Proof of Concept

In enableRoute, approve is set to type(uint256).max - 1 instead of type(uint256).max. Since the ERC20 allowance is not set to maximum, allowance needs to be updated every time tokens are transferred. This can unnecessarily waste gas costs or exhaust the allowance.

function enableRoute(address tokenIn, address tokenOut, RouteInfo memory routeInfo) external onlyGovernor {
    bytes32 key = _key(tokenIn, tokenOut);
    // Check if the route is already authorized
    if (_routes[key].provider != SwapProvider.NONE) revert RouteAlreadyAuthorized();
    // Set the route information
@>  if (!IERC20(tokenIn).approve(routeInfo.router, type(uint256).max - 1)) revert FailedToApproveAllowance();

@>  if (!IERC20(tokenOut).approve(routeInfo.router, type(uint256).max - 1)) revert FailedToApproveAllowance();

    _routes[key] = routeInfo;
}

Approve with type(uint256).max.

[02] UseIERC4626.withdrawVault does not check if vault is address(0)

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/hooks/UseIERC4626.sol#L132-L133

Proof of Concept

While other functions check if the vault parameter is address(0), UseIERC4626.withdrawVault does not check if vault is address(0).

function withdrawVault(
    IERC4626 vault,
    uint256 assets,
    address receiver,
    address owner
) internal virtual returns (uint256 shares) {
    // Call the withdraw function of the vault to withdraw assets
@>  shares = vault.withdraw(assets, receiver, owner);
}

function redeemVault(
    IERC4626 vault,
    uint256 shares,
    address receiver,
    address owner
) internal virtual returns (uint256 assets) {
    // Check if the vault address is valid
@>  if (address(vault) == address(0)) revert InvalidVaultAddress();
    // Call the redeem function of the vault to redeem shares
    assets = vault.redeem(shares, receiver, owner);
}

The UseIERC4626.withdrawVault function should revert the transaction if vault is address(0).

[03] Wrong event emitted in removeStrategy

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/MultiStrategy.sol#L275

Finding description and impact

The RemoveStrategy event is emitted with an incorrect strategy address.

Proof of Concept

When removing a strategy in MultiStrategy, the last strategy in the array is moved to the position of the strategy being deleted. However, _strategies[lastIndex] still contains the address of the strategy that was at the last index since it hasn’t been updated to the address of the strategy being deleted. When emitting the event, this incorrect address is used, resulting in an event that indicates the wrong strategy was deleted.

function removeStrategy(uint256 index) external onlyRole(VAULT_MANAGER_ROLE) {
    ...
    // Move the last strategy to the index of the removed strategy to maintain array integrity
    uint256 lastIndex = _strategies.length - 1;
    if (index < lastIndex) {
@>      _strategies[index] = _strategies[lastIndex];
        _weights[index] = _weights[lastIndex];
    }

@>  emit RemoveStrategy(address(_strategies[lastIndex]));
    // Remove the last strategy and weight from the arrays
    _strategies.pop();
    _weights.pop();
}

Cache the address of the strategy being deleted and use it when emitting the event.

[04] Strategy with different asset token can be registered to MultiStrategyVault

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/MultiStrategy.sol#L107

Proof of Concept

While the initialize of MultiStrategyVault checks if registered strategies have the same asset, it does not check when adding a new strategy through addStrategy.

function addStrategy(IStrategy strategy) external onlyRole(VAULT_MANAGER_ROLE) {
    if (address(strategy) == address(0)) revert InvalidStrategy();
    _strategies.push(strategy);
    _weights.push(0);
    emit AddStrategy(address(strategy));
}

Check if the asset token of the newly added strategy matches the existing ones in addStrategy.

[05] Allowance remains when removing Strategy from MultiStrategyVault

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/MultiStrategy.sol#L251

Finding description and impact

The allowance for removed strategies remains at maximum value.

Proof of Concept

When calling MultiStrategy.removeStrategy to remove a Strategy, the asset token’s allowance given to the strategy is not reset to 0. Therefore, the previously set maximum allowance remains.

Reset the allowance for the removed Strategy in MultiStrategy.removeStrategy.

[06] MultiStrategyVault does not check for duplicate when registering new strategies

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/MultiStrategy.sol#L71

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/MultiStrategy.sol#L107

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/MultiStrategyVault.sol#L78

Finding description and impact

Duplicate strategies can be registered.

Proof of Concept

When registering new strategies in MultiStrategyVault’s initialize or addStrategy, there is no check for duplicate strategies.

Check for duplicate strategies when registering new strategies in initialize or addStrategy.

[07] Not using the parsed value actionToExecute in VaultRouter.dispatch

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/VaultRouter.sol#L99

Finding description and impact

The code uses action instead of the parsed value actionToExecute to check the Command.

Proof of Concept

When comparing Commands, it should use actionToExecute which is the parsed value of the lower 32 bits. However, when checking for Commands.PULL_TOKEN, it uses action. While this is implicitly cast, it is not strictly correct.

function dispatch(
    uint256 action,
    bytes calldata data,
    uint256[] memory callStack
) internal override returns (bool success, bytes memory output) {
    success = true;
    // Extract the action ID from the lowest 32 bits using bitwise AND with mask
@>  uint32 actionToExecute = uint32(action & Commands.THIRTY_TWO_BITS_MASK);

    // Extract input mapping from bits 32-63 by right shifting 32 bits and masking
    uint32 inputMapping = uint16((action >> 32) & Commands.THIRTY_TWO_BITS_MASK);

    // Extract output mapping from bits 64-95 by right shifting 64 bits and masking
    uint32 outputMapping = uint16(((action >> 64) & Commands.THIRTY_TWO_BITS_MASK));

    if (
        actionToExecute == Commands.V3_UNISWAP_SWAP ||
        actionToExecute == Commands.AERODROME_SWAP ||
        actionToExecute == Commands.V2_UNISWAP_SWAP
    ) {
        output = _handleSwap(data, callStack, inputMapping, outputMapping);
@>  } else if (action == Commands.PULL_TOKEN) {
        output = _handlePullToken(data, callStack, inputMapping);
    } else if (actionToExecute == Commands.PULL_TOKEN_FROM) {
        output = _handlePullTokenFrom(data, callStack, inputMapping);
    } ...
}

Use actionToExecute for comparison.

function dispatch(
    uint256 action,
    bytes calldata data,
    uint256[] memory callStack
) internal override returns (bool success, bytes memory output) {
    ...
    if (
        actionToExecute == Commands.V3_UNISWAP_SWAP ||
        actionToExecute == Commands.AERODROME_SWAP ||
        actionToExecute == Commands.V2_UNISWAP_SWAP
    ) {
        output = _handleSwap(data, callStack, inputMapping, outputMapping);
-    } else if (action == Commands.PULL_TOKEN) {
+    } else if (actionToExecute == Commands.PULL_TOKEN) {
        output = _handlePullToken(data, callStack, inputMapping);
    } else if (actionToExecute == Commands.PULL_TOKEN_FROM) {
        output = _handlePullTokenFrom(data, callStack, inputMapping);
    } ...
}

[08] Unused storage variable _approvedSwapTokens exists

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/VaultRouter.sol#L37

Finding description and impact

There is an unused storage variable.

Proof of Concept

The _approvedSwapTokens variable in VaultRouter has no usage. This variable is presumably intended to hold information about swappable tokens, but this is instead handled by the UseUnifiedSwapper contract.

contract VaultRouter is UseUnifiedSwapper, UseTokenActions, UsePermitTransfers, UseIERC4626, UseWETH, MultiCommand {
    /// @notice Mapping of approved swap tokens
@>  mapping(IERC20 => bool) private _approvedSwapTokens;

Remove the unnecessary _approvedSwapTokens variable.

[09] depositNative, withdrawNative, redeemNative functions cannot be called from Router

https://github.com/code-423n4/2024-12-bakerfi/blob/0daf8a0547b6245faed5b6cd3f5daf44d2ea7c9a/contracts/core/VaultRouter.sol#L93-L128

Finding description and impact

The VaultRouter has no commands to call Vault’s depositNative, withdrawNative, and redeemNative functions, making these functions uncallable.

Proof of Concept

While VaultBase’s deposit and withdrawal functions can only be called by whitelisted addresses, and VaultRouter is expected to be whitelisted, the VaultRouter does not have commands to call Vault’s depositNative, withdrawNative, and redeemNative functions. Therefore, these functions cannot be called in practice.

Add commands to call depositNative, withdrawNative, and redeemNative functions in VaultRouter.


Mitigation Review

Introduction

Following the C4 audit, 2 wardens (0xlemon and shaflow2) reviewed the mitigations for all identified issues. Additional details can be found within the C4 BakerFi Mitigation Review repository.

Mitigation Review Scope

URL Mitigation of
https://github.com/baker-fi/bakerfi-contracts/pull/17 H-01
https://github.com/baker-fi/bakerfi-contracts/pull/15 H-02
https://github.com/baker-fi/bakerfi-contracts/pull/12 H-03
https://github.com/baker-fi/bakerfi-contracts/pull/24 H-04
https://github.com/baker-fi/bakerfi-contracts/pull/23 H-05
https://github.com/baker-fi/bakerfi-contracts/pull/20 H-06
https://github.com/baker-fi/bakerfi-contracts/pull/19 H-07
https://github.com/baker-fi/bakerfi-contracts/pull/27 M-01
https://github.com/baker-fi/bakerfi-contracts/pull/13 M-02
https://github.com/baker-fi/bakerfi-contracts/pull/16 M-03
https://github.com/baker-fi/bakerfi-contracts/pull/3 M-06
https://github.com/baker-fi/bakerfi-contracts/pull/4 M-07
https://github.com/baker-fi/bakerfi-contracts/pull/11 M-08
https://github.com/baker-fi/bakerfi-contracts/pull/26 M-09
https://github.com/baker-fi/bakerfi-contracts/pull/5 M-10
https://github.com/baker-fi/bakerfi-contracts/pull/22 M-11
https://github.com/baker-fi/bakerfi-contracts/pull/18 M-13
https://github.com/baker-fi/bakerfi-contracts/pull/6 M-14
https://github.com/baker-fi/bakerfi-contracts/pull/4 M-15
https://github.com/baker-fi/bakerfi-contracts/pull/28 M-16

Out of Scope

All sponsor acknowledged (wontfix) findings, including:

Mitigation Review Summary

During the mitigation review, wardens determined that 3 in-scope findings were unmitigated. They also surfaced 2 new medium severity findings. The table below provides details regarding the status of each in-scope vulnerability from the original audit, followed by full details on the unmitigated issues, as well as the new issues that were discovered.

Original Issue Status Full Details
H-01 Mitigation confirmed Reports from shaflow2 and 0xlemon
H-02 Mitigation confirmed Reports from 0xlemon and shaflow2
H-03 Mitigation confirmed Reports from shaflow2 and 0xlemon
H-04 Mitigation confirmed Reports from 0xlemon and shaflow2
H-05 Mitigation confirmed Reports from 0xlemon and shaflow2
H-06 Mitigation confirmed Reports from 0xlemon and shaflow2
H-07 Mitigation confirmed Reports from 0xlemon and shaflow2
M-01 Unmitigated Report from shaflow2
M-02 Mitigation confirmed Reports from 0xlemon and shaflow2
M-03 Mitigation confirmed Reports from 0xlemon and shaflow2
M-06 Mitigation confirmed Reports from 0xlemon and shaflow2
M-07 Unmitigated Report from shaflow2
M-08 Mitigation confirmed Reports from 0xlemon and shaflow2
M-09 Mitigation confirmed Reports from shaflow2 and 0xlemon
M-10 Mitigation confirmed Reports from 0xlemon and shaflow2
M-11 Mitigation confirmed Reports from 0xlemon and shaflow2
M-13 Mitigation confirmed Reports from shaflow2 and 0xlemon
M-14 Mitigation confirmed Reports from 0xlemon and shaflow2
M-15 Mitigation confirmed Report from 0xlemon
M-16 Unmitigated Report from shaflow2

[M-01] Unmitigated

Submitted by shaflow2

Original issue

M-01: https://code4rena.com/evaluate/2024-12-bakerfi-invitational/findings/F-3

Mitigation Issues

  1. Ineffective final statement in maxMint function: The last statement in the maxMint function has no effect. When maxAssets equals type(uint256).max, maxShares might incorrectly return type(uint256).max, which can lead to unintended behavior.
  function maxMint(address receiver) external view override returns (uint256 maxShares) {
    uint256 maxAssets = _maxDepositFor(receiver);
    maxShares = this.convertToShares(maxAssets);
    maxAssets == 0 || maxAssets == type(uint256).max
      ? maxAssets
      : _convertToShares(maxAssets, false);
  }
  1. Lack of special case handling in maxMint and maxDeposit: The maxMint and maxDeposit functions do not account for special conditions within the system. For example, if there is an Aave strategy involved, the functions should consider Aave’s maximum supply cap limits for assets to prevent exceeding protocol constraints.
    require(
      supplyCap == 0 ||
        ((IAToken(reserveCache.aTokenAddress).scaledTotalSupply() +
          uint256(reserve.accruedToTreasury)).rayMul(reserveCache.nextLiquidityIndex) + amount) <=
        supplyCap * (10 ** reserveCache.reserveConfiguration.getDecimals()),
      Errors.SUPPLY_CAP_EXCEEDED
    );
  1. Does not account for third-party strategy pauses or asset deposit rejections: The system does not consider situations where third-party strategies are paused or reject asset deposits.

VaultBase.sol#L186


[M-07] Unmitigated

Submitted by shaflow2

Original issue

M-07: https://code4rena.com/evaluate/2024-12-bakerfi-invitational/findings/F-16

Mitigation issue

The mitigation measures described in the report were not successfully implemented. The receiver only needs to transfer out the shares, and they can continue minting.

  function _maxDepositFor(address receiver) internal view returns (uint256) {
    uint256 maxDepositLocal = getMaxDeposit();
    uint256 depositInAssets = _convertToAssets(balanceOf(receiver), false);
    if (paused()) return 0;
    if (maxDepositLocal > 0) {
      return depositInAssets > maxDepositLocal ? 0 : maxDepositLocal - depositInAssets;
    }
    return type(uint256).max;
  }

The report suggests creating a mapping for each whitelisted receiver to store the deposit amount. This approach should be implemented accordingly.

VaultBase.sol#L312


[M-16] Unmitigated

Submitted by shaflow2

Original issue

M-16: https://code4rena.com/evaluate/2024-12-bakerfi-invitational/findings/F-43

Mitigation issue

The function will return the actual amount of tokens withdrawn, which has been fixed. However, the balance value calculation is incorrect, causing the contract to emit events with incorrect parameters.

https://github.com/baker-fi/bakerfi-contracts/blob/42eb8e7a09022e0ab4007d768f068874b02c8a50/contracts/core/strategies/StrategySupplyBase.sol#L129

    // Check withdrawal value matches the initial amount
    // Transfer assets to user
    ERC20(_asset).safeTransfer(msg.sender, withdrawalValue);

    balance -= amount;
    emit StrategyUndeploy(msg.sender, withdrawalValue);
    emit StrategyAmountUpdate(balance);

The actual remaining balance should be balance - withdrawalValue, where withdrawalValue is the actual amount of tokens withdrawn.

Dravee (judge) commented:

Warden noted that balance is still substracting amount instead of withdrawalValue, which means the mitigation is incomplete. The original issue is indeed fixed though.


Setting _performanceFee will result in inaccurate fees calculation

Submitted by 0xlemon

Severity: Medium

Vulnerability details

The ADMIN_ROLE can set a new performance fee or enable/disable it; which will be used when calculating the fees for the protocol in _harvestAndMintFees. The VaultSettings::setPerformanceFee() function doesn’t call the harvest function, which would lead to the newly set performance fee to be used for calculation of previous rewards.

We can see the function for setting a fee:

In VaultSettings.sol:

  function setPerformanceFee(uint256 fee) external onlyRole(ADMIN_ROLE) {
    if (fee >= PERCENTAGE_PRECISION) revert InvalidPercentage();
    _performanceFee = fee;
    emit PerformanceFeeChanged(_performanceFee);
  }

This performance fee is later used in VaultBase::_harvestAndMintFees:

  function _harvestAndMintFees() internal {
    uint256 currentPosition = _totalAssets();
    if (currentPosition == 0) {
      return;
    }
    int256 balanceChange = _harvest();
    if (balanceChange > 0) {
      address feeReceiver = getFeeReceiver();
@->      uint256 performanceFee = getPerformanceFee();
      if (feeReceiver != address(this) && feeReceiver != address(0) && performanceFee > 0) {
        uint256 feeInEth = uint256(balanceChange) * performanceFee;
        uint256 sharesToMint = feeInEth.mulDivUp(
          totalSupply(),
          currentPosition * PERCENTAGE_PRECISION
        );
        _mint(feeReceiver, sharesToMint);
      }
    }
  }

Consider the following scenario:

  1. The default performance fee is 1% and the vault currently has 1000 tokens and 100 tokens accrued interest that hasn’t been applied yet because the _harvestAndMintFees hasn’t been called yet.
  2. The admin sets this performance fee to 10%.
  3. Now the VAULT_MANAGER_ROLE calls Vault::rebalance that calls the harvest of the strategy and when calculating the performance fee it will be performanceFee = 10% * 100 tokens = 10 tokens.

In this case users lose funds and didn’t agree to stake when the performance fee is 10%.

The opposite scenario can happen as well. For example, considering the above scenario, if the admin disables the performance fee, no fees will be minted for the protocol causing a loss for the protocol.

Impact

Accrued fees will be incorrectly calculated.

Override the setPerformanceFee function in VaultBase and call _harvestAndMintFees() before setting the new performance fee.

VaultSettings.sol#L173-L176


In StrategySupplyBase::undeploy() subtracting the withdrawalValue from _deployedAmount might lead to an underflow

Submitted by 0xlemon

Severity: Medium

Vulnerability Details

In StrategySupplyBase::undeploy() subtracting the withdrawalValue from _deployedAmount might lead to an underflow because the _deployedAmount variable doesn’t account for the latest accrued interest and, therefore, can be lower than the amount a user is trying to withdraw.

By introducing the following line the protocol correctly mitigated the original issue; however, a new problem has appeared:

  function undeploy(
    uint256 amount
  ) external nonReentrant onlyOwner returns (uint256 undeployedAmount) {
    if (amount == 0) revert ZeroAmount();

    // Get Balance
    uint256 balance = getBalance();
    if (amount > balance) revert InsufficientBalance();

    // Transfer assets back to caller
    uint256 withdrawalValue = _undeploy(amount);

    // Update the deployed amount
@->    _deployedAmount -= withdrawalValue;
    //...
 }

This _deployedAmount gets updated when a harvest is called so that it accounts for the latest accrued interest. However, since we do not call the harvest function before withdrawing/redeeming, the withdrawalValue can be higher than the _deployedAmount, which would result in reverting the withdrawal transaction.

Consider the following case:

  1. Bob deposits 100 tokens to the vault that immediately get deployed to the strategy of the vault and now _deployedAmount = 100 tokens.
  2. After some time these deposited tokens accrue interest and now let’s say the interest is 2 tokens so in the strategy we have 102 tokens in total.
  3. The protocol calls _harvestAndMintFees which updates the _deployedAmount = 102 tokens and mints performance fees.
  4. After some more time these tokens accrue 3 more tokens so now in total we have 105 tokens.
  5. Bob calls Vault::withdraw (105 tokens) which would try to undeploy 105 tokens from the strategy. Since getBalance() of the strategy is 105 tokens it should be possible for Bob to withdraw that amount. However, since the _deployedAmount isn’t updated we get 102 tokens - 105 tokens which would underflow and revert the transaction.

We can see how a case can occur where a user cannot withdraw his tokens.

Impact

Temporary DoS and stuck funds until _harvestAndMintFees is called.

_harvestAndMintFees should be called before withdrawing/redeeming from a vault.

StrategySupplyBase.sol#L123


Disclosures

C4 is an open organization governed by participants in the community.

C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.

C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.