57930 sc high allocation tracking underflow in strategy deallocation leads to protocol insolvency

Submitted on Oct 29th 2025 at 13:54:17 UTC by @dobrevaleri for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #57930

  • Report Type: Smart Contract

  • Report severity: High

  • Target: https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/MYTStrategy.sol

  • Impacts:

    • Protocol insolvency

Description

Brief/Intro

The MYTStrategy::deallocate() function contains an arithmetic underflow vulnerability when attempting to deallocate amounts that include accrued rewards. This occurs because the vault's allocation tracking is stale and doesn't account for yield gains, causing the deallocation calculation to underflow when trying to subtract a larger amount (principal + rewards) from a smaller tracked allocation (principal only).

Vulnerability Details

The root cause lies in the allocation tracking mechanism between VaultV2 and MYTStrategy. When AlchemistAllocator::deallocate() is called, it passes the current vault allocation as data to the strategy:

// AlchemistAllocator.sol
    function deallocate(address adapter, uint256 amount) external {
        require(msg.sender == admin || operators[msg.sender], "PD");
        bytes32 id = IMYTStrategy(adapter).adapterId();
        uint256 absoluteCap = vault.absoluteCap(id);
        uint256 relativeCap = vault.relativeCap(id);
        // FIXME get this from the StrategyClassificationProxy for the respective risk class
        uint256 daoTarget = type(uint256).max;
        uint256 adjusted = absoluteCap < relativeCap ? absoluteCap : relativeCap;
        if (msg.sender != admin) {
            // caller is operator
            adjusted = adjusted < daoTarget ? adjusted : daoTarget;
        }
        // pass the old allocation to the adapter
@>      bytes memory oldAllocation = abi.encode(vault.allocation(id));
        vault.deallocate(adapter, oldAllocation, amount);
    }

However, as documented in VaultV2.sol line 47arrow-up-right: "The allocation is not always up to date, because interest and losses are accounted only when (de)allocating in the corresponding markets."

The vulnerability manifests in MYTStrategy::deallocate():

When a strategy accrues rewards:

  1. The strategy's realAssets() increases to reflect the new total value (principal + rewards)

  2. The vault's allocation(id) remains at the original principal amount (stale)

  3. When attempting to deallocate the full balance including rewards, oldAllocation (1M) - amountDeallocated (1.1M with rewards) causes an underflow

With the increase of the value returned from the strategy's realAssets() function, the VaultV2 totalAssets() increases, which also increase the price of the shares. So the users can withdraw more, as yield accrue.

Impact Details

Once rewards accrue beyond the tracked allocation, they become permanently locked in the strategy. No deallocation operation can extract the full balance without reverting.

The locked yield is still counted in the vault's totalAssets() calculation (via strategy's realAssets()), so users believe they can withdraw it. However, when users attempt to exit, the vault cannot retrieve the locked funds, making the protocol effectively insolvent.

References

VaultV2: https://github.com/morpho-org/vault-v2/blob/406546763343b9ffa84c2f63742ae55a490b7c42/src/VaultV2.sol#L75-L76

Preview deposit and redeem in VaultV2, which use total assets that include the yield from the strategies: https://github.com/morpho-org/vault-v2/blob/406546763343b9ffa84c2f63742ae55a490b7c42/src/VaultV2.sol#L686-L690 https://github.com/morpho-org/vault-v2/blob/406546763343b9ffa84c2f63742ae55a490b7c42/src/VaultV2.sol#L700-L704

Proof of Concept

Proof of Concept

In order to successfully show the issue, first a problem in the mocks should be addressed - the MockMTYStrategy::_deallocate() passes the amount to MockYieldToken::requestWithdraw(), who treats it as shares. So apply the following fix to the MockYieldToken:

The PoC:

In order to run the two functions use:

*Note --isolate is required for the second one, because VaultV2 uses transient storage.

Was this helpful?