Users lock up their debt tokens with the expectation of being repaid over time. However, there is a critical flaw that can bring this entire process to a halt.
The problem lies within the queryGraph function. It contains a condition that returns 0 if the starting and ending blocks are the same. While this may seem logical at first glance, it disrupts the system when _earmark() is called. If _earmark() attempts to retrieve data for a single block (which is a perfectly valid request), queryGraph returns 0. This failure in data retrieval halts the earmarking process, causing the repayment system to freeze.
As a result, users' debts do not get marked for redemption, leading to discrepancies in the system's accounting. This discrepancy makes the total debt appear larger than it should be relative to the synthetic assets. Consequently, users may find themselves repaying more MYT than necessary or may risk being liquidated unfairly.
Vulnerability Details
Let’s examine the problematic code:
The issue arises because the condition <= is too strict. A query for a single block, such as queryGraph(N + 1, N + 1), is a legitimate request; however, this condition incorrectly treats it as invalid and returns 0.
This flaw disrupts the earmarking process here:
When amount is 0, no new debt is earmarked but we update the lastEarmarkBlock to block.number. This leads to a freeze in the repayment process, causing the system's core accounting—such as totalDebt and cumulativeEarmarked—to become desynchronized. These imbalances create a ripple effect throughout the entire system, affecting redemptions and collateral calculations, and may even result in underflow errors during updates.
Impact Details
The immediate effect of this flaw is that the transmuter can freeze, halting redemptions of debts for the affect blocks and throwing the system's accounting into disarray. This leads to a cascade of problems:
Broken Accounting: The total debt and synthetic asset supply become misaligned, violating a core system invariant.
Unfair Costs for Users: Users may end up repaying more MYT than necessary to clear their debts.
Inaccurate Collateral: The calculations for locked collateral become incorrect, which can lead to transaction failures (like underflow errors) or inaccuracies in collateral weights.
References
Proof of Concept
Proof of Concept
Result
Correct state
bug gives a final balance of 199994954957234589041101 , normal state gives 199994955000000000000004
function queryGraph(uint256 startBlock, uint256 endBlock) external view returns (uint256) {
// The Bug: This rejects valid single-block queries
if (endBlock <= startBlock) return 0;
// ... rest of the function ...
}
function _earmark() internal {
// ...
// If this gets called in the same block, amount will be 0
uint256 amount = ITransmuter(transmuter).queryGraph(lastEarmarkBlock + 1, block.number);
.....
if (amount > 0 && liveUnearmarked != 0) {
// Previous earmark survival
uint256 previousSurvival = PositionDecay.SurvivalFromWeight(_earmarkWeight);
if (previousSurvival == 0) previousSurvival = ONE_Q128;
// Fraction of unearmarked debt being earmarked now in UQ128.128
uint256 earmarkedFraction = _divQ128(amount, liveUnearmarked);
_survivalAccumulator += _mulQ128(previousSurvival, earmarkedFraction);
_earmarkWeight += PositionDecay.WeightIncrement(amount, liveUnearmarked);
cumulativeEarmarked += amount;
}
@audit >>> lastEarmarkBlock = block.number;
}
/// @inheritdoc IAlchemistV3Actions
function redeem(uint256 amount) external onlyTransmuter {
_earmark();
@audit>> uint256 liveEarmarked = cumulativeEarmarked;
@audit>> if (amount > liveEarmarked) amount = liveEarmarked;
// observed transmuter pre-balance -> potential cover
uint256 transmuterBal = TokenUtils.safeBalanceOf(myt, address(transmuter));
uint256 deltaYield = transmuterBal > lastTransmuterTokenBalance ? transmuterBal - lastTransmuterTokenBalance : 0;
uint256 coverDebt = convertYieldTokensToDebt(deltaYield);
// cap cover so we never consume beyond remaining earmarked
@audit>> uint256 coverToApplyDebt = amount + coverDebt > liveEarmarked ? (liveEarmarked - amount) : coverDebt;
uint256 redeemedDebtTotal = amount + coverToApplyDebt;
// Apply redemption weights/decay to the full amount that left the earmarked bucket
if (liveEarmarked != 0 && redeemedDebtTotal != 0) {
@audit>> uint256 survival = ((liveEarmarked - redeemedDebtTotal) << 128) / liveEarmarked;
@audit>> _survivalAccumulator = _mulQ128(_survivalAccumulator, survival);
@audit>> _redemptionWeight += PositionDecay.WeightIncrement(redeemedDebtTotal, cumulativeEarmarked);
}
// earmarks are reduced by the full redeemed amount (net + cover)
cumulativeEarmarked -= redeemedDebtTotal;
// global borrower debt falls by the full redeemed amount
totalDebt -= redeemedDebtTotal;
@audit>> lastRedemptionBlock = block.number;