Skip to content

Menese DeFi Team evaluation, Part 3: the liquidation path — one new reproduced finding, and a cross-report synthesis with OhShii Labs (#4–#11) #12

Description

@KYounesMercatura

Continuing our evaluation (#2 load-bounding, #3 value/integrity), on the same published tip
(commit 0241cbafb779415e45dc56f0fabc1bbbb6ba9a5d). This part adds one new finding we
reproduced and measured on an unmodified build
, and then a synthesis: read alongside
OhShii Labs' eight-part review (#4#11) and our #2/#3, the individually-scoped liquidation
items compose into something larger than any of them. We credit each leg to whoever found
it, inline.

Housekeeping first, unchanged since #2: SECURITY.md's advisory link
(github.com/dfinity/multidex/security/advisories/new) still 404s — no private channel, so
this is public. OhShii hit the same wall (#4).

A note on framing, in OhShii's words from #4 which we agree with: play-money balances cap the
value-theft magnitude on the live #play posture, but they do not cap availability and
integrity. The new finding below is an availability+integrity failure and lands at full
severity on #play; the synthesis after it separates the capped legs from the uncapped ones
explicitly.


1. runLiquidationBatch is the only uncapped sweep on a fund-safety path — it traps at the 40 B message limit, silently, with no operator recovery (new; reproduced on-IC)

Where: src/backend/main.mo:3383-3494; dispatch at :6185; recovery path
adminRunLiquidationBatch at :10729.

Why it matters. The batch runs three phases, each iterating the entire loans set with
no cap, no shard, no cursor, no budgetPrincipal.fromText per user per phase, and
Phase 3's tryLiquidate is an 8-iteration seize loop with map mutations. Every other sweep
in the codebase is bounded (reapClosedOrdersREAP_SWEEP_CAP, sweepStaleUserOrders
MAX_PER_CALL, tickTier/tickLeaderboardShardlib/Shard.mo cursor, processDeferred
limit, tickHeatmapsMAX_POOLS). This one is not. So its cost grows without bound in the
loan-set size, and at some size it exceeds the 40 B per-message instruction limit and traps.

The trap is silent by construction. It is dispatched ignore tickLiquidations() (:6185)
after _lastLiqNs := now in the heartbeat, and tickLiquidations : async () is a separate
self-scheduled message. So the trap rolls back only the liquidation message — the heartbeat
message already committed _lastLiqNs := now, and _lastHeartbeatNs advances too. No stamp
regresses, no counter freezes; the canister looks completely healthy while its solvency engine
is dead. (This extends OhShii #5.7: they correctly classified tickLiquidations as
trap-isolated — a trap kills only that message. We add that this isolation is exactly what
makes the trap invisible, and that the batch inside that isolated message is itself uncapped.)

Reproduction (isolated local replica, dfx 0.32; instrumented copy, batch logic
byte-identical — only a performanceCounter(0) wrapper and controller-gated synthetic-loan
seeders added).

Cost of one runLiquidationBatch vs loan-set size N (healthy loans):

N instructions N instructions
1,000 0.94 B 15,000 16.86 B
2,000 1.92 B 20,000 21.63 B
4,000 3.93 B 25,000 30.42 B
8,000 8.09 B 34,000 37.72 B (last ok)
10,000 10.22 B 36,000 / 40,000 IC0522 — trap

~1.0–1.2 M instructions per loan. K_trap bracketed 34,000 < K_trap ≤ 36,000 (~35k loans).
Verbatim, at N=40,000:

Canister exceeded the limit of 40000000000 instructions for single message execution.  … IC0522

The enforced single-message limit is exactly 40 B — no extrapolation.

  • No operator recovery. adminRunLiquidationBatch calls the same runLiquidationBatch;
    at N=40,000 it returns the identical IC0522. Once N crosses ~35k, not even a controller can
    force a liquidation — recovery requires a code upgrade.
  • Silence, measured. Reading (_lastLiqNs, _lastHeartbeatNs) before/after two trapping
    attempts at N=40,000: both advanced (_lastLiqNs +36.4 s, _lastHeartbeatNs +54.4 s) while
    the batch never completed. No in-canister observable changes.
  • The risk panel then reports the opposite of the truth. absorbBadDebt has exactly one
    call site (:8106), reachable only through tryLiquidate. Batch dead → no liquidation → no
    absorption → uncoveredBadDebtUsd stays 0 → getMarginRiskSummary shows a solvent book
    while losses accrue. (Verified from source; not separately driven on-replica.)

The load-bearing number — the underwater multiplier. The curve above is healthy loans.
Underwater loans run the full plan-and-seize path; measured at ~3.9× a healthy loan
(N=3,000 → 11.35 B, seizing all 3,000; N=6,000 → 23.61 B). So the trap point collapses under
stress:

  • All-underwater book self-traps at ≈ 40 B / 3.9 M ≈ 10,200 loans.
  • Mixed: cost ≈ 1.05 M·N + 2.9 M·U (skeleton over all N, plus the seize cost of U underwater
    loans). A 20,000-loan book traps once U ≈ 6,300 go underwater together; a 30,000-loan
    book once U ≈ 2,700 do.

So the engine crosses the trap threshold precisely during the volatility event that pushes a
large cohort underwater at once
— it self-disables exactly when it must fire. No attacker
required; organic growth plus one bad candle suffices. The healthy-book ~35k figure is a
conservative upper bound.

Severity: Critical — protocol-wide, permanent, silent, no in-canister recovery, and the
risk panel conceals it. Uncapped by play money (availability + integrity, not balance size).

Suggested direction. Shard the batch with lib/Shard.mo (cursor + per-pass cap), carrying
the cursor across beats as tickTier/tickLeaderboardShard already do; add a consecutive-
failure counter and surface it; and stamp _lastLiqNs on completion inside tickLiquidations,
not on dispatch in the heartbeat, so the schedule stamp cannot advance past work that never ran.


2. Synthesis: the liquidation path is a single point of failure — five independent break-paths, one shared concealment

Reading both reviews together, the solvency backstop has no redundancy and can be disabled
or corrupted five unrelated ways — and absorbBadDebt's single call site means every one of
them leaves the risk panel green:

# break-path effect credit
a uncapped batch traps at ~35k loans (this issue §1) whole engine, silent, unrecoverable Menese (Part 3)
b 1-unit ICPUSD dust → seize rounds to 0 → break L one pool permanently un-liquidatable OhShii #6.1
c cycle starvation → outcallCycles=0 → refPrice frozen → 5-min stale batch skips everyone OhShii #9.3
d post-fill hook liquidates at a stale mark (no freshness guard, unlike the batch) seizes the healthy / lets the underwater dodge OhShii #6.2
e a short cannot self-close in the 1.15–1.25 band user forced into the 5% penalty OhShii #6.3

That convergence — liquidation is simultaneously the least-defended subsystem (the one uncapped
sweep; one path with no freshness guard; no redundant trigger) and the most concealed (single
absorbBadDebt site) — is, we think, a larger finding than any of its parts, and it only
appears when the two reviews are read side by side.


3. Synthesis: an oracle → forced-liquidation → extraction chain (the thin-oracle exploit class)

Each leg below is a separately-filed finding; composed on a real-money posture they form the
classic thin-oracle attack (manipulate a soft mark, force favourable liquidations, extract) —
the shape of the Mango Markets exploit (Oct 2022). We state it so the maintainer sees the chain,
not just the links. Play money caps the magnitude on #play; it does not close the chain, and
a #production flip removes the cap.

  1. Arm the refresh cadence — every staged order arms an 8-outcall price fetch ~1 s out, so
    an attacker controls when the mark moves. — OhShii OhShii Labs review, 6/8 · 4 findings (#9.1–#9.4): the oracle input path and the archive shed #9.3
  2. Degrade the aggregate — at 2 sources the ±2σ outlier trim is inoperative (Menese Menese DeFi Team evaluation, Part 2/2: value, integrity, and the oracle input path #3
    item 3
    ); the parser drops scientific notation / first-matches keys (Menese Menese DeFi Team evaluation, Part 2/2: value, integrity, and the oracle input path #3 item 4);
    and a stale sample is stamped fresh because withRefPrice(p, newPx, now) uses continuation
    time, not sample time (OhShii OhShii Labs review, 6/8 · 4 findings (#9.1–#9.4): the oracle input path and the archive shed #9.2).
  3. Ratchet the mark — the 2.5% breaker has no absolute anchor and XRC is unwired on #play,
    so ≤2.5% steps compound with zero breaker events. — OhShii OhShii Labs review, 6/8 · 4 findings (#9.1–#9.4): the oracle input path and the archive shed #9.1
  4. Force the harvest — the post-fill liquidation hook fires with no freshness check, seizing
    at the fabricated mark; getMarginHeatmap publishes the exact notional per band beforehand.
    OhShii OhShii Labs review, 3/8 · 6 findings (#6.1–#6.6): liquidation evasion, a de-lever trap, and release priority that is purchasable #6.2
  5. Capture / conceal — pre-position in the insurance/LP tranche, where the mint/redeem
    asymmetries let a round-trip mint value (OhShii OhShii Labs review, 4/8 · 4 findings (#7.1–#7.4): vault and insurance — mint/redemption asymmetries and a guard evaluated on the wrong quantity #7.1/OhShii Labs review, 4/8 · 4 findings (#7.1–#7.4): vault and insurance — mint/redemption asymmetries and a guard evaluated on the wrong quantity #7.2, Menese Menese DeFi Team evaluation, Part 2/2: value, integrity, and the oracle input path #3 item 1), and the
    loss never appears in the risk panel (single absorbBadDebt site).

General root (under paths c/d and step 3/4): refPriceUpdatedNs is a single timestamp
gating ~12 safety checks, and an attacker can push it both ways — freeze it (#9.3) to suppress
liquidation of their own pool, or stamp-stale-as-fresh (#9.2) to force a victim's at a chosen
mark. One value, two directions, decides who is liquidated and at what price.

Suggested directions (all already argued in the cited issues): an absolute XRC band that
rejects rather than alarms and wiring XRC on #play (#9.1/#3.3); a cumulative-drift breaker;
monotonic, sample-timestamped applyFreshAggregate (#9.2); a freshness guard inside
tryLiquidate so no call site can miss it (#6.2); and the batch sharding in §1.


Credit

Findings b, c, d, e (§2) and legs 1, 3, 4 and part of 5 (§3) are OhShii Labs' catches
(#4#11); we reproduce and build on them and claim none. §1 and Menese items #3.1/#3.3/#3.4 are
ours. Where two teams reached the same place independently we have said so in each issue. Two
independent reviews converging on the liquidation and oracle paths is, we think, itself signal.

We are happy to open PRs; several fixes are a few lines and reuse patterns already in the tree
(lib/Shard.mo cursors, the finally idiom, the IS_PRODUCTION interlock).

Filed by the Menese DeFi Team.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions