88348 w a high exponential synchronous decoding of a 213 byte utility if else proposal leaves the mobile wallet unable to render a frame or accept input whenever the affected multisig account loads
Submitted on Aug 12th 2026 at 20:14:15 UTC by @skydev for Audit Comp | Quantus
Report ID: #88348
Report Type: Websites & Apps
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-quantus-apps/tree/audit-comp-ready/mobile-app
Impacts:
Taking down the application/website
Description
Brief/Intro
The Quantus SDK describes Utility.if_else by recursively describing its main branch twice: once for the displayed primary call and again for the propagated summary. An affected row stops the application while its indexer response is being parsed. If that initial parse returns, the later authoritative-storage enrichment decodes the same call again. Repeating that valid wrapper 42 times produces a 213-byte call with only 85 runtime nodes, but the source recurrence requires 13,194,139,533,310 recursive describe invocations. The mobile application automatically obtains authoritative stored calls and performs this conversion while loading an active multisig account, blocking the application isolate. Because the active account is persisted and restored, normal startup re-enters the same conversion after an ordinary force-close and restart. A bounded test using the unmodified production decoder does not return before its two-second watchdog; the test then terminates the isolated worker for cleanup. Equal-size linear nesting completes in under one millisecond in the representative output below, and a second test proves that the exact bytes fit the runtime limits and are stored by an ordinary 2-of-2 multisig.
Vulnerability Details
Expected and observed behavior
Expected: A call accepted within the runtime's size and weight limits should have a bounded conversion cost. Recursive review should visit each decoded node once, or reject a call before synchronous work can make the application unavailable.
Observed: The conversion cost depends on where identical nesting is placed. A valid 213-byte call with 85 runtime nodes expands to more than 13 trillion synchronous conversions and does not return before the bounded two-second observation ends; the 503-byte depth-100 call expands to 3.8e30 and does not return before thirty seconds. The equal-size fallback-nested control completes in under one millisecond on the same path.
Affected sources
Mobile application and SDK:
d5fc4d6572ebc2244e95009a33f5da5048935867Supporting chain:
3b243b870a5442a6e5f443056f67bbba87d472f5Primary scoped asset:
https://github.com/immunefi-team/audit-comp-quantus-apps/tree/audit-comp-ready/mobile-appThe affected decoder is supplied by the separately listed
quantus_sdkasset at the same commit.
Public-review boundary
Public PR #585 introduced CallDecoder and describes recursive nested-call display. Public PR #586 describes recursive rendering and the outer 8 KiB cold-wallet payload cap; the same approved branch was remerged to main through PR #588. Public PR #575 addresses a different integrity issue: binding multisig review to authoritative on-chain bytes instead of indexer presentation. None of those materials identifies the repeated describe(main), its recurrence, a traversal budget, or this availability result. No exact report or fix was located in the supplied V12, Eiger and project-review corpus.
Remediation
Decode main and fallback once and reuse both results:
The local fix removes this exponential recurrence, but the boundary should also be explicit: enforce aggregate depth/node/field/render-byte limits, cache one DecodedCall per immutable callRaw, avoid decoding the same proposal in both service mapping and widgets, and perform bounded conversion away from the application isolate with cancellation. Regression tests should compare equal-size main-nested and fallback-nested calls and assert linear visit counts.
Impact Details
Impact: Websites and Applications — Critical — "Taking down the application/website".
What a co-signer is entitled to do. The pallet grants a registered signer three things: submit a proposal (pallets/multisig/src/lib.rs:535), approve one (:740), and execute it once the threshold is met (:1068). Proposing and approving are individual acts; moving the multisig's funds is not, because execution needs the threshold.
Submitting one proposal — which needs no approval from anyone — is enough to stop the wallet of any signer who has that multisig as their active account. They enable no setting, confirm nothing, review nothing and sign nothing; the only thing they do is launch the application.
What "the application" means here. The input is a valid item of chain state that the runtime accepts and that each client fetches on its own initiative when the account loads. Every wallet instance that has the affected multisig as its active account reaches the same conversion independently, on its own device, with no further action by the attacker. While the synchronous conversion runs, the UI isolate stops servicing further frames and input.
The computation does not complete on any relevant timescale. A single stored depth-100 proposal projects to 2,568,584,264,161,081 years at the fastest measured rate, and that payload is not an edge case: Multisig::propose accepts it and stores it with status Active, at 4.912% of MaxCallSize, 1.386% of the ref_time component of MaxInnerCallWeight and 26.718% of its proof_size component. Nor does the attacker depend on a single proposal. MaxTotalProposalsInStorage is 200 per multisig with a per-signer share of 200 / signers (pallets/multisig/src/lib.rs:564-581, runtime/src/configs/mod.rs:647), so a signer of a two-signer multisig may hold up to 100 active proposals where one suffices, and may replace each as it expires at the cost of an ordinary transaction.
One active, valid on-chain proposal is sufficient to make the affected mobile-wallet instance stop servicing its event loop when the relevant multisig account loads.
While the conversion runs, nothing in the application is reachable — not only the affected multisig's screens.
The conversion is synchronous and runs on the isolate that draws the interface. The chain from the screen to the decode carries no boundary that could move it elsewhere:
Home mounts the section for the active account
mobile-app/lib/v2/screens/home/home_screen.dart:224
widget build
The section watches open proposals
multisig_activity_section.dart:25-108
provider watch
The service requests open proposals from the indexer
multisig_service.dart:225-232
async
Each returned row is parsed, and a row with no transfer columns but a call_raw is decoded during that parse
multisig_proposal.dart:109-111
synchronous — the first point that blocks
If parsing returns, the service replaces the call data with the authoritative bytes from chain storage and reads the decoded call again
multisig_service.dart:293-325, :319
synchronous .map()
A rendered row reads it a third time
proposal_row.dart:72
property read
The model decodes on every read
multisig_proposal.dart:146-148
plain getter, no async, no cache
The decoder walks the call tree
call_decoder.dart:63
synchronous switch
The row must be listed by the project's GraphQL indexer to reach any of this, which is ordinary behaviour for an ordinary proposal. What is not obvious is how early the conversion happens: MultisigProposal.fromIndexerJson decodes call_raw itself when the row carries no transfer amount and no recipient, and the Utility.if_else proposal row used by the PoC satisfies that condition. The conversion therefore begins while the indexer's response is still being parsed, before the proposal row renders and before the authoritative-storage step that would decode the same call again.
Each later reader decodes again: decodedCall is an uncached getter, so nothing is reused.
The application and SDK sources — mobile-app/lib and quantus_sdk/lib — contain no compute(), Isolate.run or BackgroundIsolate call. The one Isolate.spawn under those trees is in human_readable_checksum_service.dart:43 and belongs to address-checksum generation. (quantus_sdk/rust_builder/cargokit carries an unrelated CrateHash.compute used at build time.) A blocked UI isolate in Flutter renders no frame and dispatches no input, so while the conversion runs the account switcher at accounts_screen.dart:426 cannot be operated either, and the user's other accounts are equally unreachable through this client. The input is small and cheap by every measure the chain applies to it. The runtime test reports the depth-42 call at 0.582% of the ref_time component of MaxInnerCallWeight, 11.222% of its proof_size component, and 2.080% of MaxCallSize. It is a well-formed call accepted under the ordinary limits, fetched by the application's ordinary open-proposal flow, and it is a single stored state item rather than sustained traffic, repeated requests or malformed input.
Conditions
The proposing party is already one registered signer of an ordinary multisig that the affected user also tracks. This is normal multisig authority, not access to project infrastructure.
That signer submits a valid nested
Utility.if_elsecall as an active proposal and pays the ordinary proposal fee and deposit. The PoC demonstrates depth 42 at 213 bytes and depth 100 at 503 bytes; either is accepted, and one proposal is enough.That multisig is the affected user's active account. For a signer who uses it this is the resting state rather than an action: the active account is persisted in shared preferences and restored during provider construction (
settings_service.dart:173-193,account_providers.dart:63-98). Selecting it, or following the normal proposal-notification flow, reaches the same state.When the application loads open proposals, it converts the call synchronously while parsing the indexer's response. If that parse returns, the authoritative-storage step converts the same call again.
No approval, execution or movement of funds is needed.
This report is about an ordinary multisig, which is what Multisig::create_multisig produces and what the application's create flow builds. High security is a separate opt-in living in a different pallet, entered by an explicit ReversibleTransfers::set_high_security call that also nominates a guardian and a reversibility delay (pallets/reversible-transfers/src/lib.rs:375-380). An account already in that mode is not reachable by this exact shape, because the whitelist gating its proposals does not admit an outer Utility.if_else.
What the evidence establishes
The observations compose:
The runtime accepts and stores both payloads. The runtime test proposes them into an ordinary 2-of-2 multisig and reads them back with
ProposalStatus::Active.The application's own response handling does not survive one such row.
MultisigProposal.fromIndexerJson, given anACTIVErow carrying the relevant selected fields the factory uses, parses a depth-16 row normally and does not finish parsing either the depth-42 or the depth-100 row.That work is synchronous on the isolate that draws the interface, through the chain tabulated above, with no
compute(),Isolate.runor comparable boundary in either asset's source.The cost doubles per nesting level. Measured over seven converged depths, three samples each, the fitted slope is 0.9998 against a predicted 1.0 with r² = 0.999988, at 21.3–21.6 ns per traversal.
At the fastest of those rates, a depth-100 row needs 2,568,584,264,161,081 years — about 186,000 times the age of the universe — and that rate was measured on desktop hardware, not a phone.
When the application loads the affected multisig as its active account, the synchronous conversion prevents the UI isolate from servicing further frames or input. An ordinary force-close and relaunch restores the same active account and repeats the conversion.
Persistence and recovery bounds
The selected multisig is stored in shared preferences and restored during provider construction, so force-closing the application and reopening it restores the same active account and enters the same conversion again. Only the proposer may cancel the proposal before expiry (pallets/multisig/src/lib.rs:840); the proposer chooses the expiry, up to the runtime's 100,800-block maximum.
The conversion is already wrapped in a catch-all (multisig_proposal.dart:268-274), so a decoding failure is handled: the proposal renders with a null decoded call and the screen continues. That guard does not apply here, because nothing is thrown. A deep but linear nesting would exhaust the stack — the 10,240-byte limit admits depth 2,047 at 5 bytes per level — and that StackOverflowError would be caught like any other. Neither payload here goes near that: recursion depth equals nesting depth, so the two shapes descend 42 and 100 frames. The cost is in the branching, not the depth, and branching throws nothing — it simply does not return.
The PoC executes the production converter directly and bounds each observation — two seconds at depth 42, thirty at depth 100. Neither returns. The recurrence puts depth 42 at more than 13 trillion describe invocations and depth 100 at 3.8e30, while the production path runs the same synchronous converter on the application isolate with no traversal budget, timeout or cancellation. The report does not depend on a particular Android/iOS watchdog duration or process-termination policy.
References
Link to Proof of Concept
https://gist.github.com/Skydev0h/2b0d75a86a271cddcfd16a2dffcae38a
Proof of Concept
Proof of Concept
This PoC has three local layers joined by the same call shape. The first runs the production CallDecoder in a bounded worker so the test suite can observe non-completion and clean up safely. The second runs MultisigProposal.fromIndexerJson — the factory the application uses on the indexer's response — on an ACTIVE row carrying the relevant selected fields the factory uses. The third proves that the exact bytes are accepted, weighed and stored by the runtime as an ordinary active multisig proposal. No layer contacts a live node, indexer, account, device or project service.
Artifacts
The five reproduction files are in this unlisted Gist:
https://gist.github.com/Skydev0h/2b0d75a86a271cddcfd16a2dffcae38a
poc_test.dart
Frozen SDK bounded decoder regression and equal-size controls
runtime-harness.patch
Directly applicable chain test patch
runtime_test.rs
Standalone copy of the Rust test added by the patch
scaling_test.dart
Measured depth sweep, the exponential fit, and the depth-100 acceptance case
indexer_row_test.dart
The production SDK factory on one indexer-shaped row
Step 1: reproduce the bounded client observation
A Flutter SDK is required; the repository does not vendor one. The result below was reproduced with Flutter 3.44.9 stable. From a clean directory:
Observed output:
The output is one representative run; wall-clock values vary by host, and this block takes a single sample per case where the sweep below takes the median of three. The first test constructs both depth-42 calls through the project's generated RuntimeCall types and asserts their exact canonical encodings and equal 213-byte length. The second and third tests are controls through the same production decoder: equal-size fallback nesting and smaller depth-16 main nesting both complete, while the source recurrence projects 127 and 196,606 invocations respectively. The final test starts its two-second watchdog only after the worker reports that it has entered, observes that the depth-42 main nesting has not returned, terminates the worker in finally, and waits for onExit to confirm cleanup.
The worker changes only containment: it calls the unmodified production CallDecoder.decodeBytes. The application itself calls that synchronous function on its normal isolate and has no equivalent timeout or cancellation.
Step 2: reproduce the production indexer-row path
poc_test.dart calls CallDecoder.decodeBytes directly. indexer_row_test.dart instead runs MultisigProposal.fromIndexerJson — the SDK factory the application uses to turn one GraphQL row into a proposal — on an ACTIVE row carrying the relevant selected fields the factory uses, with no transfer_amount, no transferTo, and call_raw holding the stored bytes.
From the same quantus_sdk directory as Step 1:
Observed output:
The depth-16 control returns a parsed proposal with pallet=Utility, call=if_else and status=active, so the row is one the factory accepts. The depth-42 and depth-100 rows, identical in every field except the nesting depth, do not finish parsing. This is the application's own response-handling path rather than the decoder in isolation, and it blocks before the proposal row renders and before the authoritative-storage step that would decode the same call again.
Step 3: reproduce runtime acceptance and storage
A Rust toolchain and the repository's normal native build dependencies are required. The first build may take several minutes. From a clean directory:
Observed output:
The second case stores the depth-100 call that the client-side argument relies on. Multisig::propose applies the size and weight gates itself, so its returning Ok and the proposal reading back as Active is the acceptance: 503 bytes against a MaxCallSize of 10,240, and a call weight of 1.386% and 26.718% of the two MaxInnerCallWeight components.
The runtime test independently constructs the SCALE fixture and the typed Rust call, then verifies:
Both encodings are exactly 213 bytes and byte-for-byte equal.
Frozen
RuntimeCall::decodeconsumes the full input and round-trips unchanged.The decoded shape contains exactly 42
Utility.if_elsenodes and 43 emptySystem.remarknodes.Its weight is below
MaxInnerCallWeight, and its bytes are belowMaxCallSize.A normal 2-of-2 multisig accepts the exact bounded bytes through
Multisig::propose.The storage entry contains the same bytes, the proposing signer's first approval and status
Active.
This layer uses local TestExternalities and direct signed origins. It does not start a node or use private keys.
How the cost scales with depth
scaling_test.dart sweeps depths 16 to 24 through the same production converter and fits the growth rather than assuming it. Each depth is measured three times and the median is used, because a single sample on a loaded machine moves the fitted slope by several percent. The fit starts at depth 18, below which the first sample still carries JIT compilation.
A least-squares fit of log2(median) against depth over the seven converged points gives a slope of 0.9998 where the recurrence predicts 1.0, with r² = 0.999988. Per-traversal cost across those seven rows runs from 21.3 to 21.6 ns. The test asserts the slope and the r², and that the resulting projection exceeds one day; the exact figure is printed rather than asserted. A change to the growth rate therefore fails the test rather than producing a stale claim.
The projection uses the plateau rate rather than an extrapolated intercept, which is less sensitive to run-to-run noise. Taking the fastest rate observed, 21.3 ns:
24
123
50,331,646
1.07 s — measured
30
153
3,221,225,470
1.1 minutes
42
213
13,194,139,533,310
3.3 days
50
253
3,377,699,720,527,870
2 years
64
323
55,340,232,221,128,654,846
37,377 years
100
503
3,802,951,800,684,688,204,490,109,616,126
2,568,584,264,161,081 years
Each level adds exactly 5 bytes and doubles the work; the encoding is 3 + 5 × depth bytes.
The rate is the fastest measured on an AMD Ryzen 7 5800X (8 cores, 4.85 GHz boost) running the Dart VM on a desktop. It is a measurement on that host, not a mathematical floor.
The depth-100 acceptance case
The last line of the run above is a separate case at depth 100 — 503 bytes, 5% of the runtime's call-size limit — put through the same production converter with a 30-second bound. It does not return, and the worker is terminated cleanly.
At the fastest rate measured, that input needs 2,568,584,264,161,081 years, about 186,000 times the age of the universe. The conversion did not return within the 30-second observation. Once the synchronous parsing begins, the UI isolate cannot service further frames or input.
Result and limitations
Together, the layers connect a small, ordinary stored runtime call to the application's unbounded synchronous conversion. The client result is deliberately bounded: it proves exact bytes, non-completion at two seconds and clean worker termination; the exponential invocation count follows from the production-source recurrence shown in the Description. It does not claim a particular Android/iOS watchdog duration or process-termination policy. Production runs the same synchronous conversion on the application isolate and does not have the worker timeout or cleanup used solely to bound the regression test.
Attached source files
Was this helpful?