89809 w a low nested utility if else proposal forces exponential work in the official sdk and temporarily disables a victim s multisig view
Submitted on Aug 21st 2026 at 10:05:27 UTC by @legat for Audit Comp | Quantus
Report ID: #89809
Report Type: Websites & Apps
Report severity: Low
Target: https://github.com/immunefi-team/audit-comp-quantus-apps/tree/audit-comp-ready/quantus_sdk
Impacts:
Temporarily disabling user to access target site, such as:
Locking up the victim from login
Cookie bombing, etc.
Description
Summary
The official SDK's CallDecoder describes the attacker-controlled primary branch of every Utility.if_else node twice: once to build the displayed nested field and again to obtain the summary. A linear-size chain of ordinary if_else calls therefore causes exponential synchronous work in the client.
The runnable PoC uses the exact frozen release node, exact frozen SDK, real Quantus key derivation/signing, and real multisig storage. An ordinary member creates a normal 2-of-2 multisig and submits one canonical depth-32 proposal. The node accepts and stores all 163 bytes, but the production CallDecoder.decodeBytes() does not return within eight seconds because it must perform exactly 12,884,901,886 description invocations.
The vulnerable decoder is called synchronously when the official mobile proposal flow hydrates authoritative Multisig.Proposals bytes and obtains decodedCall.summary. The narrow supported impact is temporary loss of access to the affected multisig view on the victim client. This report does not claim a global backend outage, chain halt, application-wide takedown, login compromise, or loss of funds.
Intended behavior
Call review should take work proportional to the encoded call tree. Each nested child should be decoded/described once and its result reused for both the detailed tree and summary. A 163-byte canonical proposal should not monopolize the Flutter UI isolate.
Actual behavior
At the frozen revision, the IfElse branch is:
The first describe(main) creates the primary display tree. The second independently recreates the complete same tree merely to read .summary. Adjacent wrappers cache their child result, confirming that the second traversal has no display purpose.
Define:
The SCALE size grows linearly:
Let D(n) be the number of describe() invocations:
20
103
3,145,726
24
123
50,331,646
28
143
805,306,366
30
153
3,221,225,470
32
163
12,884,901,886
The generated SCALE decoder first parses only 65 small call nodes. The exponential behavior begins in Quantus-authored presentation logic after decoding.
Root cause
The root is the duplicate recursive evaluation at call_decoder.dart:572-580, specifically:
The ordinary proposal consumer is multisig_service.dart:293-325: it retrieves authoritative call bytes and synchronously evaluates withBytes.decodedCall?.summary.
The official mobile home flow watches open proposals through multisig_activity_section.dart:25-57. There is no isolate/compute boundary around this SDK description path in production.
Risk
Impact
One small canonical proposal makes the exact official SDK remain inside deterministic synchronous work for materially longer than a normal UI response window. When invoked by the mobile proposal provider on the UI isolate, the affected view cannot complete loading or respond normally during that work. Reopening or refreshing the same active multisig retriggers the same decode while the proposal remains active.
This maps to the selected Low impact only. The PoC does not establish service-wide unavailability for unrelated users, and local recovery bounds severity.
Likelihood
Exploit complexity is low:
one ordinary co-signer in a normal non-high-security multisig;
one signed proposal and ordinary proposal fee/deposit;
163 canonical bytes, about 1.6% of the 10,240-byte call limit;
no malformed SCALE, trailing bytes, privileged role, governance action, oracle, public-node traffic, or repeated request volume;
no victim signature, approval, or execution is needed for the decoder sink.
The attacker cannot target unrelated users; the victim must load a multisig in which the attacker can create proposals. That membership boundary is reflected in the Low severity.
Attack path
The PoC proves steps 1-6 with a real signed transaction and storage equality, then safely kills the unchanged decoder after an eight-second non-return. Step 7 is the direct consequence of production calling that same synchronous function on the Flutter UI isolate.
Preconditions
External preconditions
The victim uses the official SDK/mobile proposal flow for a multisig shared with the attacker.
The ordinary proposal feed returns the active on-chain proposal, after which the SDK obtains authoritative storage bytes.
No malicious or private indexer behavior is required. The PoC does not depend on canceled-history retention.
Internal preconditions
A normal multisig accepts ordinary custom runtime-call bytes.
The proposal remains active when the victim loads it.
The client uses the frozen decoder.
Affected code and complete flow
Vulnerable root
quantus_sdk/lib/src/chain/call_decoder.dart:572-580
Describes main twice at every nested level
Authoritative hydration
quantus_sdk/lib/src/services/multisig_service.dart:293-325
Fetches stored bytes and eagerly reads decodedCall.summary
Model decoder
quantus_sdk/lib/src/models/multisig_proposal.dart:258-275
Sends attacker bytes into CallDecoder
Mobile consumer
mobile-app/lib/v2/screens/multisig/multisig_activity_section.dart:25-57
Watches proposals in normal home activity
Ingress/context
pallets/multisig/src/lib.rs:535-692
Accepts, charges for, and stores the canonical call
The runtime Utility and multisig pallets are impact carriers, not the submitted vulnerable asset.
Recommended mitigation
Describe each branch once and reuse the primary summary:
Defense in depth:
Add a global node/depth/work budget shared by every recursive wrapper.
Cache each proposal's decoded display tree instead of repeatedly decoding through getters.
Decode list/history data lazily rather than all rows eagerly.
Keep bounded attacker-controlled parsing off the Flutter UI isolate.
Add depth-20 through depth-32 regression vectors and assert near-linear scaling.
Preserve authoritative chain bytes as the review source.
Proof of Concept
Proof boundary and topology
The PoC exercises the exact frozen release node/runtime spec 143, actual Quantus keys and SDK signing, real 2-of-2 multisig creation, real proposal inclusion, exact Multisig.Proposals bytes, and the exact production decoder.
One exact release node is sufficient. This report makes no consensus, validator-divergence, network-partition, P2P, or cluster-availability claim. The proof boundary is canonical storage, a signed proposal, the deterministic SDK/UI decode path, and timing. Additional validators cannot change the stored call bytes or client recurrence.
The isolate is only a kill-switch so the runner can terminate the unchanged synchronous production decoder. It does not mock, accelerate, delay, or replace the target. In production the same function runs without that safety boundary on the Flutter UI isolate.
The in-memory preferences and secure-storage adapters only provide the documented disposable Alice/Bob development mnemonics. They do not supply proposal data, chain state, decoder output, or timing.
File layout
Place these files in the frozen apps checkout; keep the frozen chain checkout as sibling audit-comp-quantus-chain:
Complete Dart E2E PoC
Save as quantus_sdk/test/poc/recursive_if_else_node_e2e_test.dart:
Complete verifier and runner
Save as poc/quantus-report-1/run.sh and make executable:
Run:
Captured reference node fingerprint
The runtime-version line omits only the verbose apis array for readability; the runner records the complete unmodified RPC result.
Captured reference E2E output
The first hash is the real signed multisig-creation extrinsic; the second is the real signed proposal extrinsic. The test fails unless the proposal exists in runtime storage and retrieved bytes equal the submitted vector.
Determinism and falsification conditions
Exact invariants are depth 32, 65 call nodes, 163 encoded bytes, 12,884,901,886 descriptions, runtime spec 143, and storage equality. Wall-clock time varies by CPU, so the test asserts the conservative fact that the unchanged decoder has not returned after eight seconds. A fixed implementation completes in linear work and makes the timeout assertion fail, serving as a negative control.
Reject or revise the report if the frozen node does not accept/store the exact call, the SDK fingerprint differs, the SDK completes the unchanged depth-32 description within the deadline, or the actual mobile flow no longer calls this decoder synchronously. None occurred in the reference run/source recheck.
Was this helpful?