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:
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
MaxInnerCallWeightcheck atlib.rs:605-613is downstream of the decode and is never reached;apply_extrinsicaborts, 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.
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_pointeris initialised to1048576inside 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
proposeis 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 atlib.rs:605is 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.
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 = 10240permits 3,412 levels against aMAX_EXTRINSIC_DEPTHof 256. It also confirmsdisable_calldoes remove a variant from the codec, which is whyUtilitybeing 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
MaxCallSizefurther 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,217performs the same unboundedRuntimeCalldecode. 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?