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

90351 w a high the multisig approval screen signs call bytes it could not decode showing the signer a call name and no parameters

Submitted on Aug 24th 2026 at 15:53:56 UTC by @gzeon for Audit Comp | Quantus

  • Report ID: #90351

  • Report Type: Websites & Apps

  • Report severity: High

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

  • Impacts:

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

      • Modifying transaction arguments or parameters

      • Substituting contract addresses

      • Submitting malicious transactions

Description

Brief/Intro

MultisigActionConfirmSheet fetches the authoritative proposal bytes from Multisig.Proposals before an approval, which is the right thing to do. If those bytes then fail to decode it catches the exception, renders a headline built from the indexer's pallet and call names with no parameters at all, and leaves the confirm button live, because the flag that gates the button is set only when the loader throws. The signer approves bytes their wallet declined to read.

Two things reach that state. The runtime declares eighteen pallets and the bundled codec has thirteen cases, one of which is for a pallet the runtime has removed, so six of the runtime's pallets are undecodable, including Vesting at 22. More sharply, the chain's RuntimeCall::decode reads a call's prefix and ignores what follows, while the wallet's decoder rejects a single trailing byte outright, so appending one junk byte to any call at all produces bytes the chain stores, approves and executes and the wallet cannot render. The second trigger needs no gap in the bindings and works on any multisig, including a user's own.

A malicious co-signer proposes such a call. The victim's wallet shows them two words under the sentence "You are about to approve a transfer of", with no destination and no amount, and their approval carries it to threshold.

Vulnerability Details

The screen's stated contract

The bytes do come from the chain: fetchProposalCallBytes queries Multisig.Proposals and returns stored.call (quantus_sdk/lib/src/services/multisig_service.dart:409, :412). Showing the caller exactly which call they are approving is the half that stops.

A decode failure never closes the gate

widget.proposal.decodedCall runs CallDecoder.decodeBytes over the indexer's copy of the same call (quantus_sdk/lib/src/models/multisig_proposal.dart:146, :270) and swallows its own failure the same way (:272), so the getter returns null.

The only assignment that would disable the button lives in the loader's catch block:

The load succeeded, so _callBytesFailed stays false and blocked stays false. The stated intent, "never confirm an action whose call bytes were meant to load but did not", covers a load failure and not a decode failure.

This sheet is shared. multisig_approve_confirm_sheet.dart:23, multisig_cancel_confirm_sheet.dart:19 and multisig_execute_confirm_sheet.dart:18 all construct MultisigActionConfirmSheet with a loadCallBytes, so approve, cancel and execute inherit the same getter, the same flag and the same gate. The signer who executes the proposal, the step that dispatches the call, is shown no more than the signer who approved it.

What the signer sees instead

With _decodedProposalCall null, build falls through to _fallbackTransferCall:

pallet and call are indexer strings (multisig_proposal.dart:125, :126), fields is empty by construction, and the parameter view is skipped when fields are empty (multisig_action_confirm_sheet.dart:386). No warning is rendered, because the only error text is gated on _callBytesFailed (:382). The app is not short of a signal either: it asks the indexer for decode_error on every proposal row (quantus_sdk/lib/src/services/multisig_graphql.dart:209) and parses it into MultisigProposal.decodeError (quantus_sdk/lib/src/models/multisig_proposal.dart:138), and no screen reads that field.

The sheet's own copy completes the deception. multisigApproveConfirmBody is "You are about to approve a transfer of" (mobile-app/lib/l10n/app_en.arb:958), a sentence written to be finished by the headline underneath it. The PoC captures every string the sheet renders over a 500 000 QUAN transfer the signer is about to approve:

No destination, no amount, and a live confirm button. The only number on the screen is the fee.

Trigger A: a pallet the bindings do not carry

RuntimeCall._decode has cases for pallet indices 0, 1, 2, 7, 9, 11, 13, 14, 15, 16, 17, 19 and 20 (quantus_sdk/lib/generated/planck/types/quantus_runtime/runtime_call.dart:107 to :132). The runtime declares eighteen pallets at indices 0, 1, 2, 3, 5, 6, 7, 8, 9, 11, 13, 14, 15, 16, 19, 20, 21 and 22, so Scheduler, ZkTree and Vesting are among the six with no case (runtime/src/lib.rs:283, :286). One of the codec's thirteen cases, Assets at 17, is for a pallet the runtime has removed. It states its own origin, specVersion: 136 (quantus_sdk/lib/generated/planck/pallets/system.dart:767), against the runtime's spec_version: 143 (runtime/src/lib.rs:79).

Vesting is the sharpest instance. Its admin calls are gated on EitherOfDiverse<EnsureRoot<AccountId>, EnsureTreasury> (runtime/src/configs/mod.rs:630), and EnsureTreasury accepts a signed origin equal to the treasury account (:608, :612). The treasury is a 2-of-3 multisig (runtime/src/genesis_config_presets.rs:454), and pallet-multisig dispatches an executed proposal as RawOrigin::Signed(multisig_address) (pallets/multisig/src/lib.rs:1156). The pallet the wallet cannot read is the pallet the treasury multisig exists to drive.

Trigger B: one trailing byte, with the bindings fully current

The chain and the wallet disagree about trailing bytes. Multisig::propose decodes with SCALE's decode, which reads the call's prefix and leaves the rest in the buffer:

The wallet's decoder treats the same bytes as a hard error:

Multisig::execute decodes the stored bytes the same prefix-tolerant way (pallets/multisig/src/lib.rs:1125), and neither call site uses decode_all, so nothing in the pallet ever objects to the tail.

So a proposer appends one junk byte to an ordinary Balances::transfer_allow_death. The chain accepts the proposal, stores the 45 bytes verbatim, and executes the 44-byte call inside them. Multisig::approve requires call == proposal.call (pallets/multisig/src/lib.rs:780), so the victim's wallet fetches those exact 45 bytes and resubmits them, which is what makes the byte-equality check work against the victim rather than for them. The decoder throws on the trailing byte, the sheet swallows it, and a transfer the bindings decode perfectly well is rendered as "Transfer allow death / Balances" with no destination and no amount. PoC 2 case B shows the same bytes minus the last byte decoding into a full summary.

The indexer cannot rescue the victim either. Its multisig_proposal row carries transfer_amount and transferTo (quantus_sdk/lib/src/services/multisig_graphql.dart:202, :213), so an indexer that reads a direct transfer server-side could still populate the headline. Wrapping the same transfer in Utility::batch removes that: a batch is not a direct transfer, the columns stay empty, and the sheet renders "Batch / Utility". PoC 2 case B2 does exactly that over a 250 000 QUAN transfer, and shows the same bytes minus the trailing byte decoding into a two-field Utility.batch.

This trigger does not depend on the codec's pallet coverage, on the runtime's spec_version, or on the treasury. It applies to every call and every multisig, so a personal 2-of-3 with no privileged role is exposed to it.

The same SDK refuses this on the hardware path

One decoder serves both surfaces. The approval sheet calls CallDecoder.decodeBytes (multisig_action_confirm_sheet.dart:189), the signing path calls CallDecoder.describe (quantus_sdk/lib/src/quantus_payload_parser.dart:159), and both reach the same _multisig branch, which recurses into the call being approved:

That decodeBytes is unguarded, so a Keystone signer scanning the same approval gets a FormatException out of QuantusPayloadParser.parsePayload (quantus_sdk/lib/src/quantus_payload_parser.dart:119) and the cold wallet refuses to sign (cold-wallet-app/lib/screens/sign_transaction_screen.dart:48). The project's own tests pin both halves: approve must expose the inner call being approved (quantus_sdk/test/chain/call_decoder_test.dart:121), and an unparseable payload is "never signed" (cold-wallet-app/test/transaction_signing_test.dart:117).

The mobile approval sheet is the one signing surface that catches that exception and continues.

Not fixed, and not fixed upstream either

2293f20, "fix: multisig approve must carry the proposal's inner call" (#585, 2026-07-27), introduced both halves: the authoritative loadCallBytes fetch and the _callBytesFailed flag that gates only on a load failure. The in-scope asset is frozen at d5fc4d6 and refs/heads/audit-comp-ready still resolves to it.

The public upstream repository is worth stating explicitly, because it has moved and does not fix this. Quantus-Network/quantus-apps PR #610, "fix: take the wormhole volume fee from the shipped runtime metadata", merged 2026-08-21 as 8fd9021, regenerated the bindings as a side effect and added case 22 for Vesting. It does not touch multisig_action_confirm_sheet.dart: at that commit the file's _decodedProposalCall, _fallbackTransferCall and blocked are byte-identical to the in-scope copy, differing only in imports, a text style and a spacer. The regenerated bindings still decode thirteen of the runtime's eighteen pallet indices, leaving 3, 5, 6, 8 and 21 undecodable, and trigger B never depended on the bindings at all.

Preconditions

  • The attacker is one signer of a multisig the victim also signs, and crafts the proposal outside the app. For trigger B that is any multisig; for trigger A the treasury multisig is what makes Vesting reachable.

  • For trigger A only: the treasury multisig has been created post-genesis and funded. No preset seeds Multisigs storage, and the shipped Planck preset says why: the treasury is derive_multisig_address(&planck_treasury_signers(), 2, 0) (runtime/src/genesis_config_presets.rs:454), each signer is seeded "enough to create + propose on the treasury multisig once" (:560), and the vesting pot gets its ED buffer "so create_schedule works post-genesis" (:571).

  • The deceived co-signer approves from the mobile app with a local software signer, the path PoC 2 exercises. A Keystone signer takes a different branch (multisig_action_confirm_sheet.dart:259) and the cold wallet refuses the payload.

  • No hostile indexer is required. An honest one supplies the true pallet and call names and nothing else; one that could not read the call either leaves the headline blank, which PoC 2 case A2 shows.

Impact Details

(Websites & Applications) Malicious interactions with an already-connected wallet, such as: modifying transaction arguments or parameters; submitting malicious transactions.

The co-signer's connected wallet builds and submits a real Multisig::approve over bytes it never displayed, under a line of copy telling them they are approving a transfer. For Balances::transfer_allow_death(dest, value) neither the destination nor the value is on screen; for Vesting::create_schedule(beneficiary, start, cliff, end, total) neither the beneficiary nor the amount is. The one number the signer sees is the 0.001 QUAN network fee.

A single signer on a 2-of-3 has authority to propose, not to spend. The screen is what carries their proposal past a second signer who never agreed to it.

Trigger B bounds the loss at the balance of whatever multisig the attacker co-signs, and reaches ordinary user multisigs. Trigger A bounds it at the treasury balance, since create_schedule transfers total out of the treasury with Preservation::Preserve, subject only to total >= MinimumPayout, total % PayoutQuantum == 0, and the treasury keeping its existential deposit (pallets/vesting/src/lib.rs:522). The treasury takes 50 percent of every block reward (runtime/src/genesis_config_presets.rs:575), and the reward is remaining supply over an EmissionDivisor of 15 163 560 (runtime/src/configs/mod.rs:144) against a 21 000 000 QUAN cap (runtime/src/lib.rs:105) on 12-second blocks (runtime/src/lib.rs:87), so early in the chain's life the treasury accrues roughly 4 986 QUAN per day. A schedule whose dates are already past is fully vested on arrival, and claim is documented permissionless with the payout always going to the stored beneficiary (pallets/vesting/src/lib.rs:326), so any funded account finishes the theft; PoC 1 uses an unrelated one.

PoC 1 moves 1 000 000 QUAN out of a 2 000 000 QUAN treasury through trigger A, 500 000 QUAN through trigger B, and 250 000 QUAN through the batch form of trigger B.

References

  • mobile-app/lib/v2/screens/multisig/multisig_approve_confirm_sheet.dart:38, :41 the loader and its stated contract

  • multisig_approve_confirm_sheet.dart:23, multisig_cancel_confirm_sheet.dart:19, multisig_execute_confirm_sheet.dart:18 the three sheets sharing the widget

  • mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart:185, :189 the decode and the swallowed exception

  • mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart:174 the sole _callBytesFailed = true

  • mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart:199 _fallbackTransferCall

  • mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart:353, :354, :362, :406, :407 the headline, the gate, the button

  • mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart:382, :386 the warning and the parameter view, both skipped

  • mobile-app/lib/l10n/app_en.arb:958 the sheet's body copy, "You are about to approve a transfer of"

  • quantus_sdk/lib/src/services/multisig_service.dart:409, :412 the authoritative fetch

  • quantus_sdk/lib/src/services/multisig_graphql.dart:202, :209, :213 the indexer's transfer columns and the decode_error field the app never reads

  • quantus_sdk/lib/src/models/multisig_proposal.dart:138 where decode_error is parsed and then unused

  • quantus_sdk/lib/src/models/multisig_proposal.dart:125, :126, :146, :270, :272 indexer pallet and call, and the getter that fails the same way

  • quantus_sdk/lib/src/chain/call_decoder.dart:50, :57, :339 the decoder's display policy, the trailing-byte rejection, and the unguarded recursion into an approve's inner call

  • quantus_sdk/lib/src/quantus_payload_parser.dart:119, :130, :137, :142, :159 the fail-closed signing decoder in the same codebase

  • quantus_sdk/test/chain/call_decoder_test.dart:121, cold-wallet-app/test/transaction_signing_test.dart:117 the project's own tests for the correct behaviour

  • quantus_sdk/lib/generated/planck/types/quantus_runtime/runtime_call.dart:107 to :132 the decoded pallet indices

  • quantus_sdk/lib/generated/planck/pallets/system.dart:767 and runtime/src/lib.rs:79 codec spec 136 against runtime spec 143

  • runtime/src/lib.rs:283, :286 ZkTree at 21 and Vesting at 22

  • runtime/src/configs/mod.rs:144, :608, :612, :630, :648 the emission divisor, EnsureTreasury, the vesting AdminOrigin, and MaxCallSize

  • runtime/src/genesis_config_presets.rs:454, :560, :571, :575 the Planck treasury multisig, the signer funding, the pot buffer, and the treasury portion

  • pallets/multisig/src/lib.rs:557, :599, :615, :780, :1125, :1156 signer check, the prefix-tolerant decode on propose and again on execute, high-security classification, byte-equality on approve, dispatch origin

  • pallets/vesting/src/lib.rs:326, :331, :376, :500, :522 the permissionless claim and its stored beneficiary, treasury funding, the past-end branch, and the absent future-date check

Proof of Concept

Two runnable tests. PoC 1 executes every payload on the chain and prints the balances that move. PoC 2 pumps the shipped approval sheet over the same bytes. The hex strings are identical across the two, so what the wallet declines to display is what the chain executes.

PoC 1: the chain executes both payloads

1

Clone the repository

2

Add the tests

Append this to the existing mod tests block in runtime/tests/governance/vesting.rs, and widen that file's use codec::Encode; to use codec::{Decode, Encode};. It reuses the file's own helpers, which already model the shipped treasury: treasury_multisig() is derive_multisig_address(&[account(1), account(2), account(3)], 2, 0), and propose_approve_execute runs Multisig::propose by signer 1, Multisig::approve by signer 2, Multisig::execute by signer 3. Signer 2's approval is the step the mobile sheet renders.

3

Run the tests

Output:

PoC 2: the wallet renders those bytes with a live confirm button and no parameters

1

Clone and prepare the application

2

Write the test

Write mobile-app/test/unit/multisig_blind_approval_poc_test.dart. It pumps the shipped MultisigActionConfirmSheet with the labels showMultisigApproveConfirmSheet passes it (multisig_approve_confirm_sheet.dart:23) and stubs for the fee, build and submit callbacks; makeAccount and makeMultisigAccount come from the project's own test/fakes.dart. Signer 1 proposed, so the sheet acts for signer 2, the co-signer whose approval reaches threshold.

3

Run the test

Output:

[PoC] could not decode stored proposal call is the shipped quantusPrint at multisig_action_confirm_sheet.dart:191. That log line is the whole consequence of the decode failure: the sheet finishes building and the button stays wired to _confirm.

Cases B and B2 are the ones to read first. Each prints what the same bytes decode to with the last byte removed: a Balances.transfer_allow_death carrying a summary, and a two-field Utility.batch. Both are calls the bundled bindings handle correctly. One 0xFF is the difference between a transfer the signer can inspect and two words they cannot.

Suggested fix

Decode during _prepare, where the state can be set safely, and fail closed on a decode failure exactly as the screen already fails closed on a load failure. Setting state from inside a getter that build calls would risk a setState during build.

Two changes belong with it. Once _callBytes has loaded, drop the indexer fallback, so a description shown next to a signature always derives from the bytes being signed. And refuse to sign when the chain's spec_version is not one the bundled codec was generated against, which closes trigger A for every future runtime upgrade rather than leaving it to reappear with the next new pallet.

Trigger B is worth fixing on the chain as well. Multisig::propose can reject a call with bytes left over, the way the wallet's decoder already does, so a proposal's stored bytes are always exactly the call it dispatches:

The behaviour to copy on the app side is already in the SDK. QuantusPayloadParser.parsePayload throws a FormatException when the call does not decode, when bytes trail the payload, when the metadata-hash mode is inconsistent, and when the genesis hash is unknown (quantus_sdk/lib/src/quantus_payload_parser.dart:130, :137, :142, :159).

Was this helpful?