57751 sc high there is a problem related to forced liquidation branch and this creates issue thatk cna drains protocol backing

Submitted on Oct 28th 2025 at 17:08:40 UTC by @XDZIBECX for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #57751

  • Report Type: Smart Contract

  • Report severity: High

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

  • Impacts:

    • Protocol insolvency

Description

Brief/Intro

there is a problem here in this contract because in the contract is let that liquidate() can erase an undercollateralized user's entire debt and this is happen using the protocol funds instead of actually seizing that user's collateral. so When the liquidate(accountId) runs, it first calls _forceRepay() which reduces account.debt in storage and sends real MYT to the transmuter using the protocol’s own balance, the TokenUtils.safeTransfer(myt, address(transmuter), creditToYield);, and this is happens before any collateral is actually liquidated from that user, Immediately after, the liquidate() checks if (account.debt == 0) and, if true, it stops there, and pays the caller a fee with protocol funds TokenUtils.safeTransfer(myt, msg.sender, feeInYield);), and is returns without calling _doLiquidation() which is the only place where collateral would actually be seized from the user. means the position’s debt is cleared and the liquidator is rewardeed, but the user’s remaining collateral was never fully taken to cover what they owed; instead, the protocol itself covered that bad debt. and if this still repeated, then the protocol can be drained and pushed toward insolvency. check the poc is show this issue

Vulnerability Details

this issue is came from the “repay-only” of the liquidate() and comes from a mismatch between the internal bookkeeping and real asset movemen when the liquidate(accountId) is runs, it first calls the _forceRepay(accountId, account.earmarked). Inside _forceRepay(), three key lines run in order are here ---> https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L758C8-L782C6 :

  _subDebt(accountId, credit); << here is clears user's debt in storage + reduces global totalDebt

        // Repay debt from earmarked amount of debt first
        uint256 earmarkToRemove = credit > account.earmarked ? account.earmarked : credit;
        account.earmarked -= earmarkToRemove;

        creditToYield = creditToYield > account.collateralBalance ? account.collateralBalance : creditToYield;
        account.collateralBalance -= creditToYield; << here is  just decrements an internal number on the account 

        uint256 protocolFeeTotal = creditToYield * protocolFee / BPS;

        emit ForceRepay(accountId, amount, creditToYield, protocolFeeTotal);

        if (account.collateralBalance > protocolFeeTotal) {
            account.collateralBalance -= protocolFeeTotal;
            // Transfer the protocol fee to the protocol fee receiver
            TokenUtils.safeTransfer(myt, protocolFeeReceiver, protocolFeeTotal); << 
        }

        if (creditToYield > 0) {
            // Transfer the repaid tokens from the account to the transmuter.
            TokenUtils.safeTransfer(myt, address(transmuter), creditToYield);  << here the problem is sends real MYT out of the protocol contract 
        }
        return creditToYield;
    }

There are two problems here so the first, _subDebt(accountId, credit) is reduces the account.debt and also reduces the global totalDebt, even though the contract has not actually seized and isolated enough collateral from that borrower to cover the repayment. a,d second the account.collateralBalance -= creditToYield only updates a number in the storage. collateralBalance is just an accounting variable, not an actual per-account escrow; so all MYT collateral is pooled at the contract level. and afterr that, the TokenUtils.safeTransfer(myt, address(transmuter), creditToYield) is moves the real MYT out of the protocol contract and into the transmuter, and this is happen using protocol-held funds to pay down that user’s debt. and there isno guarantee that this MYT are actually came from that specific undercollateralized account as opposed to coming from other users’ collateral held in the shared pool. the _forceRepay() also does not decrease the _mytSharesDeposited, so after tokens are sent out, the protocol’s accounting still claims to have the same total collateral, even though the balance actually dropped on-chain.

  • so after the _forceRepay(), the _liquidate() is checks ---> https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L823C9-L828C10 :

At this point the account.debt can already be 0 because the _forceRepay() is paid it off using shared the protocol MYT. When that if (account.debt == 0) branch is triggers, the liquidate() exits early and never calls _doLiquidation(). and is Skipping _doLiquidation() means the borrower’s remaining collateral is never actually seized to reimburse the protocol for what was just fronted on their behalf. Instead, the contyract is calls the _resolveRepaymentFee() to compute a fee, and then pays that fee to the liquidator and this happen using another TokenUtils.safeTransfer(myt, msg.sender, feeInYield), again paying out of the shared protocol balance, not out of the defaulted account’s isolated collateral. _resolveRepaymentFee() just does in-memory bookkeeping here --> https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L902C8-L904C104 :

so this line are only subtracts from the account.collateralBalance a number in storage, but does not ensure that the real MYT was actually reserved from that specific account before sending feeInYield to the liquidator. so as ressult an undercollateralized account’s bad debt is cleared and marked safe, and the liquidator gets paid, and the protocol itself is lose because the value was transferred out of the shared collateral pool without properly seizing that user’s remaining collateral. and repeating this is gone make drain the protocol-held MYT and drive the system toward to insolvency this need to be fixed

Impact Details

i use this as an impact the protocol insolvency because the protocol can no longer cover its outstanding liabilities from his issue

this vulnerability lets an undercollateralized borrower to have their bad debt wiped and this using the protocol funds instead of their own collateral. becuase In _forceRepay(), the contract transfers real MYT held by the protocol to the transmuter (TokenUtils.safeTransfer(myt, address(transmuter), creditToYield)), and reduces the borrower’s account.debt in storage.so after that, the liquidate() observes account.debt == 0, and exits early, and never calls the _doLiquidation(), and this is means the borrower’s remaining collateral is not seized. and the contract then pays the liquidator a fee from the protocol funds (TokenUtils.safeTransfer(myt, msg.sender, feeInYield)). so as reuslt is the borrower’s debt is set to 0, and the borrower keeps the collateral that should have been liquidated, and the protocol itself spends real assets to cover that debt and reward the liquidator. and this is create unbacked liabilities and a protocol insolvency, because repeating this on multiple unsafe positions will drains the pooled MYT while those positions are recorded as fully repaid and healthy see test

References

i use all in vulnerability details

Proof of Concept

Proof of Concept

here is a test show the issue : copy past this test in the AlchemistV3.t.sol and run it use the forge test --match-test testExploit_FullRepayBranch_AccountingMismatchAndLeak -vvvvv

the logs

Was this helpful?