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

89798 w a high multisig review hides call parameters and enables a treasury drain

Submitted on Aug 21st 2026 at 08:06:59 UTC by @Paludo0x for Audit Comp | Quantus

  • Report ID: #89798

  • 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

Quantus Mobile can let one treasury signer obtain an uninformed second approval and move up to the treasury's spendable balance. Stale SDK metadata and a fail-open multisig review compose into an authority amplification bug. A threshold execution can create an immediately claimable Vesting schedule for the attacker's account.

Vulnerability Details

Planck configures its treasury as a 2-of-3 multisig. The Mobile application reviews proposals before a signer supplies the approval needed to cross that threshold. Its bundled SDK targets runtime spec 136, while the frozen runtime is spec 143 and assigns Vesting to pallet index 22.

Step-by-step vulnerability explanation

The bundled RuntimeCall codec has no Vesting variant. RuntimeCall::decode() throws when it receives the valid live-runtime prefix 22, 1 for Vesting::create_schedule().

This mismatch is not cosmetic. Beneficiary, total, start, cliff, and end exist only inside the undecodable call bytes.

The sheet loads authoritative bytes from chain storage. It catches the decode exception and retries widget.proposal.decodedCall instead of recording a security failure.

That getter uses the same stale codec and returns null. Rendering then falls through to _fallbackTransferCall, which contains only the indexer's Vesting / Create schedule labels. It contains none of the value-moving parameters.

The approval gate ignores authoritative decode failure. It blocks loading and storage errors, but it does not block decoded == null.

Local authentication proves user presence, not informed intent. MultisigActionConfirmSheet::_confirm() then forwards _callBytes unchanged to the approval submitter.

After the second approval, Multisig::execute() dispatches as the treasury address. Runtime configuration accepts that signed treasury origin for Vesting administration.

Vesting::create_schedule() transfers total from the treasury into the Vesting pot. It stores the attacker as beneficiary.

An immediately matured schedule can then be claimed by any signed account. Payment always goes to the hidden beneficiary.

Differential from public M3 / PR #575

PR #575 fixed a different necessary root. M3 trusted GraphQL indexer JSON for the reviewed recipient and amount while the approval committed only to a proposal ID. The fix moved the source of truth to the proposal bytes in chain storage and made approve resubmit those exact bytes.

This report starts after both controls succeed. The sheet loads the authoritative bytes, and the approval is byte-equal to the stored proposal. No compromised indexer response is required. The residual bypass occurs when the bundled codec cannot decode a valid live-runtime call. Both decode attempts fail, rendering falls through to generic indexer labels instead of the authoritative raw bytes promised by PR #575, and confirmation remains enabled.

The necessary root and minimal fix are therefore different. M3 required authoritative byte sourcing and byte binding; this issue requires fail-closed handling of authoritative decode failure, or an unambiguous raw-byte review, plus runtime-compatible metadata. Applying PR #575 does not automatically fix this issue because its controls are already present in the frozen vulnerable source. This is a bypass of the prior fix, which the competition rules treat as a new valid bug.

Attack path

1

One legitimate treasury signer proposes the payload

One legitimate treasury signer calls Multisig::propose() with an immediately matured Vesting::create_schedule() payload.

2

The signer cannot execute alone

The runtime rejects Multisig::execute(), and treasury state remains unchanged.

3

An honest co-signer loads the proposal

An honest co-signer opens MultisigActionConfirmSheet. It loads the exact stored bytes from the chain.

4

Decoding fails

RuntimeCall::decode() throws on pallet index 22. The sheet shows only generic Vesting labels without parameters.

5

Approval remains enabled

MultisigActionConfirmSheet::_confirm() submits the same hidden bytes after local authentication.

6

The threshold is crossed

The second approval crosses the 2-of-3 threshold. The malicious signer calls Multisig::execute().

7

Funds are claimed

The runtime creates the schedule as the treasury multisig. Any signer calls Vesting::claim() and pays the attacker.

Impact Details

  • Funds at risk: up to the treasury's spendable balance. I do not claim a live treasury balance.

  • Demonstrated impact: my local fixture moved 999 of 1,000 QUAN units out of the treasury, then the beneficiary spent 997 QUAN to a separate cash-out account.

  • Profitability: after every signed transaction fee, the aggregate attacker-controlled balance increased by 997326360003366 Planck units.

  • Authority amplification: one signer may propose, but cannot spend alone. The application defect supplies the missing informed threshold approval.

  • Victims: the treasury and honest co-signers whose approval intent does not match the concealed parameters.

  • Recovery: co-signers can refuse before execution. After a matured claim, ordinary unilateral recovery is unavailable.

  • Primary Critical mapping: Direct theft of user funds. The beneficiary receives spendable treasury funds and completes a normal signed cash-out transfer.

  • Secondary Critical mapping: Malicious interactions with an already-connected wallet, including modifying transaction parameters or submitting malicious transactions. The confirmation hides the beneficiary, amount, and vesting times of the call that receives the threshold approval.

  • Severity defense: the connected wallet performs the security approval that crosses the treasury threshold and authorizes the value-moving call.

  • Permissioned-role limit: the attacker is a legitimate signer, but holds insufficient unilateral authority. My negative control proves that boundary.

  • Known issues: retained referendum votes, trusted miner transport, and finite ZK or reward quantization do not cover this root.

  • Public-fix differential: M3 required a compromised indexer and unbound proposal semantics. This bypass requires neither; it exploits the distinct decode-failure branch after #575's authoritative load and byte-binding controls succeed.

Treat missing or failed authoritative decoding as a blocking state for every multisig action.

Also bind generated metadata to the live runtime. Never use generic indexer labels for an actionable confirmation after decode failure.

Proof of Concept

PoC Explanation

The PoC contains two tests with different roles. Together they prove the application failure and its economic consequence.

  • Flutter application test: quantus_2650_app_poc_test.dart uses Flutter's native widget-test framework and instantiates the production MultisigActionConfirmSheet. It loads the live-runtime Vesting bytes, proves the bundled decoder rejects them, and checks that beneficiary, amount, and timing remain hidden. It then presses the real Yes, Approve button and captures the unchanged bytes passed to the submission callback.

  • Rust runtime test: quantus_multisig_vesting_full_entry_path() runs inside the Quantus runtime test suite. It feeds the Flutter output through signed ML-DSA-65 UncheckedExtrinsic values and Executive::apply_extrinsic(). It does not call an internal settlement function directly.

  • Cross-layer runner: run_poc_2650.sh runs Flutter first and passes its captured payload to Rust. It aborts unless the application output exactly matches the call executed by the runtime.

The isolated widget fixture contains no enrolled local wallet, so LocalAuthService takes its documented no-enrollment success branch. This does not bypass the vulnerable authorization boundary: production local authentication proves signer presence, while the defect concerns the call parameters shown before that signer approves. The PoC does not claim to bypass biometrics; it proves that successful authentication can authorize parameters the confirmation sheet failed to disclose.

The Rust test first submits two negative controls. A direct signed Vesting call fails the treasury origin check. A proposal with only the malicious signer's approval cannot execute. Both controls traverse signature, nonce, fee, weight, extension, and dispatch checks.

The harmful branch adds the approval captured by Flutter, executes as the treasury, and claims the matured schedule. The beneficiary then signs Balances::transfer_keep_alive() to a separate cash-out account. Final assertions prove spendability and positive aggregate profit after all fees.

Both tests use frozen competition commits and local execution. I sent no transaction to a public network.

Running Script

Flutter POC

Rust POC

Was this helpful?