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