DEV Community

Daniel
Daniel

Posted on

How a Borrower-Controlled Repay Preference Could Brick Rujira Liquidations

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

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

After the account became unsafe, a liquidation attempt reached:

BORROW.load(
    deps.storage,
    denom.clone(),
)?
Enter fullscreen mode Exit fullscreen mode

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

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

The liquidation loop consumed the queue with:

queue.pop()
Enter fullscreen mode Exit fullscreen mode

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

p1

p2

s1

s2
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

The environment configured BTC as collateral and USDC as debt.

Initial prices were:

BTC
20,000

USDC
1
Enter fullscreen mode Exit fullscreen mode

The vault received:

25,000 USDC
Enter fullscreen mode Exit fullscreen mode

The borrower supplied:

1 BTC
Enter fullscreen mode Exit fullscreen mode

and borrowed:

15,000 USDC
Enter fullscreen mode Exit fullscreen mode

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

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

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

to:

10,000
Enter fullscreen mode Exit fullscreen mode

The account's LTV crossed the liquidation threshold:

let ltv_liquidatable =
    credit
        .query_account(
            &app,
            &account.account,
        )
        .ltv;

assert!(
    ltv_liquidatable
        >= Decimal::one()
);
Enter fullscreen mode Exit fullscreen mode

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![],
            },
        ],
    },
    &[],
);
Enter fullscreen mode Exit fullscreen mode

The transaction failed.

The resulting error was consistent with the missing borrow-market lookup and contained terms such as:

borrow

not found

NotFound
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

Base score:

8.1 High
Enter fullscreen mode Exit fullscreen mode

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

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

the contract should require that the borrow configuration contains the denom:

if !BORROW.has(
    deps.storage,
    denom.clone(),
) {
    return Err(
        ContractError::InvalidPreference {},
    );
}
Enter fullscreen mode Exit fullscreen mode

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

The exact design can vary.

The invariant is:

An unsafe account must always retain at least one protocol-controlled liquidation path
Enter fullscreen mode Exit fullscreen mode

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

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

The trust hierarchy should be visible in the architecture, not emerge accidentally from reverse(), append(), and pop().

Regression tests

A complete patch should verify:

  1. SetPreferenceMsgs rejects an unknown repay denom

  2. A known repay denom can be stored

  3. A denom that later becomes unavailable cannot brick liquidation

  4. Failure of a preference does not prevent mandatory liquidation steps

  5. Liquidator-provided steps remain reachable

  6. An unsafe borrower cannot veto liquidation

  7. Preference count and complexity are bounded

  8. Queue ordering is explicit and tested

  9. Clearing preferences while safe succeeds

  10. Recovery from an invalid stored preference does not require borrower cooperation

  11. LiquidateMsg::Execute failure remains isolated

  12. LiquidateMsg::Repay failure 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(),
)
Enter fullscreen mode Exit fullscreen mode

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)