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

88851 w a high exponential utility if else call display lets a multisig co signer persistently freeze the mobile wallet

Submitted on Aug 15th 2026 at 07:16:28 UTC by @Ailenia for Audit Comp | Quantus

  • Report ID: #88851

  • Report Type: Websites & Apps

  • Report severity: High

  • Target: https://github.com/immunefi-team/audit-comp-quantus-apps/tree/audit-comp-ready/quantus_sdk

  • Impacts:

    • Taking down the application/website

Description

Brief/Intro

The Quantus SDK describes a Utility::if_else primary child twice. Nesting another if_else in that child doubles the synchronous display work for every additional five bytes of SCALE input.

An ordinary member of a non-high-security multisig can store a 203-byte proposal that the mobile wallet retrieves from authoritative chain storage and expands on its UI isolate.

This makes the wallet unresponsive and causes the freeze to recur after restart while the affected multisig remains the persisted active account.

Vulnerability Details

At commit d5fc4d6572ebc2244e95009a33f5da5048935867, quantus_sdk/lib/src/chain/call_decoder.dart:572-581 handles Utility::if_else as follows:

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,
  );

describe(main) executes once for the primary field and again for the summary. If the attacker places the next if_else in main and uses an empty System::remark as each fallback, the invocation count is exactly 3·2^depth − 2, but the payload length is only 5·depth + 3 bytes. Every nearby recursive case avoids this problem by caching the child description and reusing it.

The attacker and victim must be members of the same ordinary, non-high-security multisig already tracked by the victim. The attacker uses the intended Multisig::propose permission withoyt needing threshold approval or privileged actor.

The frozen chain validates the call at proposal time. pallets/multisig/src/lib.rs:594-610 decodes it, obtains its dispatch weight, compares it with MaxInnerCallWeight, and applies the high-security policy.

My PoC below configures the published production ceiling and proves that a typed depth-40 if_else call is accepted and stored:

The production ceiling is Weight { ref_time: 1000000000000, proof_size: 2621440 }. The payload therefore fits both the 10,240-byte call-size limit and the dispatch-weight limit.

The wallet reaches the defect through this normal flow:

1

Watch open proposals

MultisigActivitySection watches multisigOpenProposalsProvider when the active account is a multisig.

2

Retrieve on-chain call data

MultisigService.getOpenProposals calls _withOnChainCallData.

3

Replace proposal data

_withOnChainCallData reads each proposal from Multisig.Proposals over RPC and replaces the indexer value with the authoritative stored bytes.

4

Evaluate the call summary

It evaluates withBytes.decodedCall?.summary synchronously.

5

Trigger recursive decoding

MultisigProposal.decodedCall invokes CallDecoder.decodeBytes, which reaches the duplicated describe(main) calls.

I couldn't find any decoder nesting limit nor isolate offload. The only scoped Isolate.spawn is used by the unrelated human-readable checksum service. A try/catch cannot interrupt the computation because the decoder neither throws nor yields.

The active display account is serialized to SharedPreferences by SettingsService.setActiveAccount. A normal proposal-notification tap also selects and persists the proposal’s multisig before loading its details. On the next launch, the home screen restores that active multisig, loads open proposals, and enters the same synchronous expansion.

The user can recover by clearing application data or reinstalling, or after the proposal stops being returned as open; the claim is persistent application unavailability across restarts, not irreversible loss of the wallet.

Impact Details

This appears in scope as "Taking down the application/website".

A malicious co-signer can remotely make the victim’s mobile wallet unusable using one small, valid on-chain proposal. Real execution against the frozen decoder took 15.024 seconds at depth 26 with only 133 bytes. A depth-40 payload contains 203 bytes and requires 3,298,534,883,326 describe invocations, so the UI isolate cannot complete the display operation in a usable time.

The attacker does not need specific preconditions like a compromised indexer, malicious RPC, MITM, physical device access, local-network access, or multisig threshold approval.

It applies to non-high-security multisigs and to victims who track and load the affected multisig; it does not automatically freeze unrelated accounts or every co-signer device.

Remediation

Cache the primary description and reuse it:

Also impose a conservative nesting limit before constructing the display tree and memoize MultisigProposal.decodedCall, so future recursive variants cannot cause stack or repeated-work denial of service.

References

Proof of Concept

The attached PoC uses isolated Git worktrees at the two frozen competition revisions. It does not contact a Quantus network, broadcast a transaction, or modify the supplied checkouts.

Requirements: Git, Flutter 3.47.0 with Dart 3.13.0, Rust/Cargo 1.93.0, and local clones containing apps commit d5fc4d6572ebc2244e95009a33f5da5048935867 and chain commit 3b243b870a5442a6e5f443056f67bbba87d472f5.

Run:

The script performs three complementary demonstrations:

1

Execute the real decoder

It adds ifelse_curve_test.dart only to a disposable apps worktree and executes the real CallDecoder.decodeBytes implementation at depths 18, 20, 22, 24, and 26.

Expected output shows approximately fourfold growth every two levels; the retained run reached 15,024 ms at depth 26 for 133 bytes.

2

Validate production metadata

It applies chain-production-metadata.patch only to the disposable chain worktree and runs an integration test against quantus-runtime.

The test asserts byte equality with the app’s [09, 06] payload, production decoding, the production 5,544,840,000 / 280,160 call weight, both configured ceilings, and the ordinary versus high-security policy result.

3

Submit the typed nesting shape

It applies chain-acceptance.patch to the same disposable chain worktree and submits the typed depth-40 nesting shape through the frozen pallet’s Multisig::propose implementation.

This separate mock-runtime test asserts that proposal creation succeeds and its exact 203 encoded bytes are stored; its weight output is not used as the production weight.

Decisive retained output:

poc_ifelse_bomb.py is an offline secondary control that constructs the same payload and verifies 3·2^depth − 2 invocations for every depth from 0 through 22. call_decoder_ifelse_bomb_test.dart contains small structural controls and a skipped depth-30 timeout case. flutter.log, production-metadata.log, chain.log, and SHA256SUMS preserve the executed receipts and bundle integrity.

PoC code

Run it with:

The diff to PoC

Was this helpful?