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

89011 w a high fail open runtime call decoding lets a minority multisig signer obtain approval for full balance theft

Submitted on Aug 16th 2026 at 13:55:56 UTC by @rezarwz for Audit Comp | Quantus

  • Report ID: #89011

  • 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

Description

Brief/Intro

The official Quantus mobile application fails open when stale generated metadata cannot decode a valid nested runtime call in a multisig proposal: it hides the recipient, amount, and failing child, shows no decoding warning, but keeps approval enabled and approves the exact hidden bytes. A malicious minority member of an ordinary 2-of-3 multisig, who cannot spend alone, can obtain the necessary second approval when an honest co-signer follows the normal approval flow and then execute a non-atomic batch that transfers the multisig's entire transferable native balance before the hidden child fails.

Vulnerability Details

Affected revisions

The reproduction is pinned to the competition revisions:

Component
Repository
Commit / runtime version

Mobile application and Quantus SDK

https://github.com/immunefi-team/audit-comp-quantus-apps.git

d5fc4d6572ebc2244e95009a33f5da5048935867; generated metadata for runtime spec 136, transaction version 3

Chain and runtime

https://github.com/immunefi-team/audit-comp-quantus-chain.git

3b243b870a5442a6e5f443056f67bbba87d472f5; runtime spec 143, transaction version 3

The attack uses an ordinary multisig. It does not require treasury, governance, Technical Collective, administrator, guardian, high-security, or other privileged membership. It also does not require a stolen credential, a malicious RPC, or falsified indexer data.

The application cannot decode a valid current-runtime child

The application's generated RuntimeCall codec recognizes Utility at pallet index 9, but it has no variant for the frozen runtime's Vesting pallet at index 22. Unknown variants throw:

The frozen runtime, by contrast, exposes Utility at index 9 and callable Vesting at index 22. Utility.batch decodes its calls field as a sequence of complete RuntimeCall values:

The full-drain fixture is a valid current-runtime call:

The app first recognizes and enters the Utility batch, then recursively decodes the balance-moving child. It throws only when it reaches the trailing Vesting child:

The bytes are not malformed. The frozen spec-143 runtime strictly decodes the same fixture with no trailing bytes.

Authoritative proposal enrichment erases useful review data

The proposal service correctly retrieves the stored call from Multisig.Proposals. It then attaches those authoritative bytes and asks the stale decoder for a summary. If decoding fails, decodedCall yields no usable summary, and the following code replaces a previously accurate recipient and amount with empty values:

The component test deliberately begins with an accurate simulated proposal row, not false indexer data:

After production enrichment loads and fails to decode the authoritative baseline bytes, the values are:

This means an honest indexer-provided transfer summary does not protect the reviewer from the decode failure.

The confirmation screen catches the error but does not block approval

The confirmation widget loads the authoritative bytes into _callBytes. Its display getter catches a decoding exception and continues:

The widget then falls back to generic proposal labels. The approval gate covers a failure to load bytes, but not a failure to decode bytes that loaded successfully:

There is no durable decode-failure state in blocked. In the rendered production widget, the complete transaction-review text for the baseline fixture was:

The displayed fee is a deterministic local test value, not a production fee measurement. The widget showed none of the following:

  • the attacker's address;

  • 42.424242424242 or its raw integer amount;

  • Vesting or claim;

  • 18446744073709551615 (u64::MAX);

  • the raw SCALE call; or

  • a loading or decoding warning.

The production button had a non-null callback and isDisabled == false.

Approval remains bound to the hidden bytes

The exact-byte protections introduced around multisig approval do work, but they bind the signature to bytes the screen failed to explain. _confirm sends _callBytes to the production callback:

The service constructs Multisig.approve with those same stored bytes:

The runtime also enforces exact equality:

The test changed one byte and received CallMismatch. For the unmodified fixture, the bytes proposed, stored, returned to the app, passed to the enabled callback, embedded in Multisig.approve, retained after approval, decoded for execution, and included in ProposalExecuted were equal. Thus the hidden call is not substituted after review; the application approves the exact undisclosed call.

The second approval is necessary

The attacker is one legitimate member of a funded threshold-2 multisig with three members. Proposing the call records only the attacker's approval. When the attacker attempts execution at that point, the actual Multisig pallet returns ProposalNotApproved, and no multisig funds move.

An honest second member must open the ordinary pending-proposal route and approve on the incomplete screen. That approval changes the proposal from Active to Approved. It does not execute automatically: the malicious member then performs a separate permitted Multisig.execute. Neither the victim nor the third signer needs to act again.

Runtime batch semantics preserve the transfer

At execution, the Multisig pallet decodes the stored call and dispatches it with the multisig account as the signed origin. The non-atomic Utility.batch dispatches children in order. If a child fails, it emits BatchInterrupted and returns Ok, preserving effects of earlier successful children:

Balances.transfer_all(attacker, false) executes first and transfers the multisig's entire transferable native balance. Vesting.claim(u64::MAX) executes second and fails with NoSchedule. A schedule at u64::MAX cannot be allocated by this pallet because schedule creation computes schedule_id + 1 with checked arithmetic before insertion. The failure is therefore deterministic in the tested runtime state and does not rely on the victim having a particular vesting schedule.

Because batch reports the interruption while returning outer success, Multisig records ProposalExecuted(result=Ok(())) and removes the proposal. The preceding transfer remains committed. The executed batch_all control uses identical children but rolls the transfer back when Vesting fails, confirming that non-atomic batch semantics are necessary to the payload.

Attack prerequisites

Capability or condition
Required?
Detail

One legitimate key in an ordinary funded 2-of-3 multisig

Yes

The attacker starts with one vote, not an authorized quorum.

One honest member opens the pending proposal and approves

Yes

The victim takes a normal approval action after receiving the incomplete review.

Separate execution by the attacker after threshold

Yes

The attacker executes once the proposal is approved.

Third signer action

No

The third member never acts.

Treasury, governance, Technical Collective, administrator, guardian, or high-security role

No

The tested multisig has no special enrollment or privilege.

Stolen credentials or victim seed

No

The honest member uses their own key and intentionally presses the enabled approval action.

Malicious RPC or false indexer row

No

Tests use authoritative stored bytes and an accurate simulated proposal row.

Impact Details

The affected approval is the missing authorization that separates a one-vote proposal from a proposal able to spend the multisig's balance. Before the honest approval, the runtime rejects the attacker with ProposalNotApproved. After it, the same attacker can execute the exact hidden call as the multisig. The on-chain threshold is not bypassed; the flaw defeats the informed-approval security that the 2-of-3 policy is meant to provide.

The baseline fixture transferred exactly 42.424242424242 QUAN:

The harness compares total balances across execution so returning the proposal deposit does not inflate the transfer delta.

The stronger Balances.transfer_all(attacker, false) fixture then drained the entire transferable native balance remaining in the harness:

The practical amount at risk in one exploit proposal is the ordinary multisig's entire transferable native balance at execution. This does not claim transfer of unrelated asset classes or balances that the runtime does not consider transferable. Critical is justified because a minority signer who lacks quorum can use one incomplete but enabled approval to make a full-balance theft executable, and the runtime reproduction demonstrates that loss rather than only a misleading display.

The victim does participate: an honest member opens the pending proposal and approves it. The direct-loss classification remains appropriate because the official security review omits every material transfer parameter while presenting an enabled approval action, allowing a minority member who cannot spend alone to obtain the authorization needed to steal user-controlled multisig funds.

https://gist.github.com/rezarwz-sec/a3f2f87dd4407656fb18cb587dcf8fb3

Proof of Concept

The reproduction has two byte-joined tracks. The Flutter track proves what the honest signer sees and what bytes the production approval path forwards. The Rust track proves the fixtures' lifecycle and direct financial effect in the actual frozen runtime. The text-only Gist carries canonical fixture hex; the setup below reconstructs the binary files, and the runtime harness independently generates the same bytes from the frozen Runtime before execution.

Fixtures

Baseline transfer

Full-balance transfer

The attacker is the deterministic local test account [0xa5; 32]. It is not a production account or credential.

Prerequisites and clean setup

Required tools are Bash 4 or newer, Git, tar, xxd, GNU sha256sum, Rust with Cargo and rustfmt, and Flutter with Dart. Network access is needed for fresh source clones and normal locked dependency retrieval. Validation used Flutter 3.47.0, Dart 3.13.0, rustc 1.97.0, and Cargo 1.97.0; these record the executed environment, not asserted minimum versions. No node, chain specification, wallet seed, public RPC, or production indexer is needed.

The supporting Gist is flat and text-only. The supporting PoC files are provided separately through the form’s PoC link field. Run the following commands in one Bash session, supplying that URL when prompted. The checksum check covers every Gist file except SHA256SUMS itself.

Create disposable Flutter and Cargo layouts, reconstruct both binary fixtures from the canonical hex, and copy a tracked-only snapshot of the frozen app. These operations do not modify either source checkout.

Run the Flutter track with its locked dependencies:

Run the runtime track against the actual frozen Runtime and pallets:

Finally, verify both fixture identities again and prove the frozen source checkouts stayed pinned and clean:

The application track passes only when its Flutter test and all marker checks succeed. The runtime track passes only when the Rust process and its marker checks succeed. Both are required; BatchInterrupted is the expected partial execution inside the malicious call, not a partial test result.

Full attack flow exercised by the tests

1

The runtime harness creates the multisig

The runtime harness creates an ordinary multisig with deterministic members A, B, and C and threshold 2. It funds the derived multisig account with 1,000 QUAN and confirms that it has no high-security enrollment.

2

Malicious member A proposes the fixture

Malicious member A proposes the exact baseline fixture. The real Multisig pallet stores the 55 bytes with A as the only approver.

3

A attempts execution before approval

A attempts to execute. The runtime returns ProposalNotApproved, and the harness asserts that no multisig value moved.

4

The app harness loads the stored fixture

The app harness supplies an accurate pending-proposal row and returns the exact stored fixture through the generated production storage interface over loopback JSON-RPC.

5

The production decoder fails on Vesting

The frozen production decoder enters Utility.batch, decodes child 0, and throws on Vesting variant 22 at child 1.

6

Proposal enrichment removes review details

Production proposal enrichment changes the accurate attacker/amount fields to an empty recipient and zero.

7

The confirmation widget remains actionable

The test renders the real confirmation widget. It captures the generic Batch / Utility review, proves the attacker, amount, Vesting call, argument, raw bytes, and warning are absent, and verifies that Yes, Approve is enabled.

8

The enabled callback forwards the hidden bytes

The component harness directly invokes that enabled production callback after rendering. It intercepts final submission at a local boundary and verifies that the callback and generated Multisig.approve builder contain the exact 55-byte fixture.

9

Honest member B approves the unmodified bytes

The runtime test mutates one byte and confirms CallMismatch. It then submits honest member B's approval with the unmodified stored bytes. The proposal reaches Approved with two approvals.

10

A executes the stored batch

A separately executes. The runtime dispatches the stored batch as the multisig. The balance transfer completes, the Vesting child returns NoSchedule, Utility emits BatchInterrupted at index 1, and Multisig records outer success with the exact stored call.

11

The harness verifies batch behavior

The harness proves the baseline transfer remains and then runs batch_all with the same children, proving the atomic form rolls the transfer back.

12

The full-balance fixture drains the multisig

The harness repeats the real 2-of-3 lifecycle with the 49-byte transfer_all(false) fixture and transfers the multisig's entire remaining transferable native balance to A.

Expected and observed application results

Check
Expected
Observed

Supported Balances.transfer_allow_death control

Decode normally and display recipient/amount

Recipient and 42.424242424242 displayed

Malicious baseline fixture

Recursive decode reaches unknown child

Exception: RuntimeCall: Invalid variant index: "22"

Accurate proposal summary after authoritative decode failure

Security-preserving behavior would retain or block

Production code replaced recipient with "" and amount with 0

Review content

Security-preserving behavior would show details or an error

Only generic Batch / Utility information; hidden values and warning absent

Approval state

Security-preserving behavior would disable approval

Production approval callback remained enabled

Callback and builder bytes

Must equal authoritative fixture

SHA-256 matched fa9413540d1fc130e0ac65dd679bb4af952138f3a3c5c00980e0f16fd2b3a34f

Full-balance fixture

Same decoder failure and exact-byte builder

Both conditions observed; SHA-256 matched a8e24e7ab4104ff45352e7018409a23cca52e2fcd5b4636acc1fd002a5c2f94b

Representative successful application markers are:

Expected and observed runtime results

Check
Expected
Observed

Strict current-runtime decode

Valid complete call, no trailing bytes

Both fixtures decoded completely

Proposal storage

Stored bytes equal proposed fixture

proposed_eq_stored=true

Attacker execution before second approval

Rejected without value movement

ProposalNotApproved; no movement

Modified approval bytes

Rejected

CallMismatch

Honest second approval

Exact stored bytes retained

approval_input_eq_stored=true, stored_eq_approved=true

Baseline execution

Child 0 persists despite child 1 failure

Exact 42.424242424242 QUAN transfer persisted

batch_all control

Child 0 rolled back

Both attacker and multisig deltas were zero

Full-balance fixture

Entire transferable native balance moves

957.575757575758 QUAN moved; multisig balance became zero

Executed call identity

Event call equals stored proposal

stored_eq_executed_event=true

The relevant baseline event order was:

The full-drain execution produced this order:

NoSchedule is the Vesting dispatch error carried by BatchInterrupted, not a separate event. The following ProposalExecuted(result=Ok(())) records Utility's successful outer return after the interruption.

A normalized summary of the successful runtime evidence is:

These observations establish the authorization and loss chain: one signer is insufficient; the incomplete official review remains actionable; the approval is byte-bound to the hidden proposal; the second approval makes it executable; and execution moves the ordinary multisig's transferable native balance to the attacker.

Exact-byte invariant

Handoff
Executed result

Runtime-generated proposal fixture

55-byte baseline with canonical SHA-256

Multisig.propose into actual pallet storage

Byte-equal

Authoritative storage response loaded by production app code

Byte-equal

Enabled production approval callback

Byte-equal

Generated Multisig.approve inner call

Byte-equal

Post-approval proposal storage

Byte-equal

Runtime decode and ProposalExecuted.call

Byte-equal

The one-byte mutation control failing with CallMismatch confirms that this equality is enforced rather than incidental.

Evidence boundaries

The Flutter and runtime tracks are a split, byte-joined integration reproduction, not one live application-to-node transaction trace.

  • The production Quantus indexer was not executed. The application test supplies an accurate simulated proposal row. No dishonest indexer data is required, but normal production discoverability remains conditional on the honest indexer returning the pending proposal.

  • The real vulnerable widget and enabled production callback were exercised with the 55-byte baseline fixture. The 49-byte full-drain fixture reached the same production decoder exception and exact Multisig.approve builder, then completed the ordinary multisig lifecycle in the runtime harness. A full-drain widget/callback interaction was not performed.

  • The Flutter test exercises the frozen production decoder, proposal enrichment, approve builder, callback, and real confirmation widget. It is not a release APK, emulator recording, or physical-device test.

  • The enabled production callback is invoked directly after rendering; no physical tap is claimed.

  • Biometric UI and the hardware-wallet QR flow were not executed.

  • Final network submission is intercepted at a local boundary. Application transport is loopback-only.

  • The runtime test uses the actual frozen Runtime and Multisig, Utility, Balances, and Vesting pallets in native TestExternalities. It uses direct pallet dispatch, not a signed local-node extrinsic or authored block.

  • No block hash, extrinsic hash, genesis hash, signed-extension trace, production indexer result, or production network-fee measurement is claimed.

Prior remediation context

PR #549, which was closed without merge, is broad prior art for wrapped multisig calls and misleading display data. I do not claim that the general display-versus-signed-call class is new. Merged PRs #575 and #585 subsequently added authoritative stored-call loading, exact-byte use during approval, and strict recursive decoding. Chain PR #622 enforces byte equality at approval.

Callable Vesting variant 22 was added later in chain PR #646, after those fixes and after the frozen app's generated spec-136 metadata. The issue reproduced here is a post-remediation fail-open bypass: strict decoding correctly rejects the newer nested variant, but a higher-level catch turns that error into an incomplete, approval-capable confirmation while the exact hidden authoritative bytes are still passed into approval. It is not a generic indexer-spoofing report, and the PoC does not depend on false indexer data.

Was this helpful?