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

88667 w a high unhandled call decode failure in the multisig approval sheet leads to direct theft of user funds

Submitted on Aug 14th 2026 at 12:31:16 UTC by @zealynx for Audit Comp | Quantus

  • Report ID: #88667

  • Report Type: Websites & Apps

  • Report severity: High

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

  • Impacts:

    • Direct theft of user funds

    • Malicious interactions with an already-connected wallet, such as:

      • Modifying transaction arguments or parameters

      • Substituting contract addresses

      • Submitting malicious transactions

Description

Brief/Intro

The Quantus mobile wallet asks a multisig signer to approve a proposal whose contents it was unable to read, without telling them. The approval sheet fetches the proposal's authoritative call bytes from chain storage, fails to decode them, silently falls back to labels supplied by the off-chain indexer, and leaves the confirm button enabled with no warning and no parameter list. A malicious member of an ordinary user multisig can exploit this to obtain an uninformed co-signer approval for a transfer the co-signer never saw, and on execution the multisig's funds move to an attacker-controlled address. In production this is direct theft of user funds from any multisig the attacker is a member of, requiring no privileged account, no leaked key, and no compromised infrastructure.

Vulnerability Details

The unhandled failure path

MultisigActionConfirmSheet has two distinct failure modes for the proposal's inner call and handles only one of them.

A load failure sets _callBytesFailed, which blocks confirmation and renders a warning:

A decode failure of successfully-loaded bytes sets nothing. It is caught while building the display and discarded to a debug log:

_callBytesFailed remains false, so blocked is false and the confirm button stays enabled. The stated intent in the code — "Never confirm an action whose call bytes were meant to load but did not" — is not enforced for this path.

Confirmation then submits _callBytes, the authoritative chain bytes, which the chain compares byte-for-byte against what it stored. The signer therefore approves the real call while the screen described a different source.

Display and signature come from different sources

The fallback resolves to MultisigProposal.decodedCall, which decodes callRaw — bytes supplied by the off-chain indexer, not the chain. The model's own documentation states the boundary:

Display only — an approval must resubmit the bytes from chain storage.

Every display field originates from the indexer record, and the declared transfer fields take priority over decoding the call at all:

The chain bytes and the indexer bytes are never compared, and no call hash is rendered, so the signer has no manual path to detect the divergence.

Trigger 1 — generated codec cannot decode six in-use pallets

The Dart RuntimeCall codec decodes pallet indices 0,1,2,7,9,11,13,14,15,16,17,19,20. The runtime defines 0,1,2,3,5,6,7,8,9,11,13,14,15,16,19,20,21,22. Six in-use pallets cannot be decoded — 3 TransactionPayment, 5 QPoW, 6 MiningRewards, 8 Scheduler, 21 ZkTree, 22 Vesting — and the codec additionally carries an index 17 the runtime no longer defines.

Utility::batch recursively decodes each item with that same codec, so a batch containing any of the six aborts decoding in the app while remaining fully valid on chain. batch preserves the effects of calls that precede an interrupted one, so a batch of [Balances::transfer_allow_death(attacker, amount), <undecodable call>] transfers the funds and then interrupts.

Trigger 2 — chain and app disagree on trailing bytes

This trigger is independent of codec freshness and survives regenerating the bindings.

Both propose and execute validate the inner call with:

That is parity_scale_codec::Decode: it reads from the front and never requires the input to be fully consumed. Neither path checks for a remainder. The mobile decoder does the opposite:

Appending one junk byte to an otherwise ordinary, fully decodable call therefore makes the chain accept, store and dispatch it normally while the app is guaranteed to fail decoding. This reaches the same undisclosed-approval state on any call, on any runtime, with no dependence on the generated bindings.

What the approving signer sees

Stated precisely, because it bounds the claim. The sheet does not render a false benign transfer — it renders nothing: the indexer's pallet and call labels with no parameter list, no transfer summary, and no warning. DecodedCallView is skipped because the fallback carries fields: const [], and the multisigProposalCallUnavailable warning is gated on _callBytesFailed, which a decode failure never sets. MultisigService has already cleared recipient and amount when the authoritative bytes cannot be decoded, so those fields are absent from the proposal view as well.

The prompt is indistinguishable from a normally rendered proposal that simply has no displayable parameters. Nothing on this screen, or anywhere else in the app, discloses that the authoritative bytes could not be read.

Impact Details

Direct theft of user funds. A malicious member of an ordinary user multisig obtains an uninformed co-signer approval and moves funds to an address they control.

  • Amount at risk: attacker-chosen, bounded only by the multisig's balance. The proposed inner call is an ordinary Balances::transfer_allow_death whose value the attacker sets; the demonstrated case moves 100 QUAN from a 500-QUAN multisig.

  • Who bears the loss: every signer of the multisig, proportionally to its holdings. The funds are gone on execution; nothing in the flow is reversible.

  • Attacker prerequisites: membership of the target multisig and one deceived co-signer. No privileged origin, no Root or sudo, no leaked key, no compromised server, no runtime upgrade. At threshold 2-of-3 a single uninformed approval suffices; a threshold of N requires N−1.

  • Reachability: the standard approval flow. The victim opens the proposal the way they open every other one and is shown an approvable prompt.

  • Persistence: trigger 1 is live for six in-use pallets today; trigger 2 is structural and recurs on every future runtime for every call, so the exposure does not lapse with a metadata refresh.

This matches the selected in-scope impacts "Direct theft of user funds" and "Malicious interactions with an already-connected wallet — Submitting malicious transactions": the victim's connected wallet submits a transaction whose arguments were withheld from them.

References

Frozen revisions:

  • Chain — quantus-chain@3b243b870a5442a6e5f443056f67bbba87d472f5

  • Apps — quantus-apps@d5fc4d6572ebc2244e95009a33f5da5048935867

Affected code:

  • mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart — decode failure caught and discarded; blocked omits it; DecodedCallView and the warning both suppressed

  • quantus_sdk/lib/generated/planck/types/quantus_runtime/runtime_call.dart — generated codec missing six runtime pallet indices

  • quantus_sdk/lib/src/chain/call_decoder.dart#L52-L59 — strict trailing-byte rejection

  • quantus_sdk/lib/src/services/multisig_service.dart#L302-L320 — clears recipient and amount on undecodable bytes

  • quantus_sdk/lib/src/models/multisig_proposal.dart — indexer-sourced display fields; decodedCall documented as display-only

  • pallets/multisig/src/lib.rs — propose and execute non-strict RuntimeCall::decode

  • runtime/src/lib.rs — #[runtime::pallet_index(22)] pub type Vesting = pallet_vesting;

  • pallets/utility/src/lib.rs — batch preserves the effects of calls preceding an interruption

Specification:

  • parity_scale_codec::Decode reads from the front of the input and does not require full consumption, which is the basis of trigger 2.

https://gist.github.com/vendrell46/ee18c0a722970889b46fe373c7626266

Proof of Concept

PoC

Frozen revisions:

  • quantus-chain@3b243b870a5442a6e5f443056f67bbba87d472f5

  • quantus-apps@d5fc4d6572ebc2244e95009a33f5da5048935867

Environment and dependencies

Everything below runs locally. No test was performed against mainnet or any public testnet.

Chain half requires: a Rust toolchain matching the repo's rust-toolchain, and the environment variable SKIP_WASM_BUILD=1 (native-only test build). No RPC endpoint, no fork, no external service. Tests are added to the existing runtime/tests/governance/vesting.rs, which already provides the account(), signers(), treasury_multisig() and new_test_ext() helpers used below.

Mobile half requires: Flutter stable (verified in ghcr.io/cirruslabs/flutter:stable), an empty mobile-app/.env file (touch mobile-app/.env — the project's own CI does the same), and flutter pub get in both quantus_sdk/ and mobile-app/. The test uses the repository's existing mobile-app/test/fakes.dart helpers. No device or emulator is needed; it is a widget test.

Step-by-step

1

Create the multisig setup

The attacker is an ordinary signer of a 2-of-3 multisig holding funds. The victim is another ordinary signer. Neither is privileged; no key is leaked.

2

Build the hidden transfer batch

The attacker builds Utility::batch([Balances::transfer_allow_death(attacker, 100 QUAN), Vesting::claim(u64::MAX)]).

The transfer is valid for the multisig origin. The trailing Vesting call exists only because it sits at runtime pallet index 22, which the mobile app's generated codec cannot decode.

(Equivalent trigger, independent of the codec: take any ordinary valid call and append one junk byte — see the trailing-byte section below.)

3

Propose the call

The attacker calls Multisig::propose(multisig, encoded_call, expiry). The chain decodes the call successfully — every pallet in it is real — and stores the exact bytes.

4

Open the approval sheet

The victim opens the proposal in the app and reaches the approval sheet.

The sheet fetches the authoritative call bytes from chain storage. This succeeds, so _callBytesFailed stays false.

5

Trigger the decode failure

CallDecoder.decodeBytes throws Invalid variant index: "22". The exception is caught and written to a debug log only.

The sheet falls back to the indexer's labels and renders Utility / batch with no parameter list, no recipient, no amount and no warning. The confirm button is enabled, because blocked never consulted the decode result.

6

Approve the undisclosed call

The victim confirms. The app submits the chain bytes verbatim through Multisig::approve, so the approval is byte-exact against what the chain stored and counts toward the threshold.

7

Execute the batch

With the threshold met, any signer calls Multisig::execute.

The chain dispatches the batch. Balances::transfer_allow_death succeeds and moves 100 QUAN to the attacker. Vesting::claim(u64::MAX) then fails and interrupts the batch — but Utility::batch does not revert calls that already succeeded.

8

Result

The multisig is down 100 QUAN, the attacker's address is up 100 QUAN, and the approving signer was never shown the transfer, its amount, or its recipient.

Chain half

Apply this patch:

Run:

The test creates and funds an ordinary two-of-three multisig, proposes and approves the nested call with ordinary signers, executes it, and asserts the 100 QUAN loss and recipient gain.

Observed output:

Baseline:

Chain half — trailing-byte trigger

Independent of the stale bindings. Same frozen revision. Add to runtime/tests/governance/vesting.rs:

Run:

Observed output:

The control and the attack assert in the same test: a clean encoding leaves no remainder, the poisoned encoding leaves one byte and is still proposed, approved and dispatched in full.

Mobile half

Create mobile-app/test/unit/multisig_nested_unknown_blind_approval_test.dart with:

Then run:

Observed output:

The exact bytes printed by the chain test are the bytes loaded by the widget test. The test asserts that no warning or decoded parameter tree is shown and that approval remains enabled.

Fix verified

Apply:

Re-run the unchanged mobile PoC. It fails at the harm assertion because the decode warning is now rendered and approval is disabled:

A sweep over all four current Vesting variants then passes, and a supported Balances transfer still renders its parameters and remains confirmable.

Was this helpful?