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
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
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
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
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)
}
The function explicitly handled:
debt = 0
but did not handle:
collateral = 0
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
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()
Therefore, zero adjusted collateral could arise from either:
An explicitly configured ratio of 0
A missing ratio that defaults to 0
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()
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
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
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
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
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
The vulnerable behavior was:
Account evaluation panics
Queries fail
Safety checks fail
Liquidation checks fail
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
The test configured prices as:
BTC
20,000
USDC
1
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());
That established a valid baseline:
The account existed
Collateral was recognized
Debt was outstanding
The LTV query worked
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();
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);
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);
Both paths panicked.
Test result
The proof ran with:
cargo test -p rujira-ghost-credit --test poc_adjusted_ltv_div_by_zero
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
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
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
Base score:
4.9 Medium
Once the state existed, however, the availability impact affected important protocol operations:
Account reads
Account list reads
Safety evaluation
Liquidation eligibility checks
Risk monitoring
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
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
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
returning zero LTV is reasonable.
For:
debt > 0
adjusted collateral = 0
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
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)
}
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
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(())
}
and:
pub fn check_unsafe(
&self,
limit: &Decimal,
) -> Result<(), ContractError> {
let ltv =
self.adjusted_ltv();
ensure!(
ltv.ge(limit),
ContractError::Safe {},
);
Ok(())
}
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(),
},
);
}
}
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:
Zero debt and zero adjusted collateral returns zero LTV
Positive debt and positive adjusted collateral returns the expected ratio
Positive debt and zero adjusted collateral does not panic
Positive debt and zero adjusted collateral is classified as unsafe
The same account passes the unsafe check used for liquidation eligibility
QueryMsg::Accountremains successfulQueryMsg::Accountsremains successful when the account is includedQueryMsg::AllAccountsremains successful when the account is includedExecuteMsg::CheckAccountreturns a controlledUnsaferesultLiquidateandDoLiquidateno longer panic at LTV evaluationMissing collateral ratios follow the intended policy
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
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)