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.
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.
The Vulnerable Code
Location: account.rs lines 144-170
// 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!
}
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.
Cascading Failure Analysis
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:
- Immediate: Liquidation transaction reverts. The underwater position cannot be closed.
- Short-term: Bad debt accumulates on the protocol's books. Other depositors' funds are effectively backing this uncollectable debt.
- 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.
- Long-term: Depositor confidence erodes. Bank-run dynamics emerge as depositors race to withdraw before the bad debt consumes the protocol's reserves.
Attack Scenario
- Setup: A user opens a leveraged position on the lending module, borrowing against collateral
- Price Crash: The collateral asset's price drops to $0 — this can happen via oracle failure, token depeg, or market crash
- Liquidation Attempt: A liquidator identifies the underwater position and calls the
Liquidateentry point - System Call Chain:
Liquidate→DoLiquidate→adjusted_ltv()→ encountersdebt.div(Decimal::zero()) - Panic & Revert: Rust panics, transaction reverts, liquidation fails
- Permanent Bad Debt: The position remains on the protocol's books indefinitely. No liquidator can ever close it.
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.
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)
}
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