A borrower should not be able to choose an unlimited amount of work that every future liquidator must perform.
Rujira Ghost Credit allowed account owners to store liquidation preferences through:
AccountMsg::SetPreferenceMsgs(
messages,
)
The protocol did not impose a protocol-defined upper bound on their worst-case aggregate execution cost.
The number of preference messages
Their total serialized size
Their aggregate execution cost
Those preferences were later inserted into the liquidation queue.
Because of the way the contract combined reverse(), append(), and pop(), borrower preferences executed before the steps supplied by the liquidator or solver.
Preference failures were also treated as nonfatal in reply().
That combination allowed a borrower to prepare a long sequence of expensive or intentionally reverting calls while the account was healthy.
Once the position became unsafe, every liquidation attempt had to process that borrower-controlled prefix before reaching the solver’s swap or Repay step.
The preferences could consume the transaction’s gas budget first.
Even when a sufficiently large transaction remained technically possible, the required gas cost could exceed the fixed liquidation reward and make the position economically unattractive to liquidators.
The result was an account-specific liquidation time bomb.
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 liveness invariant
Liquidators are external actors.
They spend gas and capital to restore unsafe accounts because the protocol gives them an economic incentive to do so.
A safe liquidation pipeline should preserve this rule:
Borrower-controlled preferences must have a strict protocol-defined cost bound and must not execute before mandatory solvency actions.
Rujira violated that rule through three independent design choices that became dangerous when combined:
Preference storage was protocol-unbounded
Preferences executed before solver messages
Preference failures were ignored after consuming resources
A bounded preference list would limit the damage.
A liquidator bypass would make the list optional.
Solver-first execution would let debt reduction happen before cosmetic borrower instructions.
The vulnerable implementation had none of those protections.
Protocol-unbounded does not mean literally infinite
No on-chain vector is physically infinite.
Transaction size, storage cost, block limits, and chain-level gas rules impose practical constraints.
The issue was that Ghost Credit itself did not define a safe upper bound.
The borrower-controlled cost was limited only by ambient infrastructure constraints rather than by a liquidation safety invariant.
That distinction matters.
A protocol should not rely on the chain rejecting an oversized state update after an unknown amount of work.
It should prove that the maximum accepted preference set remains safely liquidatable under worst-case execution.
The preference vector had no hard cap
The account logic assigned the submitted vector directly:
self
.liquidation_preferences
.messages
=
messages;
There was no check equivalent to:
messages.len()
<=
MAX_PREFERENCE_MSGS
The code also did not cap the total serialized bytes or estimate the execution cost of the stored steps.
A borrower could therefore increase liquidation work through:
Many preference messages
Large encoded payloads
Expensive target contracts
Repeated calls that intentionally revert
The problem was not merely storage growth.
Every accepted item became future liquidation work.
Why borrower preferences executed first
ExecuteMsg::Liquidate received a list of messages from the liquidator.
The contract reversed that list.
It then loaded the borrower’s stored preferences, reversed them, and appended them to the same queue.
A simplified queue demonstrates the result:
Liquidator messages
[s1, s2]
After reverse
[s2, s1]
Borrower preferences
[p1, p2]
After reverse
[p2, p1]
After append
[s2, s1, p2, p1]
ExecuteMsg::DoLiquidate selected the next step through:
queue.pop()
Because pop() removes the last element, execution became:
p1
p2
s1
s2
The borrower’s first preference executed before the liquidator’s first repair action.
The liquidator could not reach s1 until the complete preference prefix had been attempted.
Queue mechanics had silently created a trust priority:
Borrower-controlled optional work
before
Liquidator-controlled solvency work
Why ignored failures still mattered
Preference execution used reply handling.
When a preference failed under REPLY_ID_PREFERENCE, the contract returned a successful response, emitted the relevant event, and continued processing.
This prevented one bad preference from immediately reverting liquidation.
But ignoring the error did not recover the gas already consumed.
The chain still had to:
Load and decode the stored preference data
Dispatch the submessage
Execute the target until success or failure
Process the reply
Schedule the next DoLiquidate step
A borrower could therefore choose calls that failed repeatedly.
Each failure was soft from the contract’s control-flow perspective but expensive from the transaction’s resource perspective.
Soft failure is not free failure.
A different liquidation blocker
A single invalid LiquidateMsg::Repay can create a fatal revert because that path propagates its error directly.
This finding used the opposite mechanism.
The borrower could use preference steps whose failures were deliberately swallowed.
Instead of stopping at the first error, liquidation continued paying for one failed preference after another until the gas budget was exhausted.
The two failure modes are distinct:
Fatal preference
One error aborts the transaction
Unbounded soft-failing preferences
Many errors consume resources before solver execution
Both undermine liquidation, but through different control-flow behavior.
The borrower-controlled gas prefix
Let:
n =
number of borrower preferences
gᵢ =
gas consumed by preference i
G_solver =
gas required by the liquidator’s useful steps
The transaction requires at least:
G_total
=
Σ gᵢ
+
G_solver
before accounting for general contract and chain overhead.
The borrower controlled the first term.
The liquidator controlled only the work after it.
If:
Σ gᵢ
>=
available transaction gas
the solver’s repayment step became unreachable.
The account stayed unsafe.
Technical denial of service
The most direct outcome was gas exhaustion.
When the mandatory preference prefix exceeded the gas available to a transaction or block, no caller could complete liquidation through that queue in one transaction.
The transaction reverted before reaching the step that would reduce debt.
This is a technical liveness failure.
The liquidation path exists in code but cannot finish within the execution envelope.
Economic denial of service
The attack did not need to reach the absolute gas ceiling.
A liquidation may still be technically executable while being economically irrational.
A liquidator compares:
Expected liquidation compensation
Gas cost
Capital cost
Failure risk
Opportunity cost
If borrower-controlled preferences consume most or all of the reward, rational liquidators avoid the account.
The protocol may have a theoretically valid transaction that nobody is willing to submit.
For a lending system, that is still a liquidation failure.
An unsafe account that cannot be liquidated profitably can continue deteriorating into bad debt.
The time-bomb behavior
The borrower could save the preferences before the account was liquidatable.
At that point, the position was healthy and no solver was involved.
The messages remained dormant in account state.
Later, a price movement could push the account above the liquidation threshold.
Every third-party liquidation attempt would then inherit the stored workload automatically.
The borrower did not need to monitor the mempool or race liquidators.
The attack sequence was:
1. Open a healthy credit position
2. Store a large or expensive preference list
3. Wait for the position to become unsafe
4. Force every liquidator to execute the stored prefix
5. Exhaust gas or destroy liquidation profitability
6. Prevent the solver from reaching Repay
No governance or administrator role was required.
The attacker needed only the normal borrower authority used to configure their own account.
What the proof of concept modeled
The submitted PoC was a deterministic standalone Rust reproduction.
It modeled the exact properties relevant to the DoS:
reverse() on liquidator messages
reverse() on borrower preferences
append() of preferences after liquidator messages
LIFO execution through pop()
Nonfatal failures for preference steps
A finite gas budget
A Repay step that restores safety
It did not execute the complete Rujira contract stack.
It also did not measure real chain gas.
The gas values were symbolic units used to prove the ordering and budget property.
That scope is enough to demonstrate the logical vulnerability:
Under a finite execution budget, an unbounded borrower-controlled prefix can prevent the solver step from being reached.
Confirming that preferences execute first
The model first created:
Two failing borrower preferences
One liquidator Repay message
It built the queue using the same reverse, append, and pop behavior.
Then it asserted:
if !first_executed_is_preference(
&demo_queue,
) {
return Err(
"expected first executed step \
to be a borrower preference"
.into(),
);
}
That confirmed the priority inversion.
The borrower’s optional message was the first step attempted.
The gas-budget scenario
The main scenario used:
Preference count
10,000
Cost per preference
1 symbolic gas unit
Repay cost
10 symbolic gas units
The complete modeled liquidation required:
10,000
+
10
=
10,010 units
The available budget was deliberately set to:
10,009 units
The preference prefix consumed:
10,000 units
Only nine remained.
The solver’s Repay step required ten.
The model returned:
OutOfGas
before the account reached a safe state.
Why reverting preferences continued
Each stored preference was modeled as:
LiquidateMsg::Execute {
gas_cost: 1,
always_fails: true,
}
A failed borrower preference consumed its assigned cost and continued.
A failed non-preference liquidator step remained fatal.
This reproduced the control-flow asymmetry described in the contract:
Preference error
Ignored and continued
Liquidator error
Aborted
The borrower could therefore consume resources repeatedly without having to make any preference succeed.
Expected output
The standalone program exited successfully only when the liquidation result was OutOfGas.
Its expected output was:
DoS reproduced: liquidation ran out of gas before reaching the solver repay step
The relevant conclusion was not that exactly 10,000 preferences were proven feasible on a live deployment.
The conclusion was that no contract-level bound prevented the borrower-controlled prefix from approaching or exceeding the available budget.
What the PoC proves
The PoC proves:
Borrower preferences execute before liquidator steps under the modeled queue semantics
Failed preferences can consume resources while processing continues
A sufficiently large preference prefix can exhaust a finite budget
Repay can remain unreachable
It does not establish:
The exact production gas cost of each preference
The largest vector that a deployed chain accepts
The precise profitability threshold for live liquidators
The real-world number of messages required on a particular network
Those values depend on the deployment environment.
The security flaw is the absence of a protocol-enforced worst-case bound.
Impact
When liquidation cannot reach repayment, an unsafe account can remain open while its collateral continues to decline.
Consequences can include:
Missed liquidation windows
Undercollateralized debt
Bad debt in the lending vault
Loss exposure for depositors and lenders
Noncompetitive liquidation markets
Blocked emergency de-risking
The DoS was account-specific.
A borrower sabotaged liquidation of their own position.
But each borrower had access to the same preference feature, so repeated use could create broader solvency pressure.
Liquidation exists specifically to protect shared lender capital from borrower insolvency.
Giving the borrower control over unbounded mandatory liquidation work undermines that protection.
Why the finding was High
Code4rena classified the finding as High.
The attacker required only normal borrower privileges.
The state could be prepared while the position was healthy and activate later without a race.
Every liquidator targeting the affected account inherited the borrower-selected workload.
The vulnerable path affected:
Liquidation availability
Bad-debt prevention
Lender and depositor protection
The economic viability of the solver market
The final result could be either a hard gas-limit failure or an economic DoS where execution cost exceeded expected compensation.
Both outcomes could leave an unsafe account unprocessed.
Primary fix: cap the number of preferences
The contract should enforce a small maximum:
const MAX_PREFERENCE_MSGS:
usize =
10;
Then reject oversized updates:
if messages.len()
> MAX_PREFERENCE_MSGS
{
return Err(
ContractError::
TooManyPreferences {},
);
}
The exact number should come from worst-case gas analysis of the most expensive allowed preference.
A count chosen without execution testing may still be unsafe.
Cap serialized size
A small vector can still contain large messages.
The protocol should also limit the total encoded byte size of LiquidationPreferences.messages.
This constrains:
Storage growth
Deserialization cost
Message dispatch overhead
Large payload abuse
Count and byte caps should both be enforced at write time.
Execute mandatory solver work first
The strongest ordering fix is to prioritize solvency actions:
Protocol-required checks
Solver swap or Repay
Optional borrower preferences
Another option is to let the liquidator explicitly ignore preferences:
ExecuteMsg::Liquidate {
addr,
msgs,
ignore_preferences,
}
An unsafe account must retain a path that does not depend on borrower-controlled optional work.
Restrict preference capabilities
Arbitrary LiquidateMsg::Execute calls make worst-case execution difficult to bound.
A safer preference language should allow only constrained choices such as:
Selecting an approved collateral
Selecting an approved swap adapter
Selecting a configured debt denom
Choosing among protocol-defined routes
The protocol can then estimate and test the maximum execution cost.
Limit ignored failures
The contract should not continue through an unlimited number of failed preferences.
Possible policies include:
Stop after the first preference failure
Stop after a small fixed number of failures
Skip all preferences once the account is unsafe
Reserve a strict gas allowance for preferences
Optional behavior must never consume resources reserved for mandatory liquidation.
Multi-transaction processing is not enough by itself
Processing only a fixed number of steps per transaction can prevent a single-call gas explosion.
But persisting an unbounded borrower prefix without changing priority may only spread the DoS across many transactions.
A safe design must ensure that risk-reducing steps execute early.
The protocol should not require liquidators to process thousands of optional messages before the first debt reduction.
Defense in depth
A robust patch should combine:
A hard preference-count cap
A serialized-size cap
A constrained operation allowlist
Solver or protocol actions before preferences
A liquidator bypass
A maximum ignored-failure count
A reserved execution budget for mandatory steps
Worst-case gas regression tests
The central invariant is:
Borrower-controlled optional work must have a finite protocol-defined upper bound and must never make mandatory liquidation unreachable.
Regression tests
A complete correction should verify:
Oversized preference vectors are rejected
Oversized encoded payloads are rejected
The maximum allowed set remains liquidatable under worst-case execution
Solver repayment executes before optional preferences
Liquidators can bypass preferences
Repeated preference failures stop at the configured limit
Mandatory work retains a reserved execution budget
Arbitrary external calls are rejected or constrained
Queue order is explicitly tested
An unsafe account always retains a protocol-controlled liquidation path
Maximum preference cost remains below liquidation incentives
Preference state created while healthy cannot become unbounded future work
Broader audit lessons
Soft failure still consumes resources
Ignoring an error does not refund the gas spent producing it.
Every optional failure needs a resource bound.
Stored vectors can become execution attacks
A list that appears harmless during configuration may later be processed inside a critical transaction.
Storage limits and execution limits must be designed together.
Queue order defines trust priority
reverse(), append(), and pop() gave borrower work priority over solver work.
Collection operations can encode a security boundary.
Economic DoS is a solvency issue
A transaction can be technically valid and still unavailable when its cost exceeds the reward.
Protocols that depend on external keepers must test profitability at worst-case execution cost.
Deferred state can be a time bomb
Preferences stored during healthy operation activated only after the account became unsafe.
Deferred execution must be reviewed under adversarial future conditions.
Conclusion
Rujira Ghost Credit allowed borrowers to store a protocol-unbounded list of liquidation preference messages.
Those preferences executed before liquidator-provided steps because the lists were reversed, preferences were appended, and DoLiquidate consumed the queue through pop().
Failed preference calls were ignored, but their execution cost remained.
A borrower could therefore prepare a large prefix of expensive or reverting work while the account was healthy.
Once the position became unsafe, every liquidator had to pay for that prefix before reaching Repay.
The standalone PoC reproduced the queue policy and exhausted a finite symbolic gas budget one unit before the solver step could execute.
Code4rena classified the finding as High.

The engineering rule is simple:
Borrower preferences must be bounded, skippable, and unable to execute ahead of mandatory liquidation.

Top comments (0)