88483 w a high official multisig approve displays a zero value call and still signs a full vault transfer
Submitted on Aug 13th 2026 at 12:30:01 UTC by @Omisanin0 for Audit Comp | Quantus
Report ID: #88483
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
The official app refuses to decode a multisig inner call that has leftover SCALE bytes, then treats that failure as "not a transfer" and shows amount 0 with an empty destination. Confirm stays enabled. Approve resubmits the exact bytes sitting in chain storage. Those bytes are a real Balances.transfer_allow_death plus one trailing 0x00. The pallet prefix-decodes them and, after threshold, execute sends the full vault to the proposer. I ran this on production quantus-runtime and on the shipped Planck RuntimeCall codec. Vault went from 1000 UNIT to 0. Attacker net +998.98 UNIT.
Vulnerability Details
Two decoders disagree about the same blob, and the wallet takes the wrong side of that disagreement.
What the pallet does
Multisig.propose and Multisig.execute validate the inner call with SCALE Decode, not DecodeAll:
Decode stops when a complete RuntimeCall is on the wire. A trailing 0x00 is leftover and ignored for validation. The full BoundedVec, trailing byte included, is what gets written to Proposals.
approve does not re-decode for display. It requires a byte-for-byte match against storage (lib.rs:779-781). The comment above that check says the point of resubmitting the call is so a signer can decode and inspect it (lib.rs:722-726). That only works if the client that fetches those bytes can decode them.
The HS benchmark (benchmarking.rs:242-249) states the same contract in writing: trailing bytes are ignored on decode and retained in the stored BoundedVec. MaxCallSize is 10 KiB. One extra byte fits.
A clean encode of transfer_allow_death is 43 bytes. The same encode plus 0x00 is 44 bytes. Prefix decode of the 44-byte blob still yields dest = attacker and value = 1000 UNIT. DecodeAll of that blob fails with "Input buffer has still data left after decoding!"
Approving the 43-byte clean encode against a 44-byte stored proposal is CallMismatch. The victim cannot "fix" the call in the UI. The only bytes fetchProposalCallBytes will ever hand to approve are the padded ones.
This is Quantus's pallet, not unmodified Substrate as_multi.
What the official app does with those bytes
CallDecoder.decodeBytes is strict. After the generated Planck codec consumes a call, any remainder is a hard error:
The unit test at call_decoder_test.dart:292-297 pins that throw.
After an open proposal is loaded, MultisigService._withOnChainCallData replaces indexer bytes with chain storage and rebuilds amount/dest from decodedCall:
decodedCall catches the FormatException and returns null (multisig_proposal.dart:268-274). So a padded transfer that the indexer may already have decoded correctly is rewritten to amount 0 and dest "". Pallet/call names from the indexer are left as-is. decodeError is stored and never read by the mobile app.
The list tile (proposal_list_tile.dart:41-48) then formats amount == 0 with no recipient. The detail hero (multisig_proposal_detail_sheet.dart:688-701) does the same.
The approve sheet loads storage via fetchProposalCallBytes and tries decodeBytes again (multisig_action_confirm_sheet.dart:185-195). On throw it falls back to proposal.decodedCall, which is also null after the wipe. Headline becomes a name-only / empty fallback. Confirm is disabled only when the RPC load fails:
A decode throw is not _callBytesFailed. Approve stays on. The sheet comment says it "shows the caller exactly which call they are approving." It does not.
Submit then calls approve with those same storage bytes. Threshold is met. Anyone, including the attacker, can execute. Execute prefix-decodes and dispatches the transfer as Signed(M).
What this is not
It is not "the user approved a blank proposal, their problem." The app overwrites a correct indexer amount with zero, then asks for a signature on a payload it just refused to render.
It is not a Keystone / cold-wallet bug. QuantusPayloadParser.parsePayload runs describe on Multisig.approve, which calls decodeBytes on the inner call and throws. Cold signing fail-closes. The default software signer does not go through that parser.
It is not an HS-vault bug. Balances.transfer_* is rejected at propose when the multisig itself is high-security. The official create_multisig path does not auto-enroll HS. The attack is any non-HS vault with threshold >= 2. Official default for three signers is 2.
It is not phishing and it is not an out-of-band tap. The victim is in the normal "open proposal, Approve" flow.
Attack path
Attacker creates a padded transfer proposal
Attacker encodes Balances.transfer_allow_death { dest: attacker, value: B }, appends 0x00, submits Multisig.propose with any raw client (quantus-cli is documented for this). Official buildProposeTransferCall will not produce the padding; that is fine. The attacker is a signer.
Impact Details
Selected in-scope impacts (Websites and Applications):
Critical: Direct theft of user funds
Critical: Malicious interactions with an already-connected wallet, such as modifying transaction arguments or parameters
The wallet the victim already uses is the official app. The arguments they are shown (0, no dest) are not the arguments that execute (1000 UNIT to the attacker). That is the modify-parameters class. The vault loss is direct theft.
Local run on production quantus-runtime (no mock runtime):
Vault M
1000.000000000000 UNIT
0
Attacker
999.400000000000 UNIT (after 0.6 UNIT create fee)
1998.380000000000 UNIT
Victim
1000.000000000000 UNIT
1000.000000000000 UNIT
Vault lost 1000 UNIT. Attacker net +998.98 UNIT (1000 steal, minus 1.02 UNIT propose fee, plus 1.00 UNIT deposit refund). Victim paid nothing.
Same 44-byte blob through the official generated codec: FormatException: 1 trailing bytes after nested call. Wipe result: amount 0, dest empty. Confirm blocked = false.
Pre-deployment, there is no live token price. Funds at risk are the free balance of every non-HS multisig with threshold at least 2 where a remaining signer uses the official approve sheet. That includes any 2-of-3 treasury-style vault that is not HS. The PoC number is 1000 UNIT in one vault.
A later runtime upgrade can change the pallet. It cannot un-steal a transfer that already executed.
References
pallets/multisig/src/lib.rspropose decode 599-600, store 677-682, approve match 722-726 and 779-781, execute decode 1125-1126pallets/multisig/src/benchmarking.rs242-249 (trailing retained)runtime/src/configs/mod.rs648-651 (MaxCallSize,ProposalFee1 UNIT,ProposalDeposit1 UNIT)quantus_sdk/lib/src/chain/call_decoder.dart52-59quantus_sdk/test/chain/call_decoder_test.dart292-297quantus_sdk/lib/src/services/multisig_service.dart315-320,fetchProposalCallBytes404-412quantus_sdk/lib/src/models/multisig_proposal.dart268-274mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart185-195, 361-362mobile-app/lib/v2/screens/multisig/multisig_approve_confirm_sheet.dart38-42mobile-app/lib/v2/screens/multisig/multisig_proposal_detail_sheet.dart688-701Cold contrast:
quantus_sdk/lib/src/quantus_payload_parser.dart, test "rejects a multisig proposal whose inner call cannot be decoded"
Fix that closes the hole on the wallet side: treat decodeBytes(chainBytes) failure as _callBytesFailed and refuse confirm. Do not wipe a known indexer amount into a silent 0. Optional pallet hardening: DecodeAll / consume the full buffer on propose and execute.
Proof of Concept
Local only. Production quantus-runtime and the official generated Planck RuntimeCall codec. No pallets/*/src/mock.rs. No public network.
Needs the chain Rust toolchain and Dart >= 3.8 (brew install dart-sdk). The decoder package symlinks quantus_sdk/lib/generated.
Steps
Run output (this tree)
Chain:
Decoder (same hex from poc/F-1301/out/artifact.json):
Chain program
src/chain/runtime/examples/poc_f1301_chain.rs
Decoder program
poc/F-1301/decoder/bin/poc.dart (uses quantus_sdk/lib/generated via symlink)
poc/F-1301/decoder/pubspec.yaml:
lib/generated is a symlink to quantus_sdk/lib/generated.
Was this helpful?