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_deathwhose 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@3b243b870a5442a6e5f443056f67bbba87d472f5Apps —
quantus-apps@d5fc4d6572ebc2244e95009a33f5da5048935867
Affected code:
mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart— decode failure caught and discarded;blockedomits it;DecodedCallViewand the warning both suppressedquantus_sdk/lib/generated/planck/types/quantus_runtime/runtime_call.dart— generated codec missing six runtime pallet indicesquantus_sdk/lib/src/chain/call_decoder.dart#L52-L59— strict trailing-byte rejectionquantus_sdk/lib/src/services/multisig_service.dart#L302-L320— clears recipient and amount on undecodable bytesquantus_sdk/lib/src/models/multisig_proposal.dart— indexer-sourced display fields;decodedCalldocumented as display-onlypallets/multisig/src/lib.rs—proposeandexecutenon-strictRuntimeCall::decoderuntime/src/lib.rs—#[runtime::pallet_index(22)] pub type Vesting = pallet_vesting;pallets/utility/src/lib.rs—batchpreserves the effects of calls preceding an interruption
Specification:
parity_scale_codec::Decodereads from the front of the input and does not require full consumption, which is the basis of trigger 2.
Link to Proof of Concept
https://gist.github.com/vendrell46/ee18c0a722970889b46fe373c7626266
Proof of Concept
PoC
Frozen revisions:
quantus-chain@3b243b870a5442a6e5f443056f67bbba87d472f5quantus-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
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.)
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.
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.
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?