88274 w a medium with hide balance enabled a utility batch multisig proposal shows no transfer amount anywhere on the approval sheet while the same transfer unwrapped still shows it
Submitted on Aug 12th 2026 at 14:53:28 UTC by @skydev for Audit Comp | Quantus
Report ID: #88274
Report Type: Websites & Apps
Report severity: Medium
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
In the frozen Quantus mobile application, the multisig approval sheet shows the exact amount of a direct Balances.transfer_allow_death proposal even when Hide balance is enabled. Wrapping the same transfer in a one-item Utility.batch removes the amount from that sheet entirely: no headline figure, and the nested amount renders as -----. Destination, call and the enabled Approve control remain.
The cause is one function. Utility.batch is the only call wrapper in the decoder that does not carry its inner call's TransferSummary upward; six siblings — including two in the same _utility switch — do. A co-signer therefore supplies the second signature of a threshold multisig without the amount ever appearing on the screen that requests it, and the authentic stored call then moves that amount out of the multisig account.
Vulnerability Details
One wrapper in its class drops the summary
DecodedCall carries an optional TransferSummary. When present, the approval sheet renders it as a headline with the amount. Every wrapper that dispatches its inner call under the signer's own origin lifts that summary through — except one:
Wrapper
call_decoder.dart
carries summary
Multisig (two arms)
:337, :352
yes
Utility.as_derivative
:555
yes
Utility.with_weight
:570
yes
Utility.if_else
:580
yes
Utility.dispatch_as
:607
yes
Recovery
:619
yes
Utility.batch / batch_all / force_batch
:587-595
no
as_derivative and with_weight sit in the same switch as batch, eleven and eighteen lines away, and both pass summary: inner.summary. batch is the only member of that class that does not.
Two further sites build a NestedCallField without a summary — _boundedProposalField (:884-887, reached from Referenda.submit) and _preimageBytesField (:910-912, from Preimage.note_preimage, whose own note reads "Noted for later dispatch by a referendum"). Neither dispatches under the signer's origin: both queue bytes for governance to dispatch later, so neither is in the class above. They inherit the same rendering gap.
The project's own test suite records the expectation. In one group in quantus_sdk/test/chain/call_decoder_test.dart, test('recovery.as_recovered lifts the wrapped transfer summary') at :277-287 asserts expect(decoded.summary?.amount, oneToken), while test('utility.batch_all expands every inner call') at :261-275 asserts the call name, the child count and both child call names, and never mentions the summary. No test anywhere asserts that a batch has no summary.
The file that renders the result documents the intent (decoded_call_view.dart:11-14): "Multisig proposals are not always transfers — governance votes, runtime upgrade authorisations and batches all arrive through the same pallet — so a transfer-shaped layout silently misreports them. This shows the call the runtime actually declared, with each of its parameters." Batches are named there as a proposal shape the sheet must render correctly.
Two merged pull requests state the same rule. Commit 2293f20 ("fix: multisig approve must carry the proposal's inner call", #585) gives the goal as lifting "a nested transfer's amount into the hero position". #586, merged 2026-07-27 and titled "cold wallet decodes every chain call, no blind signing", states it directly:
Amount hero comes from the call's transfer summary, which propagates up from a nested call — a multisig approve of a transfer still reads as an amount and a recipient, while a governance vote names the call instead of claiming zero.
The summary propagating up from a nested call is the behaviour _batch does not implement.
What each branch produces
The batch children are decoded correctly — the amount survives strict decoding as an AmountField. What does not survive is the summary, and the summary is what the sheet's headline is built from.
The nested amount is routed through the passive balance-hiding provider
DecodedCallView renders a native AmountField with txAmountDisplayProvider. That provider watches isBalanceHiddenProvider and replaces both token and fiat representations with the hidden placeholder. By contrast, the direct-transfer headline receives NumberFormattingService directly and remains exact.
DecodedCallHeadline.of takes an injected formatter, and the three call sites differ:
Approval sheet headline (multisig_action_confirm_sheet.dart:295, :354)
fmt.formatBalance
exact amount
no amount — the headline falls back to the call name
AmountField detail row (decoded_call_view.dart:66-68)
txAmountDisplayProvider
-----
-----
Proposal list tile (proposal_list_tile.dart:43)
formatAmount
-----
-----
So under Hide balance a direct transfer still states its amount once, in the headline of the screen that asks for the signature. A batch states it nowhere. The sheet has no per-field reveal; the only way to see the figure is to leave the flow and turn the global setting off from the home screen.
The final approval control does not treat an omitted amount as incomplete review data
The approval sheet disables confirmation while submitting, while authoritative bytes are loading, or after those bytes fail to load. It does not disable confirmation when a value-moving field is hidden. After local authentication it forwards the already-loaded authoritative bytes to the submit callback.
This report does not rely on modified RPC or indexer data. The proposal bytes are authentic, strict decoding recovers the correct amount, and the approval path forwards the same bytes. The defect occurs after those protections succeed, when the review renderer suppresses the amount.
Where the proposal comes from
Neither scoped asset builds a batch proposal: the app's propose path calls proposeTransfer (multisig_propose_strategy.dart:213), which reaches the SDK's MultisigService.propose (multisig_service.dart:384-392); that in turn builds its call with buildProposeTransferCall (:341, :391), which wraps a plain transfer. The proposal in this report is encoded outside the application.
The runtime accepts that. Multisig::propose (pallets/multisig/src/lib.rs:535-540) takes any encoded RuntimeCall from any registered signer, and the approval renderer was written for exactly this case — decoded_call_view.dart:11-14 names batches among the proposal shapes that "arrive through the same pallet" and must therefore be rendered correctly rather than assumed to be transfers.
A normal multisig threshold makes the omitted field economically consequential
The frozen runtime requires the approver to be a registered signer and to resubmit bytes equal to the stored proposal. The proposing signer already contributes approval one. When a second signer approves the same bytes, the proposal becomes Approved; any registered signer may then execute it. Execution decodes the stored call and dispatches it with the multisig account as the signed origin.
The PoC uses one fixed 46-byte SCALE call in both repositories:
The frozen app decodes this as:
The widget regression shows that the exact amount — 987654321000000 base units, 987.654321 tokens — is decoded but not shown, that the rendered Approve control can be activated, and that the sheet forwards those exact bytes once. The frozen-runtime regression shows that the same bytes remain stored through an ordinary 2-of-2 approval and that execution increases the encoded destination balance by exactly 987654321000000.
This is not a signature or threshold bypass. It is a final-review defect: the second signature is real, but one of the principal transaction parameters is not shown at the moment that signature is requested.
Expected and observed behavior
Expected: Every amount and destination that can move value from a multisig remains visible at the final approval step, or confirmation remains disabled until the user explicitly reveals those fields.
Observed: A one-item batch hides its correctly decoded amount behind the portfolio privacy placeholder while keeping confirmation enabled. Removing only the batch wrapper makes the same amount visible under the same privacy setting.
Affected frozen sources
Mobile application and SDK:
d5fc4d6572ebc2244e95009a33f5da5048935867Supporting chain:
3b243b870a5442a6e5f443056f67bbba87d472f5Scoped asset:
https://github.com/immunefi-team/audit-comp-quantus-apps/tree/audit-comp-ready/mobile-appThe affected decoder is supplied by the separately listed
quantus_sdkasset at the same frozen commit.
Remediation
_batch should carry the summary of its children, as as_derivative, with_weight, if_else, dispatch_as and the two Multisig arms already do. A batch can hold several transfers, so a single-child summary is not sufficient — every nested value-moving amount and destination needs to reach the sheet.
The same omission is present elsewhere in the decoder and is worth checking in the same pass: in _balances, force_unreserve, force_set_balance, force_adjust_total_issuance and burn each emit a native AmountField with no TransferSummary (call_decoder.dart:106-131). The first three require a Root origin; burn is ensure_signed (pallets/balances/src/lib.rs:881-886).
Impact Details
Impact: Websites and Applications — Critical — "Direct theft of user funds".
The published Critical list for this asset type contains a separate entry for actions taken "on behalf of other users without any interaction by that user". That interaction condition is written into that entry and is absent from "Direct theft of user funds", so the presence of a victim action does not by itself move this finding out of the theft entry.
The funds outcome here needs no second defect and no further step. One Balances.transfer_allow_death is dispatched with the multisig account as the signed origin (pallets/multisig/src/lib.rs:1156-1157), the destination is chosen by the proposer, and the balance delta is asserted exactly in the runtime test. Nothing is displayed incorrectly at any point — the amount is simply not displayed, on the one screen whose purpose is to obtain authorization for it.
If the classification is read differently, the same demonstrated behaviour also falls under the Critical entry "Malicious interactions with an already-connected wallet, such as: … Submitting malicious transactions". Both are listed on the submission.
The flow
An ordinary 2-of-2 or 2-of-3 multisig, threshold at least 2:
The amount is bounded only by the multisig's balance. The PoC uses 987654321000000 base units — 987.654321 tokens at the chain's 12 decimals — and asserts that exact delta on the destination.
Conditions and bounds
Hide balance is opt-in and off by default (
settings_service.dart:387). The attack requires the second signer to have enabled it.The attacker must already be a registered signer of that multisig, and below the threshold alone. This is a position the victim granted deliberately; it is not access to the project's own systems or infrastructure.
The second signer performs a real, locally authenticated approval. No signature or threshold check is bypassed, and no silent signing is claimed.
Destination and wrapper stay visible. A signer who declines every unfamiliar destination is unaffected.
At threshold 1 the finding does not apply, because no second approval is solicited.
A multisig configured with the runtime's high-security policy is not reachable by this exact shape: the gate at
pallets/multisig/src/lib.rs:620, re-checked at:1142, admits only the calls listed inruntime/src/configs/mod.rs:683-693— threeReversibleTransferscalls andVesting::claim.Utility.batchis not among them. Ordinary multisigs, which the application creates by default, are reachable.The proposal must remain valid and the transfer affordable at execution.
Proposing is not free:
Multisig::proposecharges a non-refundable fee scaled by signer count and reserves a deposit until the proposal is resolved.
The report claims neither control of a wallet nor a runtime authorization failure. It claims that the scoped application omits the amount at the moment it asks a signer to authorize it, and that the omission is specific to one decoder branch.
The public M3 remediation addressed a different stage: it made approval load and bind authoritative call bytes instead of trusting indexer presentation. This report begins after that protection has succeeded — the bytes are authentic, decoding is correct, and the omission happens in rendering.
References
Link to Proof of Concept
https://gist.github.com/Skydev0h/f5404b2b44029d378825917708a7200d
Proof of Concept
This PoC has two local layers joined by one fixed SCALE byte vector. The first exercises the frozen production decoder and final approval widget. The second applies the same bytes to the frozen runtime's ordinary 2-of-2 multisig lifecycle and verifies the exact balance effect. Neither test contacts a node, RPC endpoint, indexer, device, live account, or project service.
Artifacts
The three reproduction files are in this unlisted Gist:
https://gist.github.com/Skydev0h/f5404b2b44029d378825917708a7200d
poc_test.dart
Frozen application widget regression
runtime-harness.patch
Directly applicable frozen-chain test patch
runtime_test.rs
Standalone copy of the Rust test added by the patch
Both tests use this exact 46-byte call:
It represents:
Step 1: reproduce the final approval behavior
A Flutter SDK compatible with the frozen project is required. From a clean directory:
A Flutter SDK is required; the repository does not vendor one. Verified on Flutter 3.44.9 stable, which satisfies the sdk: ">=3.8.0 <4.0.0" constraint in mobile-app/pubspec.yaml. The Rust step below needs only a Rust toolchain and the repository's normal native build dependencies.
Expected output:
The widget test performs the following checks:
The submit callback is injected and inert: this layer stops at the production UI-to-submission boundary and does not sign or broadcast anything. The widget test also satisfies the local-authentication gate trivially, so it demonstrates the rendering and the enabled control, not the biometric step.
Step 2: reproduce the frozen runtime semantics
A Rust toolchain and the repository's normal native build dependencies are required. From a clean directory:
Expected output:
The runtime test shows that the same fixed bytes:
This layer uses local TestExternalities and direct signed origins. It does not start a node or use private keys.
Observed result
Both layers passed on the exact frozen commits listed above. The fixed byte vector is independently decoded by the SDK and runtime. Together the tests connect the final review state—correct destination, hidden amount, enabled and activated approval—to the deterministic balance movement represented by the same bytes.
Attached source files
Was this helpful?