DEV Community

Daniel
Daniel

Posted on

How Unbounded Borrower Preferences Could Make Rujira Liquidations Economically Impossible

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,
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

There was no check equivalent to:

messages.len()
<=
MAX_PREFERENCE_MSGS
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

ExecuteMsg::DoLiquidate selected the next step through:

queue.pop()
Enter fullscreen mode Exit fullscreen mode

Because pop() removes the last element, execution became:

p1

p2

s1

s2
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The transaction requires at least:

G_total
=
Σ gᵢ
+
G_solver
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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(),
    );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The complete modeled liquidation required:

10,000
+
10

=
10,010 units
Enter fullscreen mode Exit fullscreen mode

The available budget was deliberately set to:

10,009 units
Enter fullscreen mode Exit fullscreen mode

The preference prefix consumed:

10,000 units
Enter fullscreen mode Exit fullscreen mode

Only nine remained.

The solver’s Repay step required ten.

The model returned:

OutOfGas
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Then reject oversized updates:

if messages.len()
    > MAX_PREFERENCE_MSGS
{
    return Err(
        ContractError::
            TooManyPreferences {},
    );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Another option is to let the liquidator explicitly ignore preferences:

ExecuteMsg::Liquidate {
    addr,
    msgs,
    ignore_preferences,
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. A hard preference-count cap

  2. A serialized-size cap

  3. A constrained operation allowlist

  4. Solver or protocol actions before preferences

  5. A liquidator bypass

  6. A maximum ignored-failure count

  7. A reserved execution budget for mandatory steps

  8. 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:

  1. Oversized preference vectors are rejected

  2. Oversized encoded payloads are rejected

  3. The maximum allowed set remains liquidatable under worst-case execution

  4. Solver repayment executes before optional preferences

  5. Liquidators can bypass preferences

  6. Repeated preference failures stop at the configured limit

  7. Mandatory work retains a reserved execution budget

  8. Arbitrary external calls are rejected or constrained

  9. Queue order is explicitly tested

  10. An unsafe account always retains a protocol-controlled liquidation path

  11. Maximum preference cost remains below liquidation incentives

  12. 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)