Font Size
Theme
SECURITY RESEARCH · MEDIUM SEVERITY

Division by Zero DoS in adjusted_ltv() Leads to Permanent Bad Debt

A missing zero-value guard in the liquidation math causes a runtime panic when collateral value drops to zero, permanently freezing underwater positions and creating unrecoverable protocol debt.

By Maro · January 2026 · ~10 min read · Security Audit
CosmWasm Rust DoS Liquidation DeFi
OVERVIEW

Executive Summary

In DeFi lending protocols, the liquidation mechanism is the last line of defense against insolvency. When a borrower's collateral value drops below their debt, liquidators step in to repay the debt and claim the collateral at a discount — keeping the protocol solvent. If liquidation fails, the debt becomes permanently stuck on the protocol's books: bad debt that erodes the health of every other depositor.

The protocol's lending module's adjusted_ltv() function calculates the Loan-to-Value ratio by dividing total debt by adjusted collateral value. While the function correctly handles the case where debt is zero (returning Decimal::zero()), it fails to guard against the case where collateral value is zero. In Rust's cosmwasm_std::Decimal, dividing by zero triggers an immediate panic! — not a graceful error, but a hard transaction revert.

The consequence is surgical: any attempt to liquidate an account whose collateral has lost all value will panic, leaving the debt permanently frozen on the protocol's balance sheet. This was validated as a Medium severity finding with multiple independent submissions, indicating it was widely recognized by the auditing community.

VULNERABILITY

The Vulnerable Code

Location: account.rs lines 144-170

Rust
// account.rs, lines 144-170
pub fn adjusted_ltv(&self) -> Decimal {
    let collateral = self.collaterals.iter()
        .map(|x| x.value_adjusted)
        .reduce(|a, b| a + b)
        .unwrap_or_default(); // Can be Decimal::zero()
    
    let debt = self.debts.iter()
        .map(|x| x.value)
        .reduce(|a, b| a + b)
        .unwrap_or_default();
    
    if debt.is_zero() {
        return Decimal::zero();
    }
    
    // BUG: No check for collateral == 0
    debt.div(collateral) // PANIC if collateral is zero!
}
The Vulnerability: The guard on line 155 correctly handles the zero-debt case. But the symmetric case — where debt exists but collateral is zero — is completely unguarded. Rust's Decimal::div() will panic on division by zero, reverting the entire transaction. There is no try-catch in CosmWasm; a panic is fatal and unrecoverable within the transaction context.
IMPACT

Cascading Failure Analysis

panic!
Transaction Outcome
Debt Lifetime (Permanent)
0
Liquidation Success Rate
Multiple
Independent Reports

The Liquidation Death Spiral:

When adjusted_ltv() panics, it doesn't just affect one transaction — it creates a permanent black hole in the protocol's accounting:

  1. Immediate: Liquidation transaction reverts. The underwater position cannot be closed.
  2. Short-term: Bad debt accumulates on the protocol's books. Other depositors' funds are effectively backing this uncollectable debt.
  3. Medium-term: If multiple positions enter this state (e.g., during a market crash where an asset goes to zero), the protocol's total bad debt grows unboundedly.
  4. Long-term: Depositor confidence erodes. Bank-run dynamics emerge as depositors race to withdraw before the bad debt consumes the protocol's reserves.
Black Swan Amplification: This vulnerability is particularly dangerous during black swan events — exactly when the liquidation mechanism is most critical. A token depegging to $0, an oracle failure returning zero, or a protocol exploit draining a pool's reserves would all trigger this bug at the worst possible moment.
EXPLOITATION

Attack Scenario

  1. Setup: A user opens a leveraged position on the lending module, borrowing against collateral
  2. Price Crash: The collateral asset's price drops to $0 — this can happen via oracle failure, token depeg, or market crash
  3. Liquidation Attempt: A liquidator identifies the underwater position and calls the Liquidate entry point
  4. System Call Chain: LiquidateDoLiquidateadjusted_ltv() → encounters debt.div(Decimal::zero())
  5. Panic & Revert: Rust panics, transaction reverts, liquidation fails
  6. Permanent Bad Debt: The position remains on the protocol's books indefinitely. No liquidator can ever close it.
Self-Sustaining DoS: Unlike typical DoS vulnerabilities where the attacker needs to continuously spend gas, this DoS is self-sustaining. Once a position enters the zero-collateral state, it requires zero ongoing effort from anyone — the protocol simply cannot process it.
MITIGATION

Recommended Fix

The fix is elegant and minimal: add a guard for zero collateral that returns Decimal::MAX, signaling that the position is maximally unsafe and should be liquidated immediately.

Rust
pub fn adjusted_ltv(&self) -> Decimal {
    let collateral = self.collaterals.iter()
        .map(|x| x.value_adjusted)
        .reduce(|a, b| a + b)
        .unwrap_or_default();
    
    let debt = self.debts.iter()
        .map(|x| x.value)
        .reduce(|a, b| a + b)
        .unwrap_or_default();
    
    if debt.is_zero() {
        return Decimal::zero();
    }
    
    // FIX: Handle zero collateral
    if collateral.is_zero() {
        return Decimal::MAX; // Position is maximally unsafe
    }
    
    debt.div(collateral)
}
The Solution: Returning Decimal::MAX when collateral is zero ensures that the LTV check always flags these positions as liquidatable. This allows the protocol to process the bad debt through its normal liquidation flow rather than letting it accumulate silently.

Additional recommendations:

  • Add comprehensive unit tests for edge cases: zero debt, zero collateral, both zero, dust amounts
  • Consider implementing a bad debt socialization mechanism for cases where liquidation cannot fully recover the debt
  • Add monitoring/alerting for positions approaching zero collateral value