For the complete documentation index, see llms.txt. This page is also available as Markdown.

57053 sc critical integer division precision loss in normalizedebttokenstounderlying leads to permanent collateral locking

Submitted on Oct 23rd 2025 at 03:42:01 UTC by @fullstop for Audit Comp | Alchemix V3

  • Report ID: #57053

  • Report Type: Smart Contract

  • Report severity: Critical

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

  • Impacts:

    • Permanent freezing of funds

Description

Brief/Intro

A critical precision loss vulnerability exists in the AlchemistV3 contract's debt-to-underlying conversion logic. When a user or attacker repays a very small amount of debt (an amount less than the underlyingConversionFactor), the integer division in normalizeDebtTokensToUnderlying rounds the amount of collateral to be "freed" down to zero. This causes a critical state desynchronization: the user's debt is correctly reduced, but their internal rawLocked collateral accounting is not. This desynchronized state acts as a "trap," which is later triggered by a global redeem event. When triggered, the contract's _sync logic incorrectly seizes a portion of the user's actual collateralBalance to cover global redemptions, leading to a permanent and irrecoverable loss of user funds.

Vulnerability Details

The vulnerability is exploited in a three-stage process: "The Setup," "The Trap," and "The Kill."

Stage 1: The Setup (Prerequisite)

The vulnerability is only present when the debtToken has significantly more decimals than the underlyingToken.

In initialize, the underlyingConversionFactor is set:

Using the PoC's parameters (18-decimal debtToken, 6-decimal underlyingToken), this factor becomes 10**(18 - 6) = 1e12.

Stage 2: The Trap (State Desynchronization)

The attacker's goal is to desynchronize a victim's debt and rawLocked states. This is achieved by repaying a "dust" amount of debt.

The attacker calls burn(amountToBurn, victim_tokenId), where amountToBurn < underlyingConversionFactor (e.g., 1e12 - 1).

burn calls _subDebt(victim_tokenId, amountToBurn).

Inside _subDebt, the contract calculates the amount of locked collateral to release (toFree):

convertDebtTokensToYield calls normalizeDebtTokensToUnderlying(amount). This is the root cause: The conversion function performs integer division:

Because amount (1e12 - 1) is less than underlyingConversionFactor (1e12), this function returns 0.

As a result, toFree is calculated as 0.

_subDebt then proceeds to:

Correctly reduce the victim's debt: account.debt -= amount;.

Incorrectly fail to reduce the locked collateral: _totalLocked -= toFree; (i.e., _totalLocked -= 0) and account.rawLocked = lockedCollateral - toFree; (i.e., rawLocked is not reduced).

At the end of this stage, the victim's position is in a desynchronized state: their debt is lower, but their rawLocked (their pro-rata stake in the system's risk) is still incorrectly high.

Stage 3: Loss (Realizing the Loss)

The desynchronized state is harmless until a global event changes the protocol's accounting.

The Trigger: A transmuter calls alchemist.redeem(earmarked). This action is a normal part of the protocol. Crucially, it updates the global _collateralWeight.

The Loss: The victim's position is now out of sync with the global state. The next time the victim's account is accessed (e.g., via getCDP, deposit, withdraw, etc.), the _sync logic is triggered.

_sync calculates the collateral to remove from the victim's balance to account for the global redeem:

This is the kill: The formula uses the incorrectly high rawLocked from Stage 2 against the newly updated _collateralWeight from Stage 3. This results in an erroneously large collateralToRemove value.

This value is then subtracted directly from the victim's actual assets:

The Funds Lost shown in the PoC log (5555555555555555555556) is this collateralToRemove. It is not returned to the user; it is seized by the protocol to cover a global redemption for which the user was not proportionally responsible.

Impact Details

This is a High/Critical vulnerability leading to permanent, irrecoverable loss of user funds.

The attack vector is highly malicious and accessible. Any user holding even a tiny amount of the debtToken can set this "trap" on any other user's position (tokenId) by simply calling burn(dust_amount, victim_tokenId). The attacker pays only gas and a negligible amount of debt, but the victim permanently loses a significant portion of their deposited collateral. The victim cannot prevent this attack, and the loss is realized silently the next time the protocol syncs their account.

References

Vulnerable Contract: AlchemistV3.sol

Root Cause (Precision Loss): normalizeDebtTokensToUnderlying

Trap Location (State Desync): _subDebt

Loss Realization (Fund Seizure): _sync

Proof of Concept

Proof of Concept

The following Foundry test case successfully reproduces the vulnerability, resulting in a permanent loss of 5.55e21 myt tokens from the victim's 1e23 collateralBalance.

Add testVulnerability_Repay_PrecisionLoss_LocksCollateral_FullTrigger in src/test/AlchemistV3_6_decimals.t.sol.

Was this helpful?