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

89478 w a medium balance privacy display blanks the transfer amount in the multisig approval confirmation enabling blind co signer approval and direct theft of user funds

Submitted on Aug 19th 2026 at 14:49:07 UTC by @zealynx for Audit Comp | Quantus

  • Report ID: #89478

  • Report Type: Websites & Apps

  • Report severity: Medium

  • 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

    • Direct theft of user funds

Description

Brief/Intro

The Quantus mobile wallet renders the transfer amount in the multisig approval confirmation through the same display provider it uses for portfolio balances, so the user's balance-privacy toggle blanks it. When a user has enabled balance privacy, a native-token transfer nested in Utility.batch renders with its call name, destination, and structure intact, but its amount is replaced by the placeholder -----, while the Approve button stays enabled. A malicious co-signer of an ordinary funded multisig can exploit this to obtain an uninformed approval and drain the shared account — the confirmation never shows the amount the victim is signing. In production this causes direct theft of up to the full multisig balance; the proof of concept signs and executes an exact transfer of 1,000,000 QUAN to the attacker that was never displayed.

Vulnerability Details

The confirmation amount crosses this production path:

Location
Behaviour

quantus_sdk/lib/src/services/settings_service.dart

Persists the user-selected balance_hidden preference.

mobile-app/lib/providers/wallet_providers.dart (isBalanceHiddenProvider)

Publishes the persisted preference.

mobile-app/lib/providers/currency_display_provider.dart

For balance-hidden state, replaces every transaction amount's display value with -----.

mobile-app/lib/v2/components/decoded_call_view.dart

Renders the transfer parameter through that preference-dependent value.

mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart

Decodes the authoritative stored call bytes and renders their parameters through DecodedCallView, with Approve enabled.

The confirmation decodes the authoritative on-chain bytes correctly — the wrapper Utility · batch, the inner Balances · transfer_allow_death, and the attacker's full destination address all render. Only the amount is dropped, and it is dropped by the display preference:

Balance privacy is a portfolio-display preference, not authorization to omit a transaction parameter from a signing confirmation. The sheet provides no amount-reveal control and no warning, and leaves Approve enabled.

Vulnerable scenario

1

An attacker and a victim are the two members of an ordinary funded 2-of-2 multisig

The victim has enabled the released balance-privacy toggle — a production-reachable state selected from the home screen.

2

The attacker proposes a transfer

Utility.batch([ Balances.transfer_allow_death(attacker, 1_000_000 QUAN) ])

3

The victim opens the approval sheet

It shows Utility · batch, Balances · transfer_allow_death, and the attacker's destination address, but renders Amount: -----. Approve is enabled.

4

The victim approves

The wallet signs the exact stored call bytes with the production ML-DSA key. Once the threshold is met the attacker executes; the runtime moves 1,000,000 QUAN from the multisig to the attacker.

The attacker uses only ordinary signed multisig and Balances calls. No remote-data or exchange-rate manipulation, compromised infrastructure, victim key, or privileged chain origin is required, and the attacker cannot change the victim's preference.

Recommended fix. Render native amounts in signing confirmations independently of exchange-rate, fiat-first, and balance-privacy preferences. The exact chain-denominated amount must remain mandatory even when the user hides portfolio balances:

Impact Details

A malicious co-signer obtains a victim's approval signature for a transfer whose amount was never shown, then executes it, stealing up to the entire funded multisig balance. This exceeds the co-signer's baseline powers: a 2-of-2 member cannot move shared funds alone — the exploit manufactures the missing second signature by hiding the amount, so the loss is theft, not merely the freeze a hostile co-signer could already cause.

The proof of concept demonstrates an exact loss of 1,000,000 QUAN from the multisig with a matching attacker credit, proven end to end: the confirmation renders Amount: -----, the exact bytes passed to the ML-DSA signer independently decode to the 1,000,000 QUAN transfer, and the production runtime accepts the byte-identical extrinsic and moves the funds.

Selected in-scope Websites & Apps impacts:

  • Malicious interactions with an already-connected wallet — the confirmation omits a transaction parameter (the amount) while leaving Approve enabled, inducing the victim to submit a malicious transaction.

  • Direct theft of user funds — the demonstrated consequence (1,000,000 QUAN extracted).

References

Frozen revisions: quantus-apps @ d5fc4d6572ebc2244e95009a33f5da5048935867 · quantus-chain @ 3b243b870a5442a6e5f443056f67bbba87d472f5

  • https://github.com/immunefi-team/audit-comp-quantus-apps/blob/audit-comp-ready/mobile-app/lib/v2/components/decoded_call_view.dart#L66-L83

  • https://github.com/immunefi-team/audit-comp-quantus-apps/blob/audit-comp-ready/mobile-app/lib/providers/currency_display_provider.dart

  • https://github.com/immunefi-team/audit-comp-quantus-apps/blob/audit-comp-ready/mobile-app/lib/providers/wallet_providers.dart

  • https://github.com/immunefi-team/audit-comp-quantus-apps/blob/audit-comp-ready/mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart

  • https://github.com/immunefi-team/audit-comp-quantus-apps/blob/audit-comp-ready/quantus_sdk/lib/src/services/settings_service.dart

https://gist.github.com/vendrell46/f28de277cdd4571e5edaf4207dfe6d73

Proof of Concept

Frozen revisions: quantus-apps @ d5fc4d6 · quantus-chain @ 3b243b8. Two halves prove the full path: the wallet hides the amount and signs the real bytes, and the production runtime accepts those exact bytes and moves the funds. The complete test files are in the secret gist linked at the end; the steps and observed output below make the report reproducible on its own.

Step by step

1

Mobile half (privacy_batch_confirmation_poc_test.dart)

Enables balance privacy through the production SettingsService().setBalanceHidden(true) path, builds Utility.batch([ transfer_allow_death(attacker, 1,000,000 QUAN) ]), and renders the real MultisigActionConfirmSheet with an honest 1:1 exchange rate.

It captures every rendered Text: the sheet shows Utility · batch, Balances · transfer_allow_death, the attacker destination, and Amount: -----, with Approve enabled. It taps Approve, constructs the production signing payload, signs it with the real Rust ML-DSA FFI, verifies the signature, and independently SCALE-decodes the signed bytes back to the 1,000,000 QUAN transfer to the attacker.

2

Chain half (runtime_fiat_batch_acceptance.rs)

Reconstructs the byte-identical signing payload with the production runtime and full TxExtension tuple, asserts it equals the bytes captured at the mobile ML-DSA boundary, applies the signed approval, executes the proposal, and asserts the multisig free balance falls by exactly 1,000,000 QUAN while the attacker's rises by the same amount (plus the returned proposal deposit).

Run

Observed output

The mobile ML-DSA payload hex equals the runtime-accepted payload hex: the bytes signed under a blank amount are exactly the bytes that move the funds.

Fix check. Changing only DecodedCallView to render the chain-denominated native amount makes the sheet show Amount: -1,000,000 QUAN, and the unchanged mobile test then fails its ----- assertion; a sweep over batch / batch_all / force_batch, nested batch, existential-deposit, and million-QUAN values is exact under the fix.

Full PoC files: https://gist.github.com/vendrell46/f28de277cdd4571e5edaf4207dfe6d73

Was this helpful?