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

89058 bc high pallet multisig decodes an opaque inner call with no depth limit a 4 8 kb nested blob traps the runtime before the weight guard can fire so the work is never charged

#89058 [BC-High] pallet-multisig decodes an opaque inner call with no depth limit: a 4.8 KB nested blob traps the runtime before the weight guard can fire, so the work is never charged

Submitted on Aug 16th 2026 at 21:26:23 UTC by @Naresh_Kandula for Audit Comp | Quantus

  • Report ID: #89058

  • 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

    • Inaccuracies to the weight (similar to gas) accounting system that under or overcharge by less than 50% of the correct amount (and are not intentional as indicated by the program's terms

Description

pallet-multisig decodes an opaque inner call with no depth limit: a 4.8 KB nested blob traps the runtime before the weight guard can fire, so the work is never charged

Severity: High Impacts: Causing network processing nodes to process transactions from the mempool beyond set parameters (High) · Inaccuracies to the weight accounting system that under or overcharge by more than 50% of the correct amount, and are not intentional as indicated by the program's terms (Medium) Target: https://github.com/immunefi-team/audit-comp-quantus-chain — branch audit-comp-ready, HEAD 3b243b8 · Blockchain/DLT Root cause: chain/pallets/multisig/src/lib.rs:602 and chain/pallets/multisig/src/lib.rs:1128


Summary

pallet-multisig carries its inner call as an opaque byte blob and decodes it with a plain, unbounded Decode. RuntimeCall's derived Decode is recursive and Utility::batch re-enters it, so recursion depth is attacker-controlled. The same repository defines MAX_EXTRINSIC_DEPTH = 256 and applies decode_all_with_depth_limit at every other decode boundary — but the multisig inner call travels as bytes inside the outer extrinsic, so that limit never sees it.

At ~1,500 levels of nesting — a blob of 4,803 bytes, well inside the 10,240-byte MaxCallSize — the decode traps rather than returning Err. Because it traps, the pallet's own anti-griefing mitigation (err_burn_full_raw on decode failure) never runs, the MaxInnerCallWeight gate downstream is never reached, apply_extrinsic aborts, and the extrinsic is never included — so no fee of any kind is ever charged. Each attempt costs a block-producing node ~2.7 ms of real work and costs the attacker nothing.

Root cause

The codebase already knows this defence — it is applied everywhere else

This is not a missing feature; it is a missing call:

location
guard

frame/support/src/lib.rs:40

pub const MAX_EXTRINSIC_DEPTH: u32 = 256;

frame/executive/src/lib.rs:877

decode_all_with_depth_limit(MAX_EXTRINSIC_DEPTH, ..)

frame/executive/src/lib.rs:983

decode_all_with_depth_limit(MAX_EXTRINSIC_DEPTH, ..)

frame/support/src/traits/misc.rs:1043

decode_all_with_depth_limit(MAX_EXTRINSIC_DEPTH, ..)

frame/support/src/traits/misc.rs:1108

decode_all_with_depth_limit(MAX_EXTRINSIC_DEPTH, ..)

pallets/multisig/src/lib.rs:602

RuntimeCall::decode — none

pallets/multisig/src/lib.rs:1128

RuntimeCall::decode — none

The outer extrinsic is depth-limited at 256. The executive sees a Vec<u8>, not a call tree, so the inner call bypasses it entirely. A repo-wide grep confirms these two are the only unbounded-depth RuntimeCall decodes in pallets/ and runtime/:

The guard written to stop exactly this cannot fire

The comment above the decode states the intended mitigation in the developers' own words:

"NOTE: Decode cost is O(inner_call_count) for nested calls, not O(bytes). On decode failure, we burn the full reserved weight to prevent griefing."

That mitigation assumes the decode returns Err. It does not — it traps. Therefore:

  • .map_err(|_| Self::err_burn_full_raw(..)) never runs, so no weight is burned;

  • the MaxInnerCallWeight check at lib.rs:605-613 is downstream of the decode and is never reached;

  • apply_extrinsic aborts, the extrinsic is never included, and no fee is charged.

Both anti-griefing mechanisms the pallet ships are structurally unreachable in exactly the case they were written for. This is a guard that runs after the thing it guards.

Reachability

Utility is #[runtime::pallet_index(9)] (runtime/src/lib.rs:253-254) and is not disabled — only Scheduler at index 8 carries #[runtime::disable_call]. This distinction is load-bearing: I verified that disable_call does remove the variant from the Decode impl, which would have made the nested encoding unconstructible. Measured:

propose is an ordinary signed extrinsic. Its only precondition is that the caller is a signer of the target multisig:

An attacker satisfies that by creating their own multisig — measured cost 600000000000 planck (0.6 UNIT), paid once. No victim, no privileged role, no cooperation from anyone.

Nesting costs exactly 3 bytes per level ([pallet_idx=9][call_idx=0][Compact(1)]), so the production MaxCallSize = 10240 (runtime/src/configs/mod.rs:648) permits 3,412 levels — 13.3x the MAX_EXTRINSIC_DEPTH = 256 enforced on every other path.

Impact

All figures below are measured against the real runtime wasm blob under the production executor configuration.

depth
blob
result in wasm

100

303 B

Ok — decoded, no trap

1,400

4,203 B

Ok — decoded, no trap

1,600

4,803 B

TRAP

3,412

10,239 B

TRAP

A blob of at most 4,803 bytes traps the runtime. The failure boundary lies between 1,400 and 1,600 levels.

Cost asymmetry, same executor:

  • ~2.7 ms per bomb versus 64-83 µs for a normal call across runs — conservatively ~32x.

  • Zero charged to the attacker: the extrinsic never lands in a block.

  • The trap cost is depth-independent — 1,600 levels costs the same as 3,412 — so the cheapest effective bomb is also the smallest. Bytes-per-unit-of-damage is optimal at ~4.8 KB, not 10 KB.

Why this is "processing transactions from the mempool beyond set parameters"

The set parameter on this path is the MaxInnerCallWeight reservation, added specifically so that decoding a nested call could never exceed budget. It works normally — see the control in section 6 of the output, where a depth-100 blob is correctly rejected with CallWeightExceedsLimit. The bomb makes the node do the decode work outside that parameter entirely: the weight system observes nothing, charges nothing, and refunds nothing, while the node spends real CPU pulling the transaction from the mempool and attempting it.

The 100% undercharge also maps directly to the Medium line "Inaccuracies to the weight accounting system that under or overcharge by more than 50% of the correct amount", and the "not intentional" qualifier is satisfied by the pallet's own comment, which states the opposite intent.

What I did not prove, stated plainly

  • No chain split, and this must not be read as Critical. The trap is the runtime blob's own linear-memory shadow stack, not a host setting: the trap text is "out of bounds memory access", not "call stack exhausted", and __stack_pointer is initialised to 1048576 inside the consensus wasm itself. Every node traps at the same depth. There is no divergence.

  • No node crash and no poisoned state. The executor recovers cleanly (call #7 above). A block author drops the offending transaction and continues; the block is still produced. An attacker who mines a block containing the extrinsic simply produces a block honest nodes reject.

  • Not a 500%-block-delay. The proposer is deadline-bounded at 10 s (node/src/service.rs:498) against a 12 s target block time (runtime/src/lib.rs:90), so a single block cannot be delayed by the 60 s that line requires.

  • I did not run a live multi-node flood. Sustained pressure is bounded by the attacker's account count, because propose is signed and a transaction that is never included never advances its nonce, so each account contributes a limited number of ready bombs at a time; and each distinct bomb costs a given node one trap before being banned from its pool.

The honest claim is therefore: an unprivileged attacker, after a one-time 0.6 UNIT setup, can repeatedly make block-producing nodes perform ~2.7 ms of uncharged work each, entirely outside the weight accounting system written to prevent it.

Severity reasoning, including the case against

I have asked for High on "causing network processing nodes to process transactions from the mempool beyond set parameters", because the defining feature is that the work happens outside the set parameter rather than merely exceeding it.

A reviewer may reasonably prefer Medium, on "inaccuracies to the weight accounting system that under or overcharge by more than 50%" — the undercharge is 100%. I think that mapping is also correct and would not dispute a Medium determination. What I ask is that the decision be made on the mapping rather than on an assumption that this is generic mempool spam: generic spam pays fees and is weight-accounted, and this is precisely the case that is neither.

Novelty: why this is not covered by the published audits

Equilibrium's 2026-05-13 review covered pallet-multisig (19 findings). I read the three nearest in full.

EQ-QNT-MULTISIG-F-01 (High) — "Dispatched RuntimeCall execution is not charged in the multisig fee model." The closest, and it explicitly reasons about utility::batch carrying many inner calls. The fix that shipped is the MaxInnerCallWeight reservation now at lib.rs:605-613. A reviewer could ask whether this report is a residual of F-01. It is not, and the difference is directly measurable — section 6 of the output versus section 2:

  • At depth 100 the decode completes, the F-01 weight gate fires, the transaction is included and the reserved weight is burned. The mitigation does exactly what it was built to do.

  • At depth >=1,600 the runtime traps at lib.rs:602, before the gate at lib.rs:605 is ever reached, and nothing is charged.

F-01 hardened the path where decode returns. This report is the path where decode does not return. F-01's fix cannot address it, because it lives downstream of the failure.

EQ-QNT-MULTISIG-O-01 — "High-security validation and call decoding are deferred later than necessary." Quotes the exact unlimited decode line, but its entire thesis is ordering — that the high-security check happens before cheaper proposal-count and expiry checks — and its recommendation is to "validate and decode the call earlier in a consistent place". It does not mention depth, recursion, or stack exhaustion, and moving the decode earlier would not change this outcome.

EQ-QNT-MULTISIG-O-03 — "The extrinsic call boundary is looser than the pallet's own limits require." Notes the call: Vec<u8> parameter but explicitly calls it "a design-quality issue rather than a direct bug" and recommends a bounded input type. That fix has already shipped — at HEAD the parameter is call: BoundedCallOf<T> = BoundedVec<u8, MaxCallSize> (lib.rs:538, alias at lib.rs:254) — and the bomb passes straight through it, because a size bound does not constrain depth: 4,803 bytes is comfortably inside the 10,240-byte limit.

I also grepped every published report in scope for depth limit, decode_all_with_depth, nesting depth, recursion depth, stack exhaust and decode bomb: no hits. And no existing test asserts this behaviour as intended — grep -niE "nested|depth|recursi|stack" over pallets/multisig/src/tests.rs and pallets/utility/src/tests.rs returns nothing relevant.

Proof of Concept

Steps to reproduce

Self-contained. Requires only a clone of the in-scope repository and a Rust toolchain; no external crates and no network beyond cargo fetch.

1

Clone the repository

2

Save PoC file A

Save PoC file A below as pallets/multisig/src/depth_poc.rs.

3

Save PoC file B

Save PoC file B below as runtime/src/depth_poc.rs.

4

Apply the wiring edits

5

Run the PoC

Save the runner below as run_depth_poc.sh in the repository root, then:

Do not set SKIP_WASM_BUILD: sections 2 and 5 execute the real runtime wasm blob and require it to be built. Expect a long first build. Everything runs --release; the timings in section 5 are only meaningful in release, because unoptimised recursive decode is far slower and would overstate the asymmetry.

A native stack overflow aborts the process by design (SIGABRT), so sections 3 and 4 print the overflow message and stop. That is the expected observation, not a harness failure. The authoritative measurement is section 2, which runs in wasm.

Individual invocations

PoC file A: pallets/multisig/src/depth_poc.rs

PoC file B: runtime/src/depth_poc.rs

Runner: run_depth_poc.sh

Verbatim output of ./run_depth_poc.sh

Reading the output

  • Section 1 establishes the budget: 3 bytes per nesting level, so MaxCallSize = 10240 permits 3,412 levels against a MAX_EXTRINSIC_DEPTH of 256. It also confirms disable_call does remove a variant from the codec, which is why Utility being enabled is what makes this reachable.

  • Section 2 is the authoritative measurement: inside the real runtime wasm, 4,203 bytes decodes fine and 4,803 bytes traps.

  • Sections 3 and 4 show the same boundary natively at the production 1 MiB stack, and section 4 confirms the whole path is reachable from an ordinary signed origin for a one-time 0.6 UNIT setup.

  • Section 5 quantifies the asymmetry and shows the executor is not poisoned.

  • Section 6 is the control that isolates the finding. Identical call shape, identical code path, only the nesting depth differs. At depth 100 the pallet behaves exactly as designed and rejects the call with CallWeightExceedsLimit. At depth >=1,600 the runtime never gets to behave at all.

Recommendation

Use the depth-limited decode the repository already provides, at both sites:

This restores intended behaviour rather than adding new behaviour: the decode now returns Err, so the pallet's existing err_burn_full_raw griefing mitigation fires as its author intended, and the MaxInnerCallWeight gate at lib.rs:605 becomes reachable again.

Two notes on the fix:

  • Bounding MaxCallSize further does not fix this. At 3 bytes per level even a 1 KB limit still permits ~340 levels, and the wasm stack fails around 1,500. A size bound only moves the threshold; the depth limit is the correct control.

  • frame/support/src/traits/preimages.rs:210,213,217 performs the same unbounded RuntimeCall decode. I am not reporting it, because on this runtime it is reachable only via Scheduler/Referenda and is therefore governance-gated and out of scope — but it is worth fixing in the same pass, since a future runtime that exposes a preimage path to unprivileged callers would reintroduce this exact bug.

Was this helpful?