You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 budget — Principal.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 (reapClosedOrders→REAP_SWEEP_CAP, sweepStaleUserOrders→ MAX_PER_CALL, tickTier/tickLeaderboardShard→lib/Shard.mo cursor, processDeferred→ limit, tickHeatmaps→MAX_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)
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.
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).
Continuing our evaluation (#2 load-bounding, #3 value/integrity), on the same published tip
(commit
0241cbafb779415e45dc56f0fabc1bbbb6ba9a5d). This part adds one new finding wereproduced 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, sothis 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
#playposture, but they do not cap availability andintegrity. 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 onesexplicitly.
1.
runLiquidationBatchis 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 pathadminRunLiquidationBatchat:10729.Why it matters. The batch runs three phases, each iterating the entire
loansset withno cap, no shard, no cursor, no budget —
Principal.fromTextper user per phase, andPhase 3's
tryLiquidateis an 8-iteration seize loop with map mutations. Every other sweepin the codebase is bounded (
reapClosedOrders→REAP_SWEEP_CAP,sweepStaleUserOrders→MAX_PER_CALL,tickTier/tickLeaderboardShard→lib/Shard.mocursor,processDeferred→limit,tickHeatmaps→MAX_POOLS). This one is not. So its cost grows without bound in theloan-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 := nowin the heartbeat, andtickLiquidations : async ()is a separateself-scheduled message. So the trap rolls back only the liquidation message — the heartbeat
message already committed
_lastLiqNs := now, and_lastHeartbeatNsadvances too. No stampregresses, no counter freezes; the canister looks completely healthy while its solvency engine
is dead. (This extends OhShii #5.7: they correctly classified
tickLiquidationsastrap-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-loanseeders added).
Cost of one
runLiquidationBatchvs loan-set size N (healthy loans):~1.0–1.2 M instructions per loan. K_trap bracketed 34,000 < K_trap ≤ 36,000 (~35k loans).
Verbatim, at N=40,000:
The enforced single-message limit is exactly 40 B — no extrapolation.
adminRunLiquidationBatchcalls the samerunLiquidationBatch;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.
(_lastLiqNs, _lastHeartbeatNs)before/after two trappingattempts at N=40,000: both advanced (
_lastLiqNs+36.4 s,_lastHeartbeatNs+54.4 s) whilethe batch never completed. No in-canister observable changes.
absorbBadDebthas exactly onecall site (
:8106), reachable only throughtryLiquidate. Batch dead → no liquidation → noabsorption →
uncoveredBadDebtUsdstays 0 →getMarginRiskSummaryshows a solvent bookwhile 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:
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), carryingthe cursor across beats as
tickTier/tickLeaderboardShardalready do; add a consecutive-failure counter and surface it; and stamp
_lastLiqNson completion insidetickLiquidations,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 ofthem leaves the risk panel green:
break LoutcallCycles=0 → refPrice frozen → 5-min staleThat 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
absorbBadDebtsite) — is, we think, a larger finding than any of its parts, and it onlyappears 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, anda
#productionflip removes the cap.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
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 continuationtime, 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).
#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
at the fabricated mark;
getMarginHeatmappublishes 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
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
absorbBadDebtsite).General root (under paths c/d and step 3/4):
refPriceUpdatedNsis a single timestampgating ~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 insidetryLiquidateso 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.mocursors, thefinallyidiom, theIS_PRODUCTIONinterlock).Filed by the Menese DeFi Team.