56519 sc critical unchecked repayment fee transfer in liquidate pays liquidators from other users collateral

Submitted on Oct 17th 2025 at 08:02:00 UTC by @yesofcourse for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #56519

  • Report Type: Smart Contract

  • Report severity: Critical

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

  • Impacts:

    • Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

Description

Brief/Intro

The liquidation flow’s repay-only path pays a repayment fee to the liquidator without clamping the actual token transfer to what the victim funded.

If the account’s residual collateral is insufficient to cover the computed fee, the contract still transfers the entire fee from its own MYT balance, effectively stealing other users’ collateral.

Attackers can loop across positions to extract value until pool balances are impaired, causing withdrawals to revert and risking insolvency.

Vulnerability Details

Where: AlchemistV3._liquidate (repay-only branches) together with _resolveRepaymentFee.

What happens:

  • _liquidate calls _forceRepay and then, in the repay-only outcomes (debt cleared or ratio restored), computes a feeInYield and always executes

  • _resolveRepaymentFee(accountId, repaidAmountInYield) computes:

    This function does not transfer tokens; it only adjusts internal accounting. It returns the full fee, even if only a portion was deducted from the victim’s collateralBalance.

Why it’s a bug: Back in _liquidate, the unconditional safeTransfer(..., feeInYield) pulls tokens from the contract’s MYT balance. When the victim couldn’t cover the whole fee, the shortfall is paid out of the pool, i.e., from other users’ collateral. In contrast, the “true liquidation” path does guard the transfer (only pays if the account can fund it). The repay-only path lacks this guard.

Impact Details

Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield.

The liquidator receives tokens that are not funded by the victim; the deficit is covered by the contract’s MYT holdings that back other users’ deposits. This is an immediate, extractable gain for the attacker and a direct loss for users.

  • Withdrawal failures / service disruption: As pool balances are drained by fee shortfalls, users attempting to withdraw may revert.

  • Protocol insolvency risk: Repeated exploitation can create a growing solvency hole (assets < liabilities), threatening system safety.

Estimated loss:

  • Per liquidation: roughly min(repaidAmountInYield * feeBps/BPS - residualCollateral, fee) taken from pooled funds.

  • Over many accounts or repeated cycles, the attacker can drain a material portion of the pool’s MYT buffer.

References

  • AlchemistV3._liquidate(...) — repay-only branches unconditionally transfer feeInYield: https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/AlchemistV3.sol#L826 https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/AlchemistV3.sol#L840

  • AlchemistV3._resolveRepaymentFee(...) — returns full fee while deducting at most the victim’s remaining collateralBalance.: https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/AlchemistV3.sol#L903

Proof of Concept

Proof of Concept

  • Configure 10% repayment fee; healthy user deposits; victim deposits and mints at 100% LTV; a matured redemption earmarks the victim’s entire debt; tweak min-collateralization to make the position liquidatable without needing seizure (repay-only).

  • Call liquidate(victimId) and observe:

    • Liquidator receives the full feeInYield.

    • The Alchemist’s MYT balance drops by (earmarked repayment + feeInYield).

    • Victim’s collateral covers only the repayment; the extra fee came from the pool.

    • A healthy user’s full withdrawal then reverts due to the shortfall; withdrawing a fee-adjusted amount succeeds.

The attached Foundry test testLiquidate_RepayOnly_Fee_Overpays_From_Pool() demonstrates this end-to-end: pool loss equals assets + feeInYield, healthy user’s full withdrawal fails, and a shortfall-adjusted withdrawal passes.

Paste this test in test/AlchemistV3.t.sol and run with forge test --match-test testLiquidate_RepayOnly_Fee_Overpays_From_Pool:

Was this helpful?