DEV Community

Daniel
Daniel

Posted on

How Zero Adjusted Collateral Could DoS Rujira Credit Queries and Liquidations

A loan-to-value calculation should identify when an account has become unsafe.

It should not make the protocol unable to inspect or liquidate that account.

Rujira Ghost Credit had an edge case where the opposite happened.

CreditAccount::adjusted_ltv() calculated an account's adjusted loan-to-value by dividing total debt by total adjusted collateral.

The function handled zero debt, but it did not handle this state:

debt > 0

adjusted collateral = 0
Enter fullscreen mode Exit fullscreen mode

In that case, the function divided by zero and panicked.

The panic was not confined to a display helper. adjusted_ltv() was used while materializing account query responses and inside the safety checks reached by account validation and liquidation flows.

Once an indebted account had zero adjusted collateral, the affected code paths could no longer evaluate it normally.

The result was a panic-based denial of service against:

QueryMsg::Account

QueryMsg::Accounts

QueryMsg::AllAccounts

ExecuteMsg::CheckAccount

Liquidate

DoLiquidate
Enter fullscreen mode Exit fullscreen mode

The proof of concept directly reproduced the failure through a single-account query and CheckAccount. The additional impact on list queries and liquidation flows follows from their shared calls to the same vulnerable LTV function.

A realistic trigger was setting the ratio of the account's only collateral denom to zero. The configuration accepted zero because validation rejected ratios above one but did not reject zero.

Code4rena classified the finding as Medium.

It was another of my three Medium findings in the Rujira contest. Across the contest, I had three High and three Medium findings and earned $252.39 in total.

What adjusted LTV was supposed to represent

The protocol calculated adjusted LTV as:

adjusted LTV =
    total debt value
    ÷
    total adjusted collateral value
Enter fullscreen mode Exit fullscreen mode

Collateral was not counted at its raw value.

Each collateral denom had a configured collateralization ratio, and the protocol used that ratio to derive the amount of value recognized for risk calculations.

A simplified healthy account might look like:

Debt value
100

Adjusted collateral value
200

Adjusted LTV
0.5
Enter fullscreen mode Exit fullscreen mode

An account with no debt should have an LTV of zero.

An account with debt but no adjusted collateral should be treated as maximally unsafe.

The vulnerable implementation instead attempted an undefined division.

The missing denominator check

The relevant logic in contracts/rujira-ghost-credit/src/account.rs was equivalent to:

pub fn adjusted_ltv(&self) -> Decimal {
    let collateral = self
        .collaterals
        .iter()
        .map(|x| x.value_adjusted)
        .fold(
            Decimal::zero(),
            |a, b| a + b,
        );

    let debt = self
        .debts
        .iter()
        .map(|x| x.value)
        .fold(
            Decimal::zero(),
            |a, b| a + b,
        );

    if debt.is_zero() {
        return Decimal::zero();
    }

    debt.div(collateral)
}
Enter fullscreen mode Exit fullscreen mode

The function explicitly handled:

debt = 0
Enter fullscreen mode Exit fullscreen mode

but did not handle:

collateral = 0
Enter fullscreen mode Exit fullscreen mode

When both totals were positive, the division behaved normally.

When debt was positive and adjusted collateral was zero, Decimal::div panicked.

The protocol therefore turned an economically meaningful state:

Outstanding debt with no recognized collateral
Enter fullscreen mode Exit fullscreen mode

into a runtime failure.

How adjusted collateral could become zero

Each collateral denom had an associated ratio.

A ratio of one meant the entire collateral value counted toward adjusted collateral.

A ratio of zero meant none of it counted.

The configuration validation rejected ratios greater than one, but it did not reject zero.

The collateral calculation also defaulted a missing ratio to zero:

ratios
    .get(&value.denom)
    .copied()
    .unwrap_or_default()
Enter fullscreen mode Exit fullscreen mode

Therefore, zero adjusted collateral could arise from either:

An explicitly configured ratio of 0

A missing ratio that defaults to 0
Enter fullscreen mode Exit fullscreen mode

The proof used the explicit configuration path.

The account initially held collateral whose ratio was one and borrowed successfully.

A privileged configuration update then set the ratio of the account's only collateral denom to zero.

The account still had debt.

Its raw collateral still existed.

But the sum of its adjusted collateral became zero.

The next LTV calculation panicked.

Why this was not only a broken account query

The vulnerable helper was reused across read and execution paths.

Single-account queries

QueryMsg::Account returned an AccountResponse.

Building that response included:

ltv: value.adjusted_ltv()
Enter fullscreen mode Exit fullscreen mode

Querying the affected account therefore reached the division by zero.

Instead of returning an account with a maximally unsafe LTV, the query panicked.

Account list queries

Both:

QueryMsg::Accounts

QueryMsg::AllAccounts
Enter fullscreen mode Exit fullscreen mode

mapped stored accounts into AccountResponse values.

If the affected account appeared in the result set, response materialization reached the same panic.

One account in this state could therefore cause the entire list query to fail.

That could disrupt:

Frontends

Risk dashboards

Indexers

Liquidation monitors

Automation and operational tooling
Enter fullscreen mode Exit fullscreen mode

The PoC did not execute these two list queries directly, but the report traced both of them to the same AccountResponse conversion.

Safety checks

check_safe() called adjusted_ltv() before comparing the result with the configured limit.

ExecuteMsg::CheckAccount called check_safe().

An account that should have been classified as unsafe instead caused the execution path to panic.

The PoC reproduced this path directly.

Liquidation checks

check_unsafe() also called adjusted_ltv().

The liquidation pipeline used these safety checks through:

ExecuteMsg::Liquidate

ExecuteMsg::DoLiquidate
Enter fullscreen mode Exit fullscreen mode

The report traced Liquidate into check_unsafe() and DoLiquidate into both check_safe() and check_unsafe().

The account was economically in the state most likely to require liquidation:

Debt exists

Adjusted collateral is zero
Enter fullscreen mode Exit fullscreen mode

Yet the function responsible for evaluating that condition could prevent the normal liquidation checks from completing.

The liveness inversion

The intended behavior was:

The account is classified as unsafe

Queries remain available

Liquidation checks remain executable
Enter fullscreen mode Exit fullscreen mode

The vulnerable behavior was:

Account evaluation panics

Queries fail

Safety checks fail

Liquidation checks fail
Enter fullscreen mode Exit fullscreen mode

This created a liveness inversion.

As the account became riskier, it became harder for the protocol to process.

Financial safety logic should fail closed economically while remaining live operationally.

Here, the calculation failed by panicking.

The proof-of-concept environment

The deterministic test used the project's real Ghost Credit and Ghost Vault components inside the multi-test environment.

It created:

A Ghost Vault funded with USDC

A Ghost Credit contract

A borrower credit account

BTC collateral

USDC debt
Enter fullscreen mode Exit fullscreen mode

The test configured prices as:

BTC
20,000

USDC
1
Enter fullscreen mode Exit fullscreen mode

The BTC and USDC collateral ratios were initially set to one.

The borrower sent one unit of BTC to the account.

The account borrowed one unit of USDC and transferred it to the borrower.

Before changing the collateral configuration, the account query succeeded:

let response: AccountResponse = app
    .wrap()
    .query_wasm_smart(
        credit_addr.clone(),
        &QueryMsg::Account(
            account.account.to_string(),
        ),
    )
    .unwrap();

assert!(!response.ltv.is_zero());
Enter fullscreen mode Exit fullscreen mode

That established a valid baseline:

The account existed

Collateral was recognized

Debt was outstanding

The LTV query worked
Enter fullscreen mode Exit fullscreen mode

Triggering the zero denominator

The test then changed the BTC collateralization ratio to zero:

app.wasm_sudo(
    credit_addr.clone(),
    &SudoMsg::SetCollateral {
        denom: BTC_DENOM.to_string(),
        collateralization_ratio:
            Decimal::from_str("0").unwrap(),
    },
)
.unwrap();
Enter fullscreen mode Exit fullscreen mode

The outstanding USDC debt remained.

The account's only collateral now contributed zero adjusted value.

The test wrapped the next account query in catch_unwind:

let query_panicked =
    catch_unwind(
        AssertUnwindSafe(|| {
            let _: AccountResponse = app
                .wrap()
                .query_wasm_smart(
                    credit_addr.clone(),
                    &QueryMsg::Account(
                        account.account.to_string(),
                    ),
                )
                .unwrap();
        }),
    )
    .is_err();

assert!(query_panicked);
Enter fullscreen mode Exit fullscreen mode

It then tested ExecuteMsg::CheckAccount:

let check_panicked =
    catch_unwind(
        AssertUnwindSafe(|| {
            app.execute_contract(
                borrower.clone(),
                credit_addr.clone(),
                &ExecuteMsg::CheckAccount {
                    addr: account
                        .account
                        .to_string(),
                },
                &[],
            )
            .unwrap();
        }),
    )
    .is_err();

assert!(check_panicked);
Enter fullscreen mode Exit fullscreen mode

Both paths panicked.

Test result

The proof ran with:

cargo test -p rujira-ghost-credit --test poc_adjusted_ltv_div_by_zero
Enter fullscreen mode Exit fullscreen mode

The result was:

running 1 test

test poc_adjusted_ltv_div_by_zero ... ok

test result: ok

1 passed
0 failed
0 ignored
0 measured
0 filtered out
finished in 0.02s
Enter fullscreen mode Exit fullscreen mode

The passing test confirmed that the vulnerable behavior was deterministic and reproducible.

What the PoC proved directly

The test established:

Account had collateral
YES

Account had debt
YES

Normal Account query worked before the change
YES

Collateral ratio was changed to 0
YES

Adjusted collateral became 0
YES

Debt remained greater than 0
YES

Account query panicked
YES

CheckAccount panicked
YES
Enter fullscreen mode Exit fullscreen mode

The PoC directly executed the single-account query and safety-check path.

The broader list-query and liquidation impact came from static tracing of their shared calls to adjusted_ltv().

Making that distinction matters: the report did not need to duplicate the same panic through every caller once the common root cause and call sites were established.

Why the finding was Medium

The demonstrated trigger required a privileged configuration action.

An ordinary user could not independently change the collateral ratio through this path.

That privilege requirement reduced exploitability and supported the submitted CVSS vector:

CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H
Enter fullscreen mode Exit fullscreen mode

Base score:

4.9 Medium
Enter fullscreen mode Exit fullscreen mode

Once the state existed, however, the availability impact affected important protocol operations:

Account reads

Account list reads

Safety evaluation

Liquidation eligibility checks

Risk monitoring
Enter fullscreen mode Exit fullscreen mode

The issue did not demonstrate direct theft.

It did not show that every account or every liquidation across the protocol would fail.

The impact applied to affected accounts and to list queries that included them.

Medium was therefore a reasonable final classification.

Why a privileged trigger did not make the panic acceptable

Changing a collateral ratio to zero may be an operator mistake.

It can also be an intentional risk-management action.

A protocol may need to stop recognizing a collateral asset after:

An oracle incident

A depeg

A bridge compromise

A token exploit

A market shutdown

A liquidity collapse
Enter fullscreen mode Exit fullscreen mode

Those are exactly the moments when account queries, safety checks, and liquidation systems must remain reliable.

There are two safe policies:

Reject a zero ratio during configuration

Or accept zero and handle zero adjusted collateral explicitly
Enter fullscreen mode Exit fullscreen mode

What is unsafe is accepting zero and later panicking when the resulting state is evaluated.

The correct semantic result

For:

debt = 0

adjusted collateral = 0
Enter fullscreen mode Exit fullscreen mode

returning zero LTV is reasonable.

For:

debt > 0

adjusted collateral = 0
Enter fullscreen mode Exit fullscreen mode

the economic result should behave like an extremely large LTV.

The contract did not need a literal infinity type.

It needed a representable sentinel that preserved the intended comparisons:

check_safe()
Returns Unsafe

check_unsafe()
Treats the account as liquidatable

Queries
Return successfully

Liquidation evaluation
Remains processable
Enter fullscreen mode Exit fullscreen mode

Required correction

The essential fix is to handle zero adjusted collateral before division:

use cosmwasm_std::{
    Decimal,
    Uint128,
};

pub fn adjusted_ltv(&self) -> Decimal {
    let collateral = self
        .collaterals
        .iter()
        .map(|x| x.value_adjusted)
        .fold(
            Decimal::zero(),
            |a, b| a + b,
        );

    let debt = self
        .debts
        .iter()
        .map(|x| x.value)
        .fold(
            Decimal::zero(),
            |a, b| a + b,
        );

    if debt.is_zero() {
        return Decimal::zero();
    }

    if collateral.is_zero() {
        return Decimal::from_atomics(
            Uint128::MAX,
            18,
        )
        .unwrap();
    }

    debt.div(collateral)
}
Enter fullscreen mode Exit fullscreen mode

Decimal::from_atomics(Uint128::MAX, 18) creates a very large representable Decimal without performing an overflowing division.

It is a sentinel, not literal infinity.

The corrected behavior is:

No division by zero

Queries remain live

The account remains unsafe

Liquidation checks can continue
Enter fullscreen mode Exit fullscreen mode

Optional cleanup of safety checks

The safety functions can also calculate LTV once:

pub fn check_safe(
    &self,
    limit: &Decimal,
) -> Result<(), ContractError> {
    let ltv =
        self.adjusted_ltv();

    ensure!(
        ltv.lt(limit),
        ContractError::Unsafe {
            ltv,
        },
    );

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

and:

pub fn check_unsafe(
    &self,
    limit: &Decimal,
) -> Result<(), ContractError> {
    let ltv =
        self.adjusted_ltv();

    ensure!(
        ltv.ge(limit),
        ContractError::Safe {},
    );

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

This cleanup is not required to fix the panic.

It avoids redundant work and makes the safety decision easier to review.

Should zero collateral ratios be rejected?

That depends on intended protocol semantics.

When zero is not a supported value, configuration validation should reject it:

for (denom, ratio)
    in self.collateral_ratios.iter()
{
    if ratio.is_zero()
        || ratio > &Decimal::one()
    {
        return Err(
            ContractError::InvalidConfig {
                key: format!(
                    "#{denom} collateral_ratio"
                ),
                value: ratio.to_string(),
            },
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

When zero is intentionally supported as a way to disable a collateral denom, it should remain valid.

In that design, the zero-collateral branch in adjusted_ltv() is mandatory because the state is expected.

Rejecting zero is defense in depth only when zero is not part of the intended configuration model.

Regression tests

A complete fix should test:

  1. Zero debt and zero adjusted collateral returns zero LTV

  2. Positive debt and positive adjusted collateral returns the expected ratio

  3. Positive debt and zero adjusted collateral does not panic

  4. Positive debt and zero adjusted collateral is classified as unsafe

  5. The same account passes the unsafe check used for liquidation eligibility

  6. QueryMsg::Account remains successful

  7. QueryMsg::Accounts remains successful when the account is included

  8. QueryMsg::AllAccounts remains successful when the account is included

  9. ExecuteMsg::CheckAccount returns a controlled Unsafe result

  10. Liquidate and DoLiquidate no longer panic at LTV evaluation

  11. Missing collateral ratios follow the intended policy

  12. Explicit zero ratios follow the intended policy

The central invariant is:

No valid account state should make LTV evaluation panic.

Broader audit lessons

Boundary values are protocol states

Zero adjusted collateral is not only a mathematical curiosity.

It can result from configuration changes, disabled collateral, missing ratio entries, or a complete loss of recognized collateral value.

Financial code must define its behavior there.

The riskiest accounts must remain processable

An account with debt and no adjusted collateral should be straightforward to classify as unsafe.

It should not become impossible to inspect.

Query conversion can contain security-critical logic

AccountResponse materialization called adjusted_ltv().

That made an arithmetic panic inside response construction affect every query that built the response.

Query and serialization paths deserve the same defensive review as execution paths.

One account can break a wider list response

When list queries map every account into a response without isolating failures, one edge-state record can abort the complete result.

Batch endpoints should be tested with adversarial account states.

Configuration changes require downstream-state testing

Validating only that a ratio is less than or equal to one was not enough.

Tests also needed to ask what happened to existing indebted accounts after that ratio became zero.

Conclusion

Rujira Ghost Credit calculated adjusted LTV by dividing debt by adjusted collateral.

It guarded zero debt but not zero adjusted collateral.

When an account retained debt while its adjusted collateral became zero, adjusted_ltv() divided by zero and panicked.

Because the same function was used by account response conversion and safety checks, the issue could disrupt:

Single-account queries

Account list queries

CheckAccount

Safety evaluation

Liquidation eligibility and execution paths
Enter fullscreen mode Exit fullscreen mode

The proof directly showed a working account becoming unqueryable and causing CheckAccount to panic after its only collateral ratio changed from one to zero.

Code4rena classified the finding as Medium.

The engineering invariant is simple:

An account with debt and zero adjusted collateral must be treated as maximally unsafe without ever making protocol evaluation panic.

Top comments (0)