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 V3arrow-up-right

  • 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?