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
Vestingreachable.For trigger A only: the treasury multisig has been created post-genesis and funded. No preset seeds
Multisigsstorage, and the shipped Planck preset says why: the treasury isderive_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 "socreate_scheduleworks 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,:41the loader and its stated contractmultisig_approve_confirm_sheet.dart:23,multisig_cancel_confirm_sheet.dart:19,multisig_execute_confirm_sheet.dart:18the three sheets sharing the widgetmobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart:185,:189the decode and the swallowed exceptionmobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart:174the sole_callBytesFailed = truemobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart:199_fallbackTransferCallmobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart:353,:354,:362,:406,:407the headline, the gate, the buttonmobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart:382,:386the warning and the parameter view, both skippedmobile-app/lib/l10n/app_en.arb:958the sheet's body copy, "You are about to approve a transfer of"quantus_sdk/lib/src/services/multisig_service.dart:409,:412the authoritative fetchquantus_sdk/lib/src/services/multisig_graphql.dart:202,:209,:213the indexer's transfer columns and thedecode_errorfield the app never readsquantus_sdk/lib/src/models/multisig_proposal.dart:138wheredecode_erroris parsed and then unusedquantus_sdk/lib/src/models/multisig_proposal.dart:125,:126,:146,:270,:272indexer pallet and call, and the getter that fails the same wayquantus_sdk/lib/src/chain/call_decoder.dart:50,:57,:339the decoder's display policy, the trailing-byte rejection, and the unguarded recursion into an approve's inner callquantus_sdk/lib/src/quantus_payload_parser.dart:119,:130,:137,:142,:159the fail-closed signing decoder in the same codebasequantus_sdk/test/chain/call_decoder_test.dart:121,cold-wallet-app/test/transaction_signing_test.dart:117the project's own tests for the correct behaviourquantus_sdk/lib/generated/planck/types/quantus_runtime/runtime_call.dart:107to:132the decoded pallet indicesquantus_sdk/lib/generated/planck/pallets/system.dart:767andruntime/src/lib.rs:79codec spec 136 against runtime spec 143runtime/src/lib.rs:283,:286ZkTreeat 21 andVestingat 22runtime/src/configs/mod.rs:144,:608,:612,:630,:648the emission divisor,EnsureTreasury, the vestingAdminOrigin, andMaxCallSizeruntime/src/genesis_config_presets.rs:454,:560,:571,:575the Planck treasury multisig, the signer funding, the pot buffer, and the treasury portionpallets/multisig/src/lib.rs:557,:599,:615,:780,:1125,:1156signer check, the prefix-tolerant decode on propose and again on execute, high-security classification, byte-equality on approve, dispatch originpallets/vesting/src/lib.rs:326,:331,:376,:500,:522the 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
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.
PoC 2: the wallet renders those bytes with a live confirm button and no parameters
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.
[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?