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?