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

88305 w a low quantus sdk predicts a multisig address the chain never creates because its derivation omits the pallet s signer deduplication

Submitted on Aug 12th 2026 at 17:27:44 UTC by @ret2basic for Audit Comp | Quantus

  • Report ID: #88305

  • Report Type: Websites & Apps

  • Report severity: Low

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

  • Impacts:

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

      • Modifying transaction arguments or parameters

      • Substituting contract addresses

      • Submitting malicious transactions

Description

Brief/Intro

pallet-multisig derives a multisig account from the sorted and deduplicated signer list and documents that determinism as the reason callers may pre-compute the address before creating the multisig. The Quantus SDK is that pre-computation, and it only sorts: neither quantus_sdk/rust/src/api/multisig.rs nor the Dart guard in front of it removes or rejects a repeated signer. For any signer list containing a duplicate the SDK returns one address while create_multisig succeeds at a different one, and the wallet persists and displays the SDK's answer as the multisig's identity. Funds sent to that displayed address land on a Blake2-256 derived account that has no key and no multisig record, and are permanently unrecoverable.

Vulnerability Details

The chain deduplicates, and says so

pallets/multisig/src/lib.rs:1227-1232 and pallets/multisig/src/lib.rs:1234-1243:

Accepting a duplicate-bearing list is deliberate on the chain side; the pallet's own test create_multisig_deduplicates_signers (pallets/multisig/src/tests.rs:249-273) submits vec![bob(), bob(), charlie()] and comments "Should succeed - duplicates are silently removed". create_multisig bounds the raw length against MaxSigners, normalizes, and then checks normalized_signers.len() >= 2 and threshold <= normalized_signers.len(), so [A, A, B] with threshold 2 is a valid, successful call.

The SDK's pre-computation only sorts

quantus_sdk/rust/src/api/multisig.rs:8-25:

There is no dedup(). Because Vec<AccountId32>::encode() length-prefixes and then emits every element, [A, A, B] and [A, B] produce different preimages and therefore different addresses.

How the two implementations came apart

The SDK's derivation was correct once. Before commit 1d299f1 ("Address Eiger Multisig Audit - 2", 2026-04-30) the chain rejected a duplicate-bearing signer list outright — create_multisig sorted, then compared adjacent entries and raised Error::DuplicateSigner — while derive_multisig_address only sorted. A sort-only client matched that contract exactly, and the May review's EQ-QNT-MULTISIG-O-02 records precisely those two shapes side by side, observing that "signer normalisation and duplicate checks are split across multiple layers" and recommending they be pushed "into a single authoritative boundary".

That single commit adopted the recommendation on the chain side and, in doing so, changed the contract:

  • DuplicateSigner and its adjacent-pair check were deleted;

  • normalize_signers was introduced as "the single authoritative place", sorting and deduplicating;

  • the doc comment on derive_multisig_address was rewritten to "Signers are automatically sorted and deduplicated internally for deterministic results. This allows users to pre-compute the address before creating the multisig."

A rejection became a silent normalisation, so the input that used to be refused now succeeds and lands at a different address. The client that performs the pre-computation that sentence promises was not updated with it, and is still the pre-remediation shape. This report is therefore not a restatement of EQ-QNT-MULTISIG-O-02 — that observation was informational and about code organisation inside one pallet — but a downstream consequence of its remediation, in a different in-scope asset.

Nothing upstream of it removes the duplicate

quantus_sdk/lib/src/services/multisig_service.dart:216-223 guards both the prediction (multisig_service.dart:74) and the create-call (multisig_service.dart:117) with the same predicate, which checks only cardinality:

predictMultisigAddress forwards the caller's list unchanged to the Rust derivation, and buildCreateMultisigCall forwards the same unchanged list into the create_multisig extrinsic. So one call site produces the address and the other produces the transaction, from the same unnormalized input, and only the chain normalizes.

The SDK's own two tests for this function — predict_multisig_address_is_order_independent and predict_multisig_address_golden_vector — both use distinct signers, so no test in the repository ever exercises the duplicate case.

The asset's own collision-avoidance degrades to a no-op

The divergence is not confined to one function returning a wrong string. quantus_sdk ships a routine whose documented job is to avoid address collisions, and on exactly the inputs that trigger this bug it cannot do it.

quantus_sdk/lib/src/services/multisig_service.dart:83-112 — "Finds the lowest nonce whose predicted address is not taken":

Every address this loop tests is produced by the same sort-only derivation. For a duplicate-bearing signer list the chain will never create an account at any of them, for any nonce. So taken(address) is false on the first iteration and stays false forever: the loop always returns immediately, always declares the default nonce free, and never tests the takenness of the address the chain is actually going to use. The routine is structurally inoperative for precisely the inputs that need it, and it hands the caller the unreachable address back as MultisigCreationParams.address — the value callers then persist and display as the multisig's identity.

The same inversion appears in the shipped consumer: mobile-app/lib/services/multisig_submission_service.dart:120 throws MultisigAlreadyExistsException when isMultisigIndexed(predictedAddress) is true, so its pre-existence check is also asked about the address that cannot exist rather than the one that will. Neither the SDK's guard nor the app's guard is looking at the account the extrinsic creates.

Observed divergence

Running the attached PoC at the frozen commits, with threshold = 2, nonce = 0, and the two signers already used by the SDK's own golden-vector test:

The chain's address for the duplicate-bearing input equals the SDK's address for the deduplicated input, and equals the value already pinned in the SDK's golden-vector test — so the divergence is attributable to the missing dedup() and to nothing else in the encoding, hasher, PalletId or SS58 prefix. The negative control confirms the two implementations agree whenever the input has no duplicate.

How the wrong address becomes the wallet's stored identity

mobile-app/lib/services/multisig_submission_service.dart:114-126 builds the local account record from the predicted address before submitting:

MultisigAccount.accountId is the identity the wallet stores, displays and polls. With a duplicate in the list the extrinsic itself still succeeds, so the submission reports nothing wrong; the wallet then polls the predicted address, never finds the multisig there, and after MultisigCreationPollingService's timeout (mobile-app/lib/services/multisig_creation_polling_service.dart:49-56) drops the pending record and shows a generic creation-timeout error. The multisig it just paid to create exists on chain at a different address.

Why this row, and what it does not claim

The impact list available to this asset has no row for funds lost through a client-side defect with no adversary. I read all four tiers before choosing: arbitrary system commands, retrieving server files, taking down the site, subdomain takeover, direct theft, XSS and HTML injection, acting on behalf of other users, PII disclosure, static content injection, open redirect, expired-link takeover, locking a user out of the site. None of them is written for this.

I selected "Malicious interactions with an already-connected wallet" because one of its enumerated effects is literally what happens here — substituting the address the connected wallet uses. MultisigAccount.accountId is set to the SDK's predicted address (mobile-app/lib/services/multisig_submission_service.dart:114), and the wallet then stores, displays and polls that address as the multisig's identity, while the account the extrinsic actually creates is a different one. The address the connected wallet operates on has been substituted for one that does not exist.

There is no attacker, and I am not claiming one. Nothing in this report describes an adversary, a beneficiary, or a malicious transaction. The substitution is produced by the SDK's own derivation, and the funds are not taken by anyone — they are stranded on an account that has no key, no multisig record, and no path by which one could ever be created. I chose the row whose effect matches and I am stating plainly that its adjective does not, rather than letting the wording imply an adversary that is not there. "Direct theft of user funds" would have been the alternative; I did not select it because no theft occurs and no one gains.

If the panel judges that the absence of an adversary puts this outside the row it otherwise matches, I ask, as a fallback only, that this be designated an Insight under the programme's Insight Reporting rule rather than closed. I raise the fallback because the programme provides one and I would rather route this correctly than win a categorisation argument; I cannot self-select it, since the rule reserves that designation to Immunefi or the project team.

Impact Details

The realised harm is that funds sent to the substituted address are unrecoverable, and it is the ceiling rather than the certainty: it is reached exactly when a caller funds the address the SDK returned. Fixing the SDK prevents every future occurrence and needs no hardfork; only recovering funds already sent to a divergent address would require a runtime upgrade.

That address is unrecoverable when it happens. It is AccountId32::decode(TrailingZeroInput(BlakeTwo256(pallet_id || [A, A, B].encode() || threshold || nonce))): not the image of any public key, so no signature can ever authorise a transfer out of it, and with no entry in Multisig::Multisigs, so no propose/approve/execute path can move funds from it either. No runtime call recovers an account in this state; only a runtime upgrade could. Nor can that entry appear later: every production derivation reaches derive_multisig_address_inner with an already-normalized list — create_multisig passes normalized_signers (pallets/multisig/src/lib.rs:457) and the public derive_multisig_address normalizes first (pallets/multisig/src/lib.rs:1244-1245) — and normalize_signers deduplicates, so no list the chain ever hashes contains a repeat. The preimage [A, A, B].encode() is therefore not reachable by any call, which is what makes the freeze permanent rather than merely current: neither the participants nor an attacker can later create a multisig at that address and unlock it. The amount at risk is whatever the participants fund their multisig with, and every later deposit to the same address is lost the same way.

Within the asset under report there is no mitigation at all. quantus_sdk accepts the duplicate-bearing list at predictMultisigAddress, accepts it again at buildCreateMultisigCall, validates only cardinality in the predicate shared by both (quantus_sdk/lib/src/services/multisig_service.dart:216-223), and — as shown above — runs its collision-avoidance loop against addresses the chain will never create. The SDK is a library published for integrators; its public API is its attack surface, and nothing in it rejects or normalises the input.

The one guard that exists lives in a different in-scope asset (.../mobile-app, listed separately on the scope page) and is a string comparison in a single view, not a property of the SDK. I record it because it is true and it limits what one particular consumer will do, not because it bounds the defect in the asset I am reporting:

  • mobile-app/lib/v2/screens/multisig/add_multisig_screen.dart:119 rejects a repeated signer in _addSigner before any prediction is made, so the wallet's own creation screen cannot produce the divergent input.

  • Even if it could, the wallet never presents the predicted address as a funding target. MultisigCreationPollingService polls it, times out, and reports a creation-timeout error (mobile-app/lib/services/multisig_creation_polling_service.dart:55), and MultisigService.discoverForUser finds multisigs by querying the indexer for records where the user is a signer and takes the address from the record's own id — the chain's address (quantus_sdk/lib/src/services/multisig_service.dart:57). So the real multisig stays discoverable and the funds stay reachable.

The exposure is therefore the SDK as a distributed library rather than the wallet as a shipped app. MultisigService.predictMultisigAddress is documented as predicting the on-chain multisig address and is the SDK's only API for doing so; the chain's own derive_multisig_address documents deduplication as precisely what makes that pre-computation possible. A third-party integrator calling it as documented, with a signer list the SDK's validator and the chain's extrinsic both accept, receives an address the chain will never create, with no error and no signal that anything is wrong — and the failure only becomes visible after value has been sent somewhere unrecoverable.

References

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

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

  • https://docs.rs/parity-scale-codec/latest/parity_scale_codec/trait.Encode.html

  • https://docs.rs/sp-runtime/latest/sp_runtime/traits/struct.TrailingZeroInput.html

Proof of Concept

Prerequisites

  • A Rust toolchain able to build the frozen SDK crate's dependencies (the PoC pins rust-toolchain from the quantus_sdk/rust workspace; stable 1.93 was used).

  • A local checkout of the in-scope SDK asset at the frozen competition commit:

    • https://github.com/immunefi-team/audit-comp-quantus-apps branch audit-comp-ready, commit d5fc4d6572ebc2244e95009a33f5da5048935867.

  • The chain-side reference for the comparison is https://github.com/immunefi-team/audit-comp-quantus-chain branch audit-comp-ready, commit 3b243b870a5442a6e5f443056f67bbba87d472f5, file pallets/multisig/src/lib.rs. That repository is a different asset and is not checked out here, so the harness reimplements the pallet's derivation locally from that source and pins the PalletId bytes it uses; the negative control below is what proves the reimplementation faithful.

  • No network access, no chain node, no funded account and no key material is required. Nothing outside the PoC directory is modified: the harness includes the frozen SDK file quantus_sdk/rust/src/api/multisig.rs verbatim with #[path] and only stubs the #[flutter_rust_bridge::frb(sync)] attribute so the file compiles without the Flutter bridge.

  • The optional Dart reachability check additionally needs the Flutter SDK and quantus_sdk's own pubspec.lock dependencies. The Rust harness alone is sufficient to establish the finding.

Reproduction steps

1

Place the PoC files

Place the PoC files at the paths shown below, relative to the root of the audit-comp-quantus-apps checkout.

2

Run the harness

From that root, run the harness:

3

Observe the output

Observe the printed addresses and the final assertions: PASS line. The harness asserts, and the run reproduces:

The three relations that matter are all asserted by the harness, not merely printed:

  • SDK([A, A, B], 2, 0) != CHAIN([A, A, B], 2, 0) — the SDK predicts an address the chain does not create.

  • CHAIN([A, A, B], 2, 0) == SDK([A, B], 2, 0) — the chain's answer is exactly the SDK's answer for the deduplicated list, so the missing dedup() is the whole of the difference.

  • SDK([B, A], 2, 0) == CHAIN([B, A], 2, 0) — negative control: with no duplicate in the input the two implementations agree, which rules out an unrelated mismatch in the SCALE encoding, the hasher, the PalletId or the SS58 prefix.

qzkvQ2YBa7RrvYKNCXxJw7hJZPBSi6GzZxXGN2fN6DddaH5HJ is also the value pinned by the SDK's own predict_multisig_address_golden_vector test, so the chain-side reimplementation agrees with the SDK on every input the SDK already has a fixture for.

4

Optionally confirm Dart reachability

Optionally, confirm that the SDK's public Dart surface accepts the duplicate-bearing list rather than rejecting it, from the same checkout root:

The first case asserts that MultisigService.buildCreateMultisigCall accepts [A, A, B] and emits the extrinsic with all three entries preserved — that is the call the chain then normalizes to [A, B]. The second asserts that MultisigService.predictMultisigAddress raises no ArgumentError for the same list, i.e. the shared _validateSignersAndThreshold predicate does not reject duplicates for either minSigners value used in the service.

Complete runnable PoC files

Each code block below is the complete file named in its heading. Save the blocks under poc-inputs/ using those paths, then follow the reproduction steps above.

poc-inputs/poc/multisig_duplicate_derivation/Cargo.toml

poc-inputs/poc/multisig_duplicate_derivation/frb_attr_stub/Cargo.toml

poc-inputs/poc/multisig_duplicate_derivation/frb_attr_stub/src/lib.rs

poc-inputs/quantus_sdk/test/poc_multisig_duplicate_reachability_test.dart

poc-inputs/poc/multisig_duplicate_derivation/src/main.rs

Was this helpful?