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

88730 w a critical generated multiaddress index decodes unit as compact letting one signer hide transfer all in an authoritative 2 of 2 multisig proposal and drain the multisig

Submitted on Aug 14th 2026 at 18:11:23 UTC by @ngosytuan for Audit Comp | Quantus

  • Report ID: #88730

  • Report Type: Websites & Apps

  • Report severity: Critical

  • Target: https://github.com/immunefi-team/audit-comp-quantus-apps/tree/audit-comp-ready/quantus_sdk

  • Impacts:

    • Direct theft of user funds

Description

The exact scoped runtime and the exact scoped Dart SDK disagree on the SCALE type of MultiAddress::Index:

  • The runtime uses MultiAddress<AccountId, ()>, so Index(()) is encoded as the one-byte enum discriminant 0x01 and consumes no value bytes.

  • The generated Dart SDK treats the same variant as Index(CompactBigInt) and consumes a compact integer after 0x01.

Because nested SCALE RuntimeCalls are not length-delimited, this is more than a display error. An attacker can construct one byte string that is a complete, canonical call under both schemas but has different call boundaries and different semantics.

The supplied 268-byte payload is decoded by the mobile approval path as:

Utility.if_else(
  TechCollective.add_member(Account index 258),
  Wormhole.verify_private_batch(<257 opaque bytes>)
)

The runtime decodes the exact same bytes as:

A malicious but sub-threshold signer can submit this as a normal 2-of-2 multisig proposal. The honest signer's mobile client fetches the authoritative call bytes from Multisig.Proposals, decodes and renders only the benign interpretation above, then resubmits those exact bytes in multisig.approve. That byte-equality check succeeds. On execution, the privileged add_member main branch fails under the signed multisig origin, if_else invokes the hidden fallback, and force_batch continues after the deliberately failing first child to execute transfer_all. The multisig's full transferable balance is sent to the attacker.

The PoC uses real ML-DSA-65 keys, a real 2-of-2 multisig, the production call decoders, the production approval byte-binding, and the exact scoped commits. It requires no vanity address, brute force, compromised RPC/indexer, metadata upgrade, forged signature, or victim key.

Severity and impact

Suggested severity: Critical — direct theft of user funds / malicious interaction with an already-connected wallet.

Both affected application assets are listed in the competition scope:

The Quantus competition scope lists both “Direct theft of user funds” and malicious wallet interactions that modify transaction arguments or submit malicious transactions as Critical website/application impacts. A runnable PoC is mandatory.

The attacker is already one legitimate multisig signer, but is below the configured threshold and has no unilateral authority to move funds. The vulnerability defeats the purpose of the remaining honest signature: the honest signer approves the exact on-chain bytes after the scoped application shows different semantics, no transfer_all, and no destination-address field. Once the threshold is reached, the attacker executes the approved proposal and drains 100% of the multisig's transferable balance.

Affected commits:

  • Chain/runtime: 3b243b870a5442a6e5f443056f67bbba87d472f5

  • Apps/SDK: d5fc4d6572ebc2244e95009a33f5da5048935867

Attacker privileges and interaction:

  • One legitimate but sub-threshold multisig signer.

  • One normal proposal, one normal review/approval by an honest co-signer, and one normal execution.

  • No privileged chain role, RPC/indexer control, victim credentials, physical access, phishing page, or modified application.

Root cause

1. The runtime instantiates MultiAddress with a unit index

The runtime address alias is MultiAddress<AccountId, ()>:

System lookup also uses AccountIdLookup<Self::AccountId, ()>:

For this concrete instantiation, the SCALE representation of MultiAddress::Index(()) is only its variant byte 0x01. The unit value has zero encoded bytes.

2. The scoped Dart codec consumes a CompactBigInt

The generated SDK implementation instead defines the Index payload as BigInt and decodes it with CompactBigIntCodec in multi_address.dart:

It therefore consumes bytes belonging to the next nested call.

3. The payload deliberately re-synchronizes under both schemas

The first 11 bytes demonstrate the boundary shift:

Offset
Bytes
Runtime interpretation
App interpretation

0–1

09 06

Utility.if_else

Utility.if_else

2–4

0d 00 01

TechCollective.add_member(Index(()))

Start of the same call and Index variant

5–6

09 04

Fallback Utility.force_batch

Canonical Compact(258) consumed as the Index value

7

14

Vec length 5

Pallet 20, Wormhole

8

02

First child pallet 2, Balances

Wormhole.verify_private_batch

9–10

05 04

force_unreserve(Address20(...))

Canonical compact byte length 257

11–267

257 bytes

Remaining runtime calls, including transfer_all

One opaque proof byte vector

Both decoders consume exactly 268 bytes. Consequently, trailing-byte checks and exact-fit parsing do not detect the payload.

The complete PoC call is:

The destination caad1e5b5296b318fc77ef3822ae82b74613590f03337b5e8220dc806c5cd56f belongs to the real ML-DSA-65 key derived from seed [99; 32]; it was not ground to satisfy a byte pattern.

4. The affected production approval flow trusts this wrong semantic decode

The existing anti-indexer-tampering measures do not help:

1

MultisigService._withOnChainCallData

It replaces indexer data with authoritative bytes from Multisig.Proposals.

2

Mobile confirmation sheet

It passes those bytes to CallDecoder.decodeBytes.

3

Rendered call tree

It renders the resulting tree through DecodedCallView.

4

Approval

The approve flow resubmits the same authoritative bytes in multisig.approve.

5

Byte-equality check

The chain's byte-equality check passes because no bytes were changed.

The raw attacker AccountId bytes occur inside the 257-byte value displayed as an opaque Wormhole proof, but the scoped application does not semantically display transfer_all, force_batch, a destination, an amount, or any address-typed field.

5. Runtime control flow makes the hidden transfer deterministic

The hidden main branch, TechCollective.add_member, requires a privileged origin and fails under the signed multisig origin. Utility.if_else therefore dispatches the fallback.

The fallback is Utility.force_batch, which records child failures but continues dispatching later children. The deliberately invalid force_unreserve child fails, then the following Balances.transfer_all(Id(attacker), false) succeeds with the signed multisig origin.

Exploit sequence

1

Attacker is a multisig signer

The attacker is one signer of a funded 2-of-2 multisig. They cannot transfer its funds alone.

2

Construct the dual-valid call

The attacker constructs the 268-byte dual-valid call above, using any attacker-controlled AccountId as the hidden transfer_all destination.

3

Submit the proposal

The attacker submits it through the normal multisig.propose dispatchable.

4

Honest signer reviews the proposal

The honest signer opens the proposal in the scoped mobile application.

5

Application decodes with shifted boundaries

The application fetches the exact stored bytes, but its generated MultiAddress::Index decoder consumes two bytes that the runtime treats as the start of the fallback call.

6

Benign interpretation is displayed

The review screen displays only if_else(add_member(Account index 258), Wormhole.verify_private_batch(257 opaque bytes)). It does not display a transfer or destination.

7

Honest signer approves

The honest signer confirms once. The application sends multisig.approve containing the exact same stored bytes, so the proposal reaches its 2-of-2 threshold.

8

Attacker executes the proposal

The attacker invokes multisig.execute.

9

Runtime decodes the hidden call tree

The runtime decodes the hidden force_batch/transfer_all tree and dispatches it as the multisig account.

10

Multisig balance is transferred

The multisig's full transferable balance is credited to the attacker.

Expected versus actual behavior

Expected: The semantic call tree displayed by a signer must be the same call tree the runtime will execute for those exact bytes. An honest signer should see transfer_all, its destination, and the fact that the multisig's full transferable balance will move before approving it.

Actual: The app and runtime assign different boundaries and types to the same canonical byte string. The app displays a non-transfer tree, while the runtime executes a hidden full-transferable-balance transfer. Binding approval to authoritative bytes does not bind approval to the semantics shown to the signer.

Public-known-issue differentiation

I reviewed the four published audit PDFs and the relevant public remediation history. I found no disclosure of the MultiAddress<AccountId, ()> versus CompactBigInt mismatch, a cross-codec SCALE polyglot, or this concrete theft path.

The closest public items have different root causes:

  • quantus-apps PR #555 addressed partial cold-payload parsing and network/genesis validation. It did not identify the unit-versus-compact Index mismatch.

  • quantus-apps PR #575, mobile audit M3, addressed a compromised indexer mislabelling proposal JSON while approvals were insufficiently bound. The scoped commit already fetches authoritative storage bytes and resubmits them exactly. This exploit requires no compromised indexer: those authoritative bytes themselves have two semantic decodings.

  • quantus-apps PR #585 added the inner call field to approvals and generalized call display. This exploit passes that exact-byte check.

The generated multi_address.dart remained unchanged on public upstream main through commit 7e316747be16b52a1e9bdefaa5f428c6e2b9892f, where Index still consumes CompactBigInt.

1

Generate the concrete runtime address codec

Generate the Dart address codec from the runtime's concrete generic type. For MultiAddress<AccountId, ()>, Index must carry a unit value and consume no bytes.

2

Reject unsupported variants in signer-display paths

Prefer rejecting every non-Id MultiAddress in signer-display paths because this runtime's AccountIdLookup<AccountId, ()> cannot resolve meaningful account indices. Fail closed instead of displaying unsupported variants.

3

Add cross-language conformance tests

Add tests in which Rust encodes every supported concrete RuntimeCall/MultiAddress variant and Dart must decode the same tree and consume the same boundaries.

4

Include adversarial nested Utility vectors

Assert semantic equivalence, not merely that both decoders consume all bytes or that Dart can re-encode its own interpretation.

5

Keep authoritative bytes and semantic validation

Keep loading authoritative proposal bytes and binding them into approve, but treat semantic decoder correctness as a separate mandatory security property.

6

Avoid opaque-field WYSIWYS assumptions

Avoid presenting large opaque fields as sufficient WYSIWYS for approval when they can conceal a second parse under a schema mismatch.

Proof of Concept

This PoC is fully self-contained below. It uses only local TestExternalities; do not run it against a public network.

It proves all of the following on the exact scoped commits:

  • The same 268 bytes decode to a hidden force_batch/transfer_all in the Rust runtime.

  • A real ML-DSA-65 signed extrinsic executes the hidden transfer through Executive::apply_extrinsic.

  • A real 2-of-2 multisig stores, approves, and executes the identical bytes, draining 1000 * UNIT to a real attacker key.

  • The production Dart decoder used by the mobile proposal model consumes all bytes but shows only if_else, add_member, and verify_private_batch, with no transfer or destination.

1. Runtime PoC

Clone and check out the exact chain commit:

Create runtime/tests/transactions/multiaddress_polyglot.rs with the complete contents below:

Add this one module-registration line to runtime/tests/transactions/mod.rs:

Run with Rust 1.93.0:

Observed result on commit 3b243b870a5442a6e5f443056f67bbba87d472f5:

The decisive assertions are:

2. Scoped SDK/mobile-decoder PoC

Clone and check out the exact apps commit:

Create quantus_sdk/test/multiaddress_ifelse_polyglot_test.dart with the complete contents below:

From quantus_sdk, run with Flutter 3.47.0 / Dart 3.13.0:

Observed result on commit d5fc4d6572ebc2244e95009a33f5da5048935867:

The Dart assertions prove that the exact byte string approved by the honest signer is rendered as:

and that the displayed tree contains no transfer_all, no force_batch, no Destination label, and no address-typed value. The Rust tests prove that the byte-identical stored proposal drains the multisig when executed.

Was this helpful?