56809 sc high vulnerable redemption survival ratio in sync allows theft of altokens

Submitted on Oct 20th 2025 at 21:11:33 UTC by @hashbug for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #56809

  • Report Type: Smart Contract

  • Report severity: High

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

  • Impacts:

    • Theft of unclaimed yield

Description

Intro

The _sync function is central to the core functions of the AlchemistV3 contract. It is used to update a CDP (collateralized debt position) to correspond to the global state.

CDPs hold information about their collateral and debt. We are mostly interested in account.earmarked and account.debt in this report.

Due to the way survivalRatio is computed in this function, it is possible to reset account.earmarked to zero while reducing account.debt without repayment or burning, thus freeing some of the tied up collateral. The rest of the debt can be burned using remaining synthetics since account.earmarked will be zero.

This erasure mechanism allows to steal some of the borrowed synthetic tokens. These tokens can then be sold or transmuted.

Vulnerability Details

The Alchemist uses global multiplicative accumulators (called weights) where individual multiplicands are <= 1 that allow CDPs to be updated lazily when required. With regular arithmetic, the values vanish, thus the Alchemix team maintains the weights in a logarithmic domain, making the accumulators additive.

The following is an update of a weight in regular arithmetic:

The following is an update of a weight in the log domain:

Note, that 0 <= increment <= total with a special case for increment == total. Thus, (total-increment)/total <= 1 and the logarithm is always negative, making the weight non-decreasing over time.

When a CDP needs to be caught up with the global state, the logarithm is reversed, using an exponential, only for a delta weight, which is in most cases carefully handled not to cause vanishing values in inappropriate places.

The weights are stored in the form of survival, e.g. _earmarkWeight stores "whatever survives earmarking in this operation out of what has survived earmarking up until now", hence the formula:

Most values in _sync need to be scaled inversely to this survival, thus the value is retrived as follows:

This equation works because

Note, that the exponentials are the inverse of the logarithmic transform, thus the division in the last equation is a normalization of the current weight by the last weight snapshot.

This correctly brings the CDP up-to-date.

Theoretically, evaluating the exponential with the weight delta and dividing the two exponentials is equivalent. In practice, exponential division is inferior, because it makes the code vulnerable to the value vanishing problem again.

In _sync, we have

Next, we will demonstrate that redemptionSurvivalNew suffers from the vanishing problem, leaving survivalRatio == 0 in certain cases.

There is a special value representing total == increment, i.e., (total-increment)/total == 0, which is

in PositionDecay.sol.

This value is not only reachable through total == increment in one of the updates, but also over time through accumulation.

For _redemptionWeight we have (in redeem):

I.e. in regular arithmetic

redeemedDebtTotal is not insignificant compared to cumulativeEarmarked because earmarks are only accumulated when a redemption process is running and all earmarked debt is meant to be redeemed. The difference can be especially small in times of low volumes. Thus, over time, _redemptionWeight is likely to reach the threshold value LOG2NEGFRAC_1.

Once this happens, the PositionDecay.SurvivalFromWeight(_redemptionWeight) call will always return 0. This leads to survivalRatio == 0 in each _sync.

Impact Details

Once the required conditions are met, calling _sync on a CDP twice in a single transaction/block will result in _accounts[tokenId].earmarked = 0 and a debt reduction for the CDP, where the first _sync call sets a non-zero account.earmark value and catches up the _earmarkWeight and the second _sync call uses this account.earmark value to reduce debt and then resets it to zero.

account.debt during the second _sync (simplified):

This demonstrates, that debt will be reduced by account.earmarked in the second _sync call.

account.earmarked is computed as follows:

From our account.debt analysis, we know, that both components are zero, thus account.earmarked = 0.

This mechanism is normally used to convert debt to earmarked debt, however, since we can set the earmarking to zero, we can effectively just reduce the account debt.

The remaining debt can be paid of with previously borrowed alTokens, since none of it is now earmarked and some alTokens will be left as a bonus - stolen, categorized as unmatured yield, even though it can be likely traded away instead of transmuting - since the debt was decreased (i.e., we don't need to use all of our borrowed tokens to repay the remaining debt).

Caveat

When a redemption is claimed in the transmuter, some of the collateral in all CDPs will be allocated for the redemption and the next _sync will reflect that using _collateralWeight. This can be completely avoided by front-running each redemption claim with a poke-burn (double _sync along with burning the debt) combination. This will run the exploit on a small scale and might require high gas fees.

A better strategy might be to wait for periods when a lot of redemptions get started to open up a debt position - the more redemptions there are running, the more earmarking will be happening and the more earmarking, the more debt reduction at the exploit. Redemptions are incentivized to be closed when fully matured, thus it can be predicted when redemption claims start coming in. Each redemption claim reduces collateral, but each block earmarks more of our debt that can be then reset.

The Alchemix team expects that there will be periods with a higher propensity to borrow or buy alTokens and redeem them, specifically, when they are cheap compared to the soft peg target. During these periods, it is likely, that a lot of redemptions will be started and their claims can be somewhat predicted.

Proof of Concept

Proof of Concept

We will demonstrate the behavior described in the report by:

  1. Preparing the initial conditions - force survivalRatio == 0

  2. Stealing tokens as 0xdad

The PoC has been coded with clarity in mind. The real market will be a complex system that is much more complicated to simulate, but the demonstrated concepts will still apply.

The new test is derived from AlchemistV3Test and the main code is in test_redeemAccountingDesync.

The new file PoC.t.sol:

Was this helpful?