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; anda
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:
The committed scoped SDK materializes the runtime's unit
AccountIndexasBigInt, consuming a nonzero-width compact payload.Quantus's handwritten
CallDecoder._addressField()treatsIndexas a normal actionableAccount index n, even though this runtime'sAccountIdLookup<AccountId, ()>resolves onlyId.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
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
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.
Recommended mitigation
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,000planck;attackerGain == multisigLoss == 999,999,000,000,000planck.
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?