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

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; // 10KB

https://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.

1

Build the node

2

Start the local validator

3

Run the PoC

Add multisig-depth-poc to workspace members, save the two files below, then run:

multisig-depth-poc/Cargo.toml:

multisig-depth-poc/src/main.rs:

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?