For the complete documentation index, see llms.txt. This page is also available as Markdown.

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

Root cause: one branch is converted twice at every level

CallDecoder.decodeBytes strictly SCALE-decodes the input and then synchronously calls describe. In the Utility.if_else arm, describe(main) is evaluated once to build the Primary call field and a second time to obtain summary. The first result is not reused:

static DecodedCall decodeBytes(List<int> bytes) {
  final input = Input.fromBytes(Uint8List.fromList(bytes));
  final call = runtime.RuntimeCall.codec.decode(input);
  final remaining = input.remainingLength ?? 0;
  if (remaining != 0) {
    throw FormatException('$remaining trailing bytes after nested call');
  }
  return describe(call);
}

// ...

case utility.IfElse(:final main, :final fallback):
  return DecodedCall(
    pallet: 'Utility',
    call: 'if_else',
    fields: [
      NestedCallField('Primary call', describe(main)),
      NestedCallField('Fallback call', describe(fallback)),
    ],
    summary: describe(main).summary,
  );

This is not the recursion inherent in walking a nested call. It is duplicate conversion of the same immutable main object at every level, including reconstruction of its complete descendant tree.

Four other wrappers in the same file take the result into a local and reuse it — as_derivative at :547, with_weight at :562, dispatch_as at :599 and Recovery at :614 each read final inner = describe(call);. if_else is the only arm that calls describe twice on the same operand, at :577 for the field and again at :580 for the summary.

The encoded call grows linearly while conversion work grows exponentially

Let R be an empty System.remark, and define F(0) = R and F(n) = Utility.if_else(main: F(n-1), fallback: R). Counting the root invocation, D(0) = 1 and the decoder follows:

Each additional layer adds only the two call discriminants and one three-byte empty fallback, so the SCALE size is 3 + 5n. The real runtime tree has 2n + 1 nodes.

Shape

SCALE bytes

Runtime nodes

Source-derived required describe invocations

Representative clean test result

depth-42, nesting in fallback

213

85

127

completed in 784 us

depth-16, nesting in main

83

33

196,606

completed in 32,970 us

depth-42, nesting in main

213

85

13,194,139,533,310

did not return before 2,000 ms; test terminated worker

depth-100, nesting in main

503

201

3,802,951,800,684,688,204,490,109,616,126

did not return before 30,000 ms; test terminated worker

Both main-nested rows are stored on chain by the runtime test at those exact byte lengths. The equal-size depth-42 fallback case is the negative control: it passes through the same SCALE codec and the same CallDecoder.decodeBytes, but the duplicated main is always the leaf, so the recurrence stays linear at 3n + 1. Wall-clock values are machine-dependent; the structural counts and encoded sizes are deterministic.

The two main-nested depths answer different questions. Depth 42 is the conservative case: at 213 bytes it is 2.080% of the runtime's call-size limit, 0.582% of the ref_time component of its inner-weight limit and 11.222% of the proof_size component, so nothing about it is near an edge. Depth 100 answers whether the state is a delay that a user could sit out — 2,568,584,264,161,081 years at the fastest rate measured settles that — and it too stays inside every gate, at 4.912% of the byte limit, 1.386% of ref_time and 26.718% of proof_size. Both payloads are accepted and stored by the runtime test.

The worker isolate exists only to keep the regression test bounded and to confirm cleanup. The mobile production path invokes the same synchronous function on its normal application isolate without a traversal budget, cancellation point, timeout, cache or background worker.

The mobile application invokes the decoder automatically for an active multisig

The condition does not require opening a proposal detail sheet. HomeScreen mounts MultisigActivitySection whenever the selected account is a multisig. That section immediately watches multisigOpenProposalsProvider, which calls MultisigService.getOpenProposals. Each row returned by the indexer is parsed first, and an affected row does not get past that step.

If the parse returns, the service then replaces the indexer bytes with the authoritative call stored in Multisig.Proposals and executes withBytes.decodedCall?.summary. The decodedCall getter calls CallDecoder.decodeBytes, so this is a second synchronous conversion of the same call, still before any row is rendered:

The application persists the complete active DisplayAccount in _activeDisplayAccountKey. ActiveAccountNotifier reloads it during construction, and HomeScreen mounts the multisig activity section again. An ordinary force-close therefore does not change the selected account or remove the stored proposal; normal startup reaches the same synchronous conversion again after the open-proposal query completes.

Both payloads are valid, inexpensive runtime proposals

The supporting runtime regression independently constructs the depth-42 bytes using the call indices — RuntimeCall::Utility = 9, Utility::if_else = 6, and empty System.remark = 00 00 00 — and also constructs the same call through typed Rust variants. It asserts byte equality, exact decode, no trailing data and byte-for-byte round-trip. A second case does the same at depth 100. Both compare their payload against the runtime's call-size and inner-weight limits.

Neither payload is near a limit:

ref_time

proof_size

bytes

depth 42

0.582%

11.222%

2.080%

depth 100

1.386%

26.718%

4.912%

proof_size is the strictest of the three, and even the depth-100 payload uses just over a quarter of it. For each case the regression creates an ordinary 2-of-2 multisig, submits the exact bytes through Multisig::propose — which applies those gates itself — and reads the same bytes back with status Active. No malformed SCALE, oversized payload, invalid call, modified endpoint or repeated request is involved.

The SDK's generated metadata predates the chain as a whole, but that does not create an assumption here: the Dart test asserts the exact encodings, and the independent runtime test decodes and stores those same call indices and bytes.

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: d5fc4d6572ebc2244e95009a33f5da5048935867

  • Supporting chain: 3b243b870a5442a6e5f443056f67bbba87d472f5

  • Primary scoped asset: https://github.com/immunefi-team/audit-comp-quantus-apps/tree/audit-comp-ready/mobile-app

  • The affected decoder is supplied by the separately listed quantus_sdk asset 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:

Step
Source
Form

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

  1. 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.

  2. That signer submits a valid nested Utility.if_else call 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.

  3. 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.

  4. 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:

  1. 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.

  2. The application's own response handling does not survive one such row. MultisigProposal.fromIndexerJson, given an ACTIVE row 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.

  3. That work is synchronous on the isolate that draws the interface, through the chain tabulated above, with no compute(), Isolate.run or comparable boundary in either asset's source.

  4. 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.

  5. 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.

Additional impact selection considerations

Against the named out-of-scope entries. The Websites-and-Apps exclusions that sit nearest to this finding are quoted below with the reason each does not reach it.

Exclusion, verbatim
Why it does not apply

"Theoretical impacts without any proof or demonstration"

Three local layers execute production code. CallDecoder.decodeBytes is run directly at depths 16–24 with the growth fitted from the measurements, at depth 42 under a two-second bound and at depth 100 under a thirty-second bound; MultisigProposal.fromIndexerJson is run on an ACTIVE indexer row; and the runtime stores both payloads and returns ProposalStatus::Active.

"Impacts caused by vulnerabilities requiring un-prompted, in-app user actions that are not part of the normal app workflows"

No in-app action is required. The conversion runs while the account's open proposals load. Viewing a multisig's pending proposals is the ordinary workflow, and the active account is restored from storage on launch.

"Impacts that only require DDoS"

One stored proposal of 503 bytes, submitted once, at ordinary transaction cost. There is no traffic volume, no repetition and no request rate involved; the payload is a well-formed call inside every runtime limit.

"UX and UI impacts that do not materially disrupt use of the platform"

Once loading reaches the synchronous conversion, the UI isolate stops servicing further frames and input, and relaunching re-enters the same conversion. That is the disruption the exclusion carves out.

"UI/UX best practice recommendations"

The report identifies one arm of one function that converts the same operand twice where its four siblings hoist it, and measures the consequence. The remediation is that one change.

"Impacts involving attacks requiring physical access to the victim device" and "…access to the local network of the victim"

Neither. The input reaches the device through the chain and the project's ordinary indexer.

"Actions by privileged actors within their privileges"

A registered signer's privilege in a threshold scheme is to propose and to co-sign. Rendering another signer's wallet unusable is not among those privileges, and the affected user need not be a party to the proposal beyond tracking the multisig.

On what the affected user must do. Nothing beyond launching the application. No proposal-specific approval, confirmation, signature or interaction is required, and no non-default setting is involved. The single precondition is that the proposer holds ordinary signer authority in a multisig the user also tracks.

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

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

File
Purpose

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:

  1. Both encodings are exactly 213 bytes and byte-for-byte equal.

  2. Frozen RuntimeCall::decode consumes the full input and round-trips unchanged.

  3. The decoded shape contains exactly 42 Utility.if_else nodes and 43 empty System.remark nodes.

  4. Its weight is below MaxInnerCallWeight, and its bytes are below MaxCallSize.

  5. A normal 2-of-2 multisig accepts the exact bounded bytes through Multisig::propose.

  6. 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:

Depth
Bytes
Traversals
Projected at 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

poc_test.dart
runtime-harness.patch
runtime_test.rs
scaling_test.dart — measured 16..24 sweep and the fit behind the projection
indexer_row_test.dart — the production SDK factory on one indexer-shaped row

Was this helpful?