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, ()>, soIndex(())is encoded as the one-byte enum discriminant0x01and consumes no value bytes.The generated Dart SDK treats the same variant as
Index(CompactBigInt)and consumes a compact integer after0x01.
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:
3b243b870a5442a6e5f443056f67bbba87d472f5Apps/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:
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:
MultisigService._withOnChainCallData
It replaces indexer data with authoritative bytes from Multisig.Proposals.
Mobile confirmation sheet
It passes those bytes to CallDecoder.decodeBytes.
Rendered call tree
It renders the resulting tree through DecodedCallView.
Approval
The approve flow resubmits the same authoritative bytes in multisig.approve.
Byte-equality check
The chain's byte-equality check passes because no bytes were changed.
Execution
At execution, the runtime decodes the stored bytes with its own schema and dispatches them as the multisig.
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
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
Indexmismatch.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
callfield 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.
Recommended remediation
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_allin 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 * UNITto a real attacker key.The production Dart decoder used by the mobile proposal model consumes all bytes but shows only
if_else,add_member, andverify_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?