90329 bc high pallet multisig propose decodes opaque inner calls without a depth limit trapping the block producer
Submitted on Aug 24th 2026 at 13:37:42 UTC by @Kuzomo for Audit Comp | Quantus
Report ID: #90329
Report Type: Blockchain/DLT
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Causing network processing nodes to process transactions from the mempool beyond set parameters
Description
Brief/Intro
pallet_multisig::propose takes the inner call as opaque bytes and decodes them inside the runtime with a plain recursive RuntimeCall::decode. Substrate bounds extrinsic decode recursion at MAX_EXTRINSIC_DEPTH = 256, but that ceiling is applied when the outer extrinsic is decoded, and the outer extrinsic only ever sees a BoundedVec<u8> — the nesting inside those bytes is invisible to it.
Upstream pallet_multisig does not have this problem because it takes a typed Box<RuntimeCall>, which the outer decode does inspect. A signer of any multisig can therefore submit ~3400 nested Utility::batch wrappers inside the 10240-byte MaxCallSize budget, pass pool validation normally, and have the block producer exhaust its WASM stack while dispatching the transaction.
In the run below the trap unwinds 5778 frames inside BlockBuilder_apply_extrinsic, block preparation goes from 1 ms to 15 ms, and the transaction is then discarded as invalid without consuming the sender's nonce or charging a fee. Every guard the pallet has for this — the full-weight burn on decode failure, MaxInnerCallWeight, and the ProposalDeposit reserve — is positioned after the decode and so cannot fire.
Vulnerability Details
propose accepts the inner call as raw bytes rather than a typed call:
pallets/multisig/src/lib.rs:535 pub fn propose(
pallets/multisig/src/lib.rs:538 call: BoundedCallOf<T>,
pallets/multisig/src/lib.rs:251 pub type BoundedCallOf<T> = BoundedVec<u8, <T as Config>::MaxCallSize>;
runtime/src/configs/mod.rs:648 pub const MaxCallSize: u32 = 10240; // 10KBhttps://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/multisig/src/lib.rs#L535-L540
The ceiling exists and the outer extrinsic is decoded under it:
https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/frame/executive/src/lib.rs#L877-L879
Upstream takes call: Box<<T as Config>::RuntimeCall>, so a typed inner call is covered by that same decode. An opaque Vec<u8> is not: the executive decodes the byte vector as bytes, and the nesting inside it is never walked. The inner decode happens later, at dispatch, with no limit:
https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/multisig/src/lib.rs#L599
The comment states the intended anti-griefing control: a malformed call burns the reserved weight so the sender pays. That control only covers a decode Err. A stack overflow is a WASM trap, not an Err, so the map_err arm is never reached and the whole extrinsic is unwound instead.
The two other guards sit even further downstream and are likewise unreachable:
DecodeLimit and decode_all_with_depth_limit do not appear anywhere under runtime/ or pallets/.
The same unbounded decode is repeated on the stored blob in execute:
https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/multisig/src/lib.rs#L1125
Building the payload is trivial. Utility::batch { calls: [inner] } encodes as pallet index 0x09, call index 0x00, compact length 0x04 — three bytes per level, with [0x09,0x00,0x00] as the innermost empty batch. 10240 bytes of MaxCallSize therefore buys roughly 3400 levels, over thirteen times the 256 the platform permits.
Pool validation runs the TxExtension pipeline and not dispatch, so the transaction is accepted and gossiped as an ordinary signed extrinsic; the recursion only happens when a producer picks it up and calls apply_extrinsic.
Impact Details
A block producer processes a transaction taken from its own mempool far beyond the decode-depth parameter the runtime cannot enforce on opaque bytes, and pays for it while the sender does not.
Measured on a --dev validator at depth 3400: the trap unwinds 5778 frames through BlockBuilder_apply_extrinsic -> do_apply_extrinsic -> pallet_multisig::Call::dispatch_bypass_filter -> RuntimeCall::decode, and preparation of the block that picks the transaction up takes 15 ms against a 1 ms baseline on every other block in the same run (14–15 ms across repeated runs).
The transaction is then marked Extrinsic invalid and removed from the pool, so the sender's nonce is not consumed and no fee is taken. Creating the multisig costs a one-time non-refundable MultisigFee of 0.6 UNIT (runtime/src/configs/mod.rs:649, withdrawn at pallets/multisig/src/lib.rs:466) and the sender must keep enough balance to pass fee validation; after that every attempt is free and repeatable, since changing the nesting depth by one produces a different transaction hash.
Sustained cost is bounded, and the bound is worth stating plainly: because the discarded transaction never consumes its nonce, further submissions from the same account queue behind it as set="future" and never reach the producer. That yields one trap per resubmission at the current nonce — ~14 ms of wasted authoring against a roughly 20 s block interval in the observed run — rather than an unbounded stall.
Block production continued normally throughout every measurement, and no funds, balances or chain state were affected.
Feasibility Limitations Check
No trusted role is involved at any point and nothing is misconfigured. create_multisig and propose are permissionless dispatchables callable by any funded account; the attacker creates their own multisig with themselves as a signer, so no existing multisig or third party has to participate.
The payload stays inside the pallet's own declared MaxCallSize, so no parameter is exceeded and no limit is bypassed to reach the sink — the transaction is well-formed by every check the runtime applies before dispatch.
Attack investment is the one-time 0.6 UNIT multisig fee plus enough balance to pass fee validation. The behaviour is a property of the pinned competition source at 3b243b870a5442a6e5f443056f67bbba87d472f5; no runtime upgrade, governance action or future provisioning is required, and the code path is live in the runtime as deployed (Multisig is registered at runtime::pallet_index(19)).
Recommendation
Decode the opaque call under the same ceiling the outer extrinsic uses, at both sinks:
This is the same call frame_executive already makes on the outer extrinsic (frame/executive/src/lib.rs:877), applied to the inner bytes.
decode_all_with_depth_limit returns Err on an over-deep payload rather than trapping, which routes the attempt back into the existing err_burn_full_raw path so the sender pays the reserved weight exactly as the pallet already intends.
Alternatively, restore the upstream typed parameter call: Box<<T as Config>::RuntimeCall> so the outer extrinsic decode enforces the limit and no in-runtime decode is needed at all.
References
opaque call parameter: https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/multisig/src/lib.rs#L535-L540
unbounded decode at dispatch: https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/multisig/src/lib.rs#L596-L606
weight and deposit guards, both after the decode: https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/multisig/src/lib.rs#L604-L650
same unbounded decode in execute: https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/multisig/src/lib.rs#L1125
MaxCallSize and MultisigFee: https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/runtime/src/configs/mod.rs#L648-L649
depth ceiling constant: https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/frame/support/src/lib.rs#L40
executive applies it to the outer extrinsic: https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/frame/executive/src/lib.rs#L877-L879
Proof of Concept
Local --dev node only; no transaction was sent to any public network. The chain uses Dilithium signatures, which polkadot-js and subkey cannot produce, so the payload is signed with the repository's own crates.
The PoC must be a workspace member: a standalone crate resolves upstream frame-system instead of the in-tree fork through the workspace [patch.crates-io] table and fails to compile.
Observed output:
The second author_submitExtrinsic is the one that matters: it submits the same transaction through the ordinary path, so the block producer dispatches it while authoring.
From the same run, the producer states plainly that it took the transaction and the runtime aborted:
Every other block prepared in that run reports (1 ms). The transaction is never included — the block still carries only the timestamp inherent — yet the producer paid 15 ms to attempt it, and the sender's nonce and balance are unchanged afterwards.
Was this helpful?