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

89808 w a high unit accountindex consumes zero runtime bytes while the sdk consumes the next field allowing one canonical approval to display no attacker transfer while transferring 999 999 qu

Submitted on Aug 21st 2026 at 10:01:27 UTC by @legat for Audit Comp | Quantus

  • Report ID: #89808

  • Report Type: Websites & Apps

  • Report severity: High

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

  • Impacts:

    • Direct theft of user funds

    • Malicious interactions with an already-connected wallet, such as:

      • Modifying transaction arguments or parameters

      • Substituting contract addresses

      • Submitting malicious transactions

Description

Summary

The frozen Quantus runtime concretizes account lookup as MultiAddress<AccountId, ()>. Therefore the canonical runtime encoding of Index(()) is only the one-byte enum tag: the unit payload consumes zero bytes. The checked-in scoped Dart binding instead models Index as BigInt and consumes a CompactBigInt from bytes that the runtime assigns to the next field.

An attacker can align ordinary nested call fields so both parsers consume every byte and return complete call trees, yet assign different semantics to the same canonical byte string. Exact trailing-byte checks, exact byte-bound approval, and runtime decode/re-encode canonicalization all pass.

The report-specific E2E uses 88 canonical bytes. The frozen official SDK displays:

  • a transfer of only 130 atomic units to Account index 0; and

  • a Recovery.as_recovered(..., System.remark(...)) fallback.

The frozen runtime instead sees a guaranteed-failing indexed transfer followed by Utility.batch_all, whose hidden third child is Balances.transfer_all(Id(attacker), keep_alive=true). A genuine Bob approval crosses a real 2-of-2 threshold; Alice then executes. From a 1,000 QUAN multisig, the arbitrary attacker account receives exactly 999.999 QUAN while the shared account remains alive with the 0.001 QUAN existential deposit.

Intended behavior

The official review tree must have exactly the same variants, field boundaries, addresses, values, and nested calls as the runtime interpretation of the byte-identical call being approved. An address form incompatible with the connected runtime must block authorization.

Actual behavior

The runtime declares:

and configures:

The generic Rust MultiAddress<AccountId, AccountIndex> has:

With AccountIndex = (), compact unit has zero encoded payload bytes. Runtime Index(()) therefore consumes only tag 0x01.

The scoped generated Dart type instead declares:

The Dart codec consumes at least one following byte as the compact integer. Every later field begins at a different offset. Because nested calls contain variable-length vectors and enums, an attacker can make the shifted suffix form a complete second tree rather than leaving trailing bytes.

Exact canonical 88-byte witness

The reference run substitutes the real Crystal Charlie AccountId into bytes 52-83. Its complete stored witness was:

The attacker destination is not structurally fixed: the test replaces the aligned 32-byte slot with getAccountId32(charliePair.ss58Address). Any ordinary 32-byte attacker AccountId can occupy that runtime Id slot.

Frozen SDK interpretation

The SDK consumes all 88 bytes, so its exact-fit guard succeeds. The rendered tree contains no batch_all, no attacker destination, no transfer_all, no entire-balance semantics, and no keep_alive=true parameter.

Frozen runtime interpretation

The runtime consumes all 88 bytes and re-encodes them byte-for-byte. AccountIdLookup<AccountId, ()> rejects the primary Index(()), so the intended if_else behavior dispatches the fallback under the same signed multisig origin. All batch_all children then succeed atomically, including the hidden transfer_all.

Root cause

The eligible Quantus integration defect has three necessary parts:

  1. The committed scoped SDK materializes the runtime's unit AccountIndex as BigInt, consuming a nonzero-width compact payload.

  2. Quantus's handwritten CallDecoder._addressField() treats Index as a normal actionable Account index n, even though this runtime's AccountIdLookup<AccountId, ()> resolves only Id.

  3. Multisig review and approval accept the resulting exact-fit wrong tree, then preserve the same bytes for runtime approval/execution.

Affected immutable source:

Upstream SCALE and MultiAddress behave consistently with the Rust type. This report targets Quantus's committed concrete binding and Quantus-authored actionable consumer, both within the selected SDK asset. A complete Quantus-owned immediate fix exists: reject non-Id forms before actionable review.

Risk

Impact

The E2E measures direct theft:

The victim's approval is required to cross the threshold. The official SDK presents materially different calls and parameters from the runtime, while the approval commits to the exact hidden bytes. This independently matches malicious interaction with an already-connected wallet.

No account is reaped: keep_alive=true preserves exactly the existential deposit. No replay, unavailable-account, indirect market-loss, or attacker-profit inference is needed.

Likelihood

  • attacker is one ordinary member of a normal non-high-security 2-of-2;

  • one canonical 88-byte proposal and normal fees/deposit;

  • arbitrary real 32-byte attacker destination; no vanity-key grind;

  • ordinary 1,000 QUAN balance used in project multisig documentation;

  • no malformed/trailing bytes, race, oracle, compromised indexer, governance, Technical Collective, admin, guardian, or unsafe configuration;

  • no victim key theft; Bob supplies only his own expected approval.

The displayed Recovery/remark fallback is unusual, so the report does not claim every user will approve or that it resembles a plain transfer. The security fact is exact: the authoritative official review identifies none of the value-moving runtime call, destination, or amount.

Attack path

1

Alice and Bob are ordinary members of a normal 2-of-2 multisig.

2

Bob funds the shared account with 1,000 QUAN.

3

Alice submits the 88-byte canonical proposal.

The runtime stores it exactly and records Alice's automatic first approval.

4

The official SDK decodes the same authoritative bytes.

It displays a 130-atomic-unit Index(0) transfer plus Recovery/remark fallback.

5

Bob performs the ordinary approval action over those exact stored bytes.

6

Runtime call == proposal.call succeeds.

Bob's signature becomes the second approval and the proposal becomes executable.

7

Alice executes through the ordinary multisig member path.

8

Runtime lookup rejects primary Index(()).

if_else dispatches the hidden batch_all fallback.

9

batch_all dispatches transfer_all(Id(attacker), keep_alive=true) as the multisig.

10

The attacker receives 999.999 QUAN.

0.001 QUAN remains in the multisig.

The PoC proves signed creation/funding/proposal/approval/execution transactions, exact storage bytes, the wrong official SDK tree, threshold state, and balance equality. Mobile source establishes that the same decoder result is used by the actionable confirmation; this report does not claim a device UI was automated.

Preconditions

External preconditions

  • The normal proposal feed surfaces the active proposal to the victim.

  • The victim uses the official SDK/mobile review and approves it.

No compromised discovery processor is required. The service replaces indexed content with authoritative storage bytes before final review.

Internal preconditions

  • Frozen/current vulnerable SDK type mapping.

  • Normal non-high-security multisig.

  • Reducible native balance above the existential deposit.

Affected code and complete flow

Role
Code
Security effect

Concrete type mismatch

quantus_sdk/.../multi_address.dart:152-174

Consumes nonexistent compact index payload

Exact-fit check

call_decoder.dart:48-59

Proves only Dart self-consumption, not runtime semantic equivalence

Actionable address rendering

call_decoder.dart:821-846

Presents impossible Index as a normal destination

Authoritative hydration

multisig_service.dart:293-325

Uses exact bytes but wrong semantic decoder

Approval

multisig_service.dart:395-448

Signs/submits exact concealed bytes

Mobile confirmation

multisig_action_confirm_sheet.dart:156-210,345-407

Uses decoded tree and leaves normal confirmation actionable

Runtime ground truth

runtime/src/lib.rs:134-135

Concretizes index to unit

Threshold/dispatch

pallets/multisig/src/lib.rs:779-825,1123-1166

Counts exact-byte approval and dispatches as shared account

Other address-bearing calls and Utility wrappers share the same type root and must remain merged, not submitted as duplicates.

1

Correct generation for MultiAddress<AccountId, ()>.

Ensure Index(()) consumes/emits only its variant tag.

2

Reject every non-Id address in actionable review/signing.

Current AccountIdLookup<AccountId, ()> cannot resolve it.

3

Treat semantic-support failures as blocking.

Disable Approve/Execute and show a hard error.

4

Differential-test Rust-produced bytes against Dart decoding.

Include every active RuntimeCall, variants, field boundaries, addresses, values, remaining bytes, and runtime re-encoding.

5

Include nested wrappers and vectors.

Include if_else, batch, batch_all, force_batch, multisig, Recovery, and ReversibleTransfers vectors.

6

Bind the generated concrete type graph/metadata fingerprint to the connected runtime before actionable review.

7

Retain chain canonical decode/re-encode checks as defense in depth.

They cannot detect two decoders assigning different semantics to the same canonical bytes.

An immediate Quantus-side fail-closed guard can be:

Proof of Concept

Proof boundary and topology

The report-specific PoC exercises the exact frozen release node/runtime spec 143, Quantus Rust key derivation, SDK transaction construction/signatures, real balances, 2-of-2 creation, funding, canonical proposal storage, authoritative retrieval, wrong production SDK tree, exact-byte approval, threshold crossing, runtime dispatch, and final balances.

One exact release node is sufficient. This is not a consensus, validator-divergence, network-partition, P2P, or cluster claim. The proof boundary is deterministic encoding/storage, real signatures and threshold state, one runtime's canonical dispatch, SDK/mobile semantic behavior, and balances. A validator cluster would execute the same finalized call semantics and add no missing evidence.

The only test-local adapters are preference/secure-storage implementations that provide documented disposable dev mnemonics. No multisig, proposal, RPC response, decoder tree, signature, storage value, call result, or balance is mocked.

File layout

Place these files in the frozen apps checkout; keep sibling audit-comp-quantus-chain:

Complete Dart E2E PoC

Save as quantus_sdk/test/poc/unit_index_alias_node_e2e_test.dart:

Complete verifier and runner

Save as poc/quantus-report-3/run.sh and make executable:

Run:

Captured reference node fingerprint

The runner records the complete runtime-version response including the apis array.

Captured reference E2E output

These are, in order, real signed create, funding, propose, Bob approve, and Alice execute extrinsic hashes. The storage-read assertion occurs after proposal inclusion and before approval.

Determinism, controls, and falsification

The test fails unless:

  • the exact frozen SDK fully consumes and renders the wrong 88-byte tree;

  • the exact frozen runtime accepts/stores the same bytes;

  • retrieved storage bytes equal the submitted list;

  • Bob's real signed approval reaches two approvals;

  • Alice's real signed execution removes the proposal;

  • the multisig ends at exactly 1,000,000,000 planck;

  • attackerGain == multisigLoss == 999,999,000,000,000 planck.

keep_alive=true is an explicit negative control against account-reaping/replay arguments. Replacing the aligned destination with a real AccountId demonstrates arbitrary-recipient control. A correct unit binding or a fail-closed non-Id policy causes the wrong-tree assertion/approval path to fail.

Reject or revise this report if either decoder leaves bytes unconsumed, runtime decode/re-encode is not canonical, the normal multisig refuses the call, approval does not preserve the exact bytes, the fallback rolls back, or the recipient gain differs from shared-account loss. None occurred.

Was this helpful?