56385 sc critical repayment fee can be paid from the pool even when the account has no collateral left

Submitted on Oct 15th 2025 at 11:04:18 UTC by @spongebob for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #56385

  • 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

_resolveRepaymentFee computes fee = repaidAmountInYield * repaymentFee / BPS and always returns the full value even when the account cannot cover it. The balance deduction is clamped (account.collateralBalance -= min(fee, balance)), but the unclamped fee is still returned.

https://github.com/alchemix-finance/v3-poc/blob/b2e2aba046c36ff5e1db6f40f399e93cd2bdaad0/src/AlchemistV3.sol#L900-L907

function _resolveRepaymentFee(uint256 accountId, uint256 repaidAmountInYield) internal returns (uint256 fee) {
        Account storage account = _accounts[accountId];
        // calculate repayment fee and deduct from account
        fee = repaidAmountInYield * repaymentFee / BPS;
        account.collateralBalance -= fee > account.collateralBalance ? account.collateralBalance : fee;
        emit RepaymentFee(accountId, repaidAmountInYield, msg.sender, fee);
        return fee;
    }

Both repayment-only paths in _liquidate transfer the returned fee to msg.sender without checking collateral availability, so any shortfall is paid out of the contract’s remaining pool, i.e. other users’ collateral.

https://github.com/alchemix-finance/v3-poc/blob/b2e2aba046c36ff5e1db6f40f399e93cd2bdaad0/src/AlchemistV3.sol#L809-L828

_doLiquidation already guards its base fee transfer with a collateral check highlighting the missing guard for repayment fees.

https://github.com/alchemix-finance/v3-poc/blob/b2e2aba046c36ff5e1db6f40f399e93cd2bdaad0/src/AlchemistV3.sol#L862-L865

Attack Path

  1. Find an under‑collateralized account with earmarked debt so _forceRepay will pull collateral.

  2. Call liquidate(accountId) as a liquidator. _forceRepay burns the account’s collateral to repay debt until its collateralBalance drops to (near) 0.

  3. _resolveRepaymentFee computes fee = repaid * repaymentFee / BPS, subtracts only the remaining collateral (0), yet returns the full fee.

  4. _liquidate transfers that returned amount to the liquidator without checking the account’s balance, paying the shortfall from the shared collateral pool.

  5. Repeat across targets (or the same account if debt reaccumulates) to siphon pooled assets.

Impact

A liquidator can extract more than the victim’s remaining collateral. The excess comes straight from the pooled collateral that backs all users. This falls under direct theft of user funds which is why i marked it critical

Recommendation

Return only what was actually deducted. In _resolveRepaymentFee, clamp the fee against account.collateralBalance, subtract that amount, and return the clamped value. Then reuse the returned amount for the liquidator transfer. Optionally emit an event when the owed fee exceeds available collateral so operators can monitor partial recovery.

Proof of Concept

Place test in AlchemixV3.t.sol and run forge test --mt testPOC_Repayment_Fee_Theft_From_Shared_Pool -vvvv

Logs

Was this helpful?