Borrower preferences should help a liquidator unwind a position.
They should never give the borrower a veto over liquidation.
Rujira Ghost Credit allowed an account owner to store liquidation instructions through AccountMsg::SetPreferenceMsgs. One accepted instruction was:
LiquidateMsg::Repay(denom)
The contract did not verify that denom existed in the configured BORROW map before saving the preference.
That omission became dangerous because stored borrower preferences executed before the messages supplied by the liquidator.
A borrower could save this while the account was still healthy:
LiquidateMsg::Repay(
"BAD".to_string(),
)
After the account became unsafe, a liquidation attempt reached:
BORROW.load(
deps.storage,
denom.clone(),
)?
The lookup failed because "BAD" was not a configured borrow denom.
Unlike an external LiquidateMsg::Execute step, the repay step was handled directly rather than through reply-based submessage handling. Its error therefore bubbled up and reverted the complete liquidation transaction.
The borrower had effectively installed a future liquidation revert while the position was healthy.
Code4rena classified the finding as High.
It was one of my three High findings in the Rujira contest. Across the contest, I had three High and three Medium findings and earned $252.39 in total.
The liquidation invariant
A protocol may allow borrowers to express preferences about how their position should be unwound.
That flexibility is safe only while one rule remains true:
Borrower-controlled preferences may guide liquidation, but they must never remove every valid liquidation path for an unsafe account.
The vulnerable flow broke that rule through the combination of:
An unvalidated Repay preference
Borrower preferences executing first
A fatal storage lookup
No liquidator-controlled fallback before the revert
None of those details alone fully explains the issue.
Together, they allowed the borrower to turn an optional preference into a hard liquidation blocker.
Why borrower preferences executed first
ExecuteMsg::Liquidate received liquidation steps from the caller.
The contract reversed those messages before placing them into a queue.
It then loaded the borrower's stored preferences, reversed them, and appended them to the same queue.
A simplified example is:
Liquidator messages
[s1, s2]
After reverse
[s2, s1]
Borrower preferences
[p1, p2]
After reverse
[p2, p1]
Combined queue
[s2, s1, p2, p1]
The liquidation loop consumed the queue with:
queue.pop()
Because pop() removes the last element, the actual execution order became:
p1
p2
s1
s2
The first borrower preference ran before the first liquidator message.
That ordering gave an untrusted account owner priority over the party trying to repair the unsafe position.
A liquidator could submit a completely valid unwind sequence and still never reach it if the first stored preference reverted.
The preference was structurally valid but operationally invalid
The borrower stored preferences through:
AccountMsg::SetPreferenceMsgs(
preferences,
)
The vector was persisted without checking whether every LiquidateMsg::Repay(denom) referenced a configured borrow market.
This meant the following value passed enum decoding and account-message validation:
LiquidateMsg::Repay(
"BAD".to_string(),
)
But it could never succeed during liquidation because "BAD" had no corresponding entry in BORROW.
This is an important smart-contract distinction:
The message was type-valid
The message was not state-valid
State-dependent references must be validated against the state in which they will be used.
Here, the contract accepted a future instruction that was guaranteed to fail.
Why the invalid repay step reverted everything
When liquidation processed a repay step, it loaded the vault associated with the requested denom:
let vault =
BORROW.load(
deps.storage,
denom.clone(),
)?;
For a configured denom, the lookup returned the expected vault.
For "BAD", load() returned an error.
The ? operator propagated that error out of the liquidation handler.
There was no local fallback that skipped the invalid borrower preference and continued with the liquidator's messages.
The complete transaction reverted.
Why LiquidateMsg::Execute was different
The preference system did not treat every message type the same way.
LiquidateMsg::Execute was dispatched as a submessage with reply handling.
That structure allowed the contract to observe the result of the external execution and apply preference-specific failure behavior in the reply path.
LiquidateMsg::Repay did not cross that boundary.
Its storage lookup and repayment preparation occurred directly in execute_liquidate().
The difference was:
Execute
Dispatched through reply-handled submessage logic
Repay
Executed directly with fatal error propagation
The borrower did not need a malicious external contract or a complicated callback.
A single nonexistent denom was enough to trigger the fatal path.
Why the malicious preference became sticky
The borrower needed to save the preference while the account was still safe.
That was possible because account updates ended with a safety check.
ExecuteMsg::Account applied the requested account messages and then scheduled ExecuteMsg::CheckAccount.
The final check required:
adjusted_ltv
<
adjustment_threshold
While the account was healthy, saving the invalid preference passed.
Later, a collateral price decline could make the account unsafe.
At that point, clearing only the preference through the normal account path also failed.
The clearing transaction still ended with CheckAccount. Because the position remained unsafe, the final check reverted the transaction, including the attempted preference update.
The borrower would have to combine removal with enough repayment or collateral restoration to make the account safe again.
That creates the dangerous dependency:
Once liquidation is needed, recovery depends on cooperation from the borrower who installed the blocker.
If the borrower is insolvent, unwilling to cooperate, or unable to restore safety, the preference remains effectively sticky.
The complete attack path
The sequence is straightforward:
1. Borrower creates a credit account
2. Borrower supplies collateral
3. Borrower takes debt
4. Account remains below the adjustment threshold
5. Borrower stores:
LiquidateMsg::Repay("BAD")
6. Collateral value falls
7. Account becomes liquidatable
8. A third party calls ExecuteMsg::Liquidate
9. The first borrower preference executes first
10. BORROW.load("BAD") fails
11. The error propagates
12. Liquidator-supplied steps are not reached
13. The liquidation transaction reverts
14. Clearing only the preference also reverts while the account remains unsafe
The borrower prepared the failure before the protocol needed to liquidate the position.
The proof-of-concept environment
The deterministic PoC used the project's Ghost Credit and Ghost Vault mock environment.
It created:
Owner
Borrower
Liquidator
The environment configured BTC as collateral and USDC as debt.
Initial prices were:
BTC
20,000
USDC
1
The vault received:
25,000 USDC
The borrower supplied:
1 BTC
and borrowed:
15,000 USDC
The borrowed USDC was then sent out of the credit account.
At the initial BTC price, the position remained below the adjustment threshold:
let ltv_safe =
credit
.query_account(
&app,
&account.account,
)
.ltv;
assert!(
ltv_safe
< Decimal::from_ratio(
95u128,
100u128,
)
);
The account was healthy enough to accept normal account updates.
Storing the invalid repay preference
The borrower then called:
credit
.account(
&mut app,
&account,
vec![
AccountMsg::SetPreferenceMsgs(
vec![
LiquidateMsg::Repay(
"BAD".to_string(),
),
],
),
],
)
.unwrap();
The call succeeded.
This directly proved that the write path accepted a repay preference for a denom that did not exist in the borrow configuration.
Making the account unsafe
The PoC reduced the BTC price from:
20,000
to:
10,000
The account's LTV crossed the liquidation threshold:
let ltv_liquidatable =
credit
.query_account(
&app,
&account.account,
)
.ltv;
assert!(
ltv_liquidatable
>= Decimal::one()
);
The position now required liquidation, and the invalid preference was already stored.
The liquidation reverted before the solver step
The liquidator supplied a harmless LiquidateMsg::Execute targeting a dummy contract.
That message represented a valid caller-provided liquidation step.
The liquidation request was:
let result = app.execute_contract(
liquidator,
credit.addr().clone(),
&ExecuteMsg::Liquidate {
addr:
account.account.to_string(),
msgs: vec![
LiquidateMsg::Execute {
contract_addr:
dummy.to_string(),
msg:
to_json_binary(
&Empty {},
)
.unwrap(),
funds: vec![],
},
],
},
&[],
);
The transaction failed.
The resulting error was consistent with the missing borrow-market lookup and contained terms such as:
borrow
not found
NotFound
The queue analysis establishes why the invalid preference was evaluated before the caller-provided step.
The PoC confirms that the resulting BORROW.load() failure reverted liquidation.
Clearing the blocker also failed
After the failed liquidation, the test attempted to replace the preference vector with an empty one:
let clear_result =
credit.account(
&mut app,
&account,
vec![
AccountMsg::SetPreferenceMsgs(
vec![],
),
],
);
assert!(
clear_result.is_err()
);
The account was already unsafe.
The update therefore failed at the final safety check, and the transaction rollback preserved the malicious preference.
The test established both sides of the trap:
Third-party liquidation
FAILED
Clearing only the malicious preference
FAILED
The position could not be recovered through either action alone.
Impact
Liquidation is not merely an optional maintenance operation.
It protects shared capital when a borrower's collateral can no longer support the debt.
Blocking liquidation for an unsafe account can create:
Undercollateralized debt
Bad-debt exposure
Loss risk for lenders or depositors
Blocked emergency unwind
Dependence on borrower cooperation
Broken assumptions in automated liquidators
The issue did not halt liquidation for every account.
The malicious preference affected the attacker's own credit account.
That scope does not make the issue minor.
A borrower being able to veto liquidation of their own unsafe debt position directly undermines the mechanism intended to protect the protocol from that borrower.
Severity
The submitted CVSS vector was:
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H
Base score:
8.1 High
The metrics reflect:
AV:N
The actions use normal on-chain entry points
AC:L
No race condition or unusual timing is required
PR:L
The attacker must control the borrower account
UI:N
No separate victim action is required
S:U
The impact remains inside the protocol's security scope
C:N
There is no confidentiality impact
I:H
The borrower can prevent the protocol from correcting an unsafe debt position
A:H
Liquidation becomes unavailable for the affected account
The direct failure is an account-specific liquidation DoS.
The economic consequence can be larger if collateral continues to fall while liquidation remains blocked.
Code4rena classified the finding as High.
Primary fix: validate preferences when stored
The first correction is to reject invalid repay preferences during SetPreferenceMsgs.
For every:
LiquidateMsg::Repay(
denom,
)
the contract should require that the borrow configuration contains the denom:
if !BORROW.has(
deps.storage,
denom.clone(),
) {
return Err(
ContractError::InvalidPreference {},
);
}
The exact error type should follow the project's conventions.
The important behavior is to reject the invalid state while the account is healthy, rather than discovering it during liquidation.
Revalidate during liquidation
Write-time validation is necessary but not sufficient.
A denom that exists when the preference is stored may later be removed, disabled, or migrated.
The liquidation path should therefore revalidate state-dependent references.
An invalid borrower preference should be skipped or treated as a controlled preference failure.
It should not revert mandatory liquidation logic.
Preserve a liquidator-controlled fallback
Borrower preferences should remain advisory.
The liquidation API should provide a path that does not depend on them.
Possible designs include:
A liquidator option to ignore preferences
A protocol-defined fallback liquidation mode
Mandatory solver steps before optional preferences
Automatic continuation after a preference fails
The exact design can vary.
The invariant is:
An unsafe account must always retain at least one protocol-controlled liquidation path
Keep preference failures non-fatal
LiquidateMsg::Repay could be moved behind controlled submessage or reply handling so a failed borrower preference does not revert the whole transaction.
That would make its failure model consistent with optional preference execution.
This protection should be paired with limits.
Otherwise, a borrower may store many expensive or failing preferences and replace a hard revert with gas griefing.
Useful controls include:
Maximum number of preference steps
A safe allowlist of preference types
Bounded message size and complexity
Liquidator bypass support
Revisit trust ordering
Changing queue order so liquidator messages execute first would prevent the first borrower preference from blocking every caller-provided step.
But ordering alone is not a complete fix.
A valid design should explicitly separate:
Mandatory protocol operations
Liquidator-controlled solver operations
Optional borrower preferences
Fallback behavior after optional failure
The trust hierarchy should be visible in the architecture, not emerge accidentally from reverse(), append(), and pop().
Regression tests
A complete patch should verify:
SetPreferenceMsgsrejects an unknown repay denomA known repay denom can be stored
A denom that later becomes unavailable cannot brick liquidation
Failure of a preference does not prevent mandatory liquidation steps
Liquidator-provided steps remain reachable
An unsafe borrower cannot veto liquidation
Preference count and complexity are bounded
Queue ordering is explicit and tested
Clearing preferences while safe succeeds
Recovery from an invalid stored preference does not require borrower cooperation
LiquidateMsg::Executefailure remains isolatedLiquidateMsg::Repayfailure becomes isolated
The central invariant is:
No borrower-controlled preference may remove every valid liquidation path for an unsafe account.
Broader audit lessons
Stored instructions are future attack surface
A message accepted while an account is healthy may execute later under liquidation conditions.
Validation must account for that future context.
Queue semantics define trust order
reverse(), append(), and pop() looked like ordinary collection operations.
Together, they gave borrower instructions execution priority over liquidator instructions.
Data-structure behavior can become a security boundary.
Type validity is not state validity
LiquidateMsg::Repay("BAD") was a valid enum value.
It was not a valid repayment instruction for the current protocol configuration.
State references require semantic validation.
Optional instructions must fail without blocking recovery
A borrower preference is optional from the protocol's perspective.
Its failure must not disable mandatory risk management.
Recovery cannot depend on the unsafe borrower
Once the account became unsafe, clearing only the blocker failed because the account update still had to pass the final safety check.
A liquidation mechanism should not need cooperation from the party whose position it is trying to liquidate.
Conclusion
Rujira Ghost Credit allowed borrowers to store LiquidateMsg::Repay preferences without validating the requested denom.
Those preferences executed before liquidator-provided steps because the queue was reversed, appended, and consumed with pop().
A borrower could therefore save:
LiquidateMsg::Repay(
"BAD".to_string(),
)
while the account was healthy.
After the position became unsafe, liquidation reached the invalid preference first.
BORROW.load() failed, the error propagated, and the entire liquidation transaction reverted.
Clearing only the preference also failed because the unsafe account could no longer pass the final CheckAccount.
The borrower had preinstalled an account-specific liquidation veto.
Code4rena classified the finding as High.

The engineering rule is simple:
Borrower preferences may guide liquidation, but protocol-controlled liquidation must always remain available.

Top comments (0)