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:
DuplicateSignerand its adjacent-pair check were deleted;normalize_signerswas introduced as "the single authoritative place", sorting and deduplicating;the doc comment on
derive_multisig_addresswas 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:119rejects a repeated signer in_addSignerbefore 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.
MultisigCreationPollingServicepolls it, times out, and reports a creation-timeout error (mobile-app/lib/services/multisig_creation_polling_service.dart:55), andMultisigService.discoverForUserfinds multisigs by querying the indexer for records where the user is a signer and takes the address from the record's ownid— 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-toolchainfrom thequantus_sdk/rustworkspace; 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-appsbranchaudit-comp-ready, commitd5fc4d6572ebc2244e95009a33f5da5048935867.
The chain-side reference for the comparison is
https://github.com/immunefi-team/audit-comp-quantus-chainbranchaudit-comp-ready, commit3b243b870a5442a6e5f443056f67bbba87d472f5, filepallets/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 thePalletIdbytes 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.rsverbatim 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 ownpubspec.lockdependencies. The Rust harness alone is sufficient to establish the finding.
Reproduction steps
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 missingdedup()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, thePalletIdor 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.
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?