← back to lessons

Pattern: Loss-index / loss-factor saturation traps + stale-snapshot slashing of fresh lenders

Banked: Cycle 192 (Morpho Midnight saturated-target mining) Sources (cross-corroborated across 3 audits): - Spearbit 5.2.5 ("When lossIndex is at its max withdrawal would not be possible but other endpoints can be called"), 5.4.6 ("Loss index inflation"). - Blackthorn M-1 ("In lossIndex == type(uint128).max state a fresh lender principal can be slashed to zero on entry"), L-10 ("Side-effect of optimistic bad debt socialization"). - TrustSec M-2 PoC mechanism, TRST-R-11 ("Add a loss-factor safety margin to take()"). Severity: Medium (Blackthorn M-1, acknowledged-wontfix) plus reinforcing Low/Info across the others. Status: NOVEL bug-class for fixed-maturity zero-coupon lending. Not previously banked.

Background: how a pooled loss index works in this class

Midnight tracks per-obligation bad debt via a monotonic lossIndex (a.k.a. lossFactor). When a liquidation realizes bad debt, the index moves; each lender position stores the index value at entry (lastLossIndex/lastLossFactor), and the difference between the obligation's current index and the position's stored index determines how much of that position's credit is slashed. This is the same shape as Aave's variableBorrowIndex, a vault's lossPerShare, or any "socialize loss across all current holders" accumulator. Three distinct failure modes emerge, all specific to the design choice of an index-based, optimistically-socialized loss.

Sub-pattern A: Index saturation soft-bricks the obligation but leaves entrypoints callable

(Spearbit 5.2.5) When bad debt == total units, lossIndex saturates to type(uint128).max and totalUnits -> 0. The formula then perceives ALL credit (old or new) as 0, so withdraw reverts for any nonzero amount. But take(), repay, supplyCollateral, withdrawCollateral, liquidate remain callable. The obligation is economically dead but not gated. Repaid loan tokens (repaidUnits) sent into this state become permanently locked - usable by no one. Fix: block take() when index is saturated; emit a terminal event.

Sub-pattern B: Fresh lender entry into a terminal/saturated obligation is slashed retroactively

(Blackthorn M-1, the high-signal finding) Because the saturating liquidation does NOT emit any ObligationTerminated event and the SAME obligation id remains usable, a late lender who signs a buy offer after saturation has their fresh credit zeroed by the next _updatePosition - the saturated obligation-level loss index is applied to a position that did not exist when the loss occurred. Worse: collateral price can RECOVER after saturation, so the market looks usable again, luring victims. Full lender principal lost per victim entry. Root: index applied at position-update time without checking that the position post-dates the loss event, and no terminal-state gate on entry.

Sub-pattern C: Index inflation is cheap and front-runnable in low-totalUnits / near-WAD-LLTV markets

(Spearbit 5.4.6) Each bad-debt event increments the index. When totalUnits is small and min viable loan/collateral are both low and lltv ~ WAD (no buffer), an attacker can repeatedly open dust positions, self-liquidate them for bad debt, and inflate the index toward saturation at low cost (report estimates ~843 bad-debt liquidations at 10% of units to max it out). Honest lenders' position creation can be SANDWICHED between two inflation phases - they enter after most inflation has occurred but before final saturation, so they eat the slash. Combine with sub-pattern B for a targeted griefing/theft primitive. TrustSec TRST-R-11 reinforces: take() only rejects the EXACT terminal value, not the range where accounting is already "extremely imprecise."

Why this is non-obvious

How to detect on future targets

  1. Find the global loss/bad-debt accumulator and its saturation ceiling (uint128.max, etc.). Ask: what happens to NEW positions opened AFTER saturation or after a large index move? Is there a TIME-of-entry vs TIME-of-loss check, or is the obligation/pool-level index blindly applied?
  2. Check whether the index-saturated / heavily-slashed state emits a terminal event and gates entry endpoints (take/deposit/supply). If entry is open while the index is saturated, fresh capital is at risk.
  3. Cost-model index inflation: cheapest dust position * number of bad-debt events to move the index materially. Lower min-position-size and near-WAD-LLTV (zero buffer) make this cheap. Check for self-liquidation (zero-amount or permissionless liquidation) enabling free index moves.
  4. Test the sandwich: inflate index, let victim enter, inflate again to saturation - does the victim's fresh credit get retroactively slashed?
  5. Distinguish "index can only conservatively under-credit existing holders" (safe) from "index is applied to holders who post-date the loss" (the bug).

Transfers to which protocol classes

Doctrine

For any "socialize loss via a monotonic index" design, the two load-bearing invariants are (1) a position must never absorb a loss that predates its own creation, and (2) the saturated/terminal state must be gated AND observable (terminal event). Both were missing or partial in Midnight. Cost-model the index-inflation attack explicitly under low-liquidity, near-WAD-LLTV configs - that is where it goes from theoretical to cheap.

Generated 2026-07-02 13:15:05 UTC | auto-sync /15min