90400 bc high opaque multisig proposals bypass the 256 level extrinsic depth limit and repeatedly trap block author wasm without a durable fee or nonce charge
Submitted on Aug 24th 2026 at 20:41:40 UTC by @tinnohofficial for Audit Comp | Quantus
Report ID: #90400
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
Quantus limits extrinsic nesting to MAX_EXTRINSIC_DEPTH = 256. Before checking a signature or dispatching a transaction, Executive re-encodes the extrinsic and decodes it with that depth limit. This is intended to prevent a small recursively nested call from exhausting a runtime execution stack.
Multisig::propose, however, accepts the intended inner RuntimeCall as an opaque BoundedVec<u8> of up to 10,240 bytes. The outer depth-limited decoder sees only a byte vector. After the transaction has passed pool validation and a block author selects it, propose decodes those bytes with unrestricted Decode::decode.
A signed proposal containing 2,500 nested Utility::as_derivative calls is only 10,003 bytes. The raw call is rejected by the runtime's 256-level decoder, but the opaque multisig proposal passes that same decoder and Executive::validate_transaction. When the production Wasm runtime dispatches it through BlockBuilder_apply_extrinsic, unrestricted decoding recurses until Wasmtime traps with an out-of-bounds memory access in RuntimeCall::decode.
The trap occurs before the pallet withdraws ProposalFee or reserves ProposalDeposit. Block construction rolls back the complete extrinsic attempt, including its transaction-payment withdrawal and nonce increment. Authorship removes and temporarily bans the exact transaction hash, but an attacker can change the proposal expiry or nested derivative indices, sign a new hash with the unchanged current nonce, and trigger the same path again.
Vulnerability Details
The executive enforces a 256-level admission boundary
Quantus declares the maximum nesting depth in frame/support/src/lib.rs:39-40:
Executive::do_apply_extrinsic deliberately applies that limit before signature checking or dispatch (frame/executive/src/lib.rs:863-886):
The PoC proves that the raw 2,500-level RuntimeCall is rejected by this decoder.
Multisig turns the nested call into opaque bytes at admission
The custom Quantus multisig pallet defines its proposal call as bytes bounded only by encoded length (pallets/multisig/src/lib.rs:184-186,250-251):
The production bound is 10,240 bytes (runtime/src/configs/mod.rs:643-657):
During outer extrinsic decoding, SCALE therefore decodes the malicious recursive structure as the elements of a byte vector. It does not recurse into the encoded RuntimeCall. The outer proposal passes decode_all_with_depth_limit(256), and its declared dispatch weight can be obtained without inspecting the bytes because the annotation uses call.len() plus MaxInnerCallWeight.
This also makes transaction-pool validation succeed. The PoC signs the proposal with the production ML-DSA-65 signature type and every production transaction extension, then calls:
The result is Ok, so an external peer can place the transaction in the ready pool.
Dispatch re-decodes the bytes without any depth limit
After the proposal has passed all inexpensive multisig checks, propose uses unrestricted decoding (pallets/multisig/src/lib.rs:594-604):
execute repeats the same unsafe operation for stored calls (pallets/multisig/src/lib.rs:1123-1131):
Neither operation uses DecodeLimit, and neither requires that all bytes be consumed. With 2,500 nested Utility::as_derivative variants, each four-byte wrapper adds one Rust/Wasm decode frame while the complete encoded payload remains only 10,003 bytes.
The PoC invokes the frozen runtime's actual Wasm blob through the same WasmExecutor<sp_io::SubstrateHostFunctions> used by node/src/service.rs:599-605. After a Core_version warm-up excludes Wasm compilation time, BlockBuilder_apply_extrinsic deterministically returns:
The focused run on the test host reached the production trap after approximately 59 ms. A native invocation on a one-MiB thread stack aborts the process with fatal runtime error: stack overflow; production nodes use Wasm, so this report does not claim that the node process itself crashes.
The intended weight charge and proposal fee never become effective
The pallet comments acknowledge that nested call decoding has structure-dependent cost and reserve MaxInnerCallWeight. That reservation does not make unrestricted recursion safe. It only affects normal dispatch accounting after execution returns.
More importantly, the non-refundable proposal fee and deposit are below the vulnerable decode (pallets/multisig/src/lib.rs:625-649):
The trap happens at line 599, before either statement. The transaction-payment extension may withdraw the outer transaction fee before dispatch, but block construction executes every candidate in a storage transaction. sc-block-builder-0.48.0/src/lib.rs:292-310 rolls the whole attempt back when the runtime API returns the executor trap:
Thus the signer keeps its current account nonce and pays neither the transaction fee nor the multisig proposal fee for the trap.
Hash banning does not close the repeat path
Basic authorship catches the BlockBuilder::push error, marks the transaction invalid, and continues constructing the block. This prevents one proposal from halting the whole network, and this report does not claim total shutdown.
The mitigation is local and hash-specific. sc-basic-authorship-0.53.0/src/basic_authorship.rs:576-601 passes the hash to transaction_pool.report_invalid; Quantus's transaction pool removes that hash and temporarily bans it for 30 minutes. It does not invalidate the signer's unchanged nonce on chain.
The attacker can produce a fresh valid hash by changing any of the following without changing the vulnerable behavior:
expiry, which may be any future block up toMaxExpiryDuration = 100,800;one or more of the 2,500
as_derivative.indexvalues;ignored trailing bytes inside the 10,240-byte bounded call, because
Decode::decodedoes not require complete consumption.
After a one-time creation of a multisig controlled by the attacker, a newly signed proposal with the current nonce can therefore re-enter the ready pool whenever the prior hash is removed. The attacker must maintain enough free balance to pass transaction-payment validation, but rollback means this balance is not consumed by malicious proposals.
Impact Details
This maps to High - Causing network processing nodes to process transactions from the mempool beyond set parameters:
The runtime explicitly limits extrinsic call nesting to 256.
The malicious raw call is correctly rejected at that limit.
Wrapping the exact bytes in
Multisig::proposemakes a 2,500-level call pass the outer decoder and ready-pool validation.A block author then processes recursion almost ten times beyond the configured boundary and reaches a production-Wasm memory trap.
The attempt consumes block-author execution time but is rolled back before nonce or fee changes become durable.
Banning the exact hash does not prevent fresh variants from the same signer and multisig.
Authorship handles the trap as an invalid candidate and can still produce a block, so this is not being submitted as a Critical network shutdown or as the High 500%-block-delay impact. The selected impact is the separate, exact mempool-processing-beyond-parameters category.
Why This Is a New Bypass Rather Than the Publicly Fixed Decode-Weight Issue
PR #608 publicly identified that propose decodes attacker-controlled RuntimeCall bytes, that nested calls make this work scale with inner_call_count rather than byte length, and that undercharging it could cause availability DoS. Its stated fix was to reserve MaxInnerCallWeight and return actual_weight: None after post-decode rejection so the full weight is burned. The collaborator review approved that accounting on the assumption that every decode attempt returns through normal post-dispatch handling.
This report does not resubmit that disclosed undercharge. It demonstrates a bypass of its mitigation: unrestricted recursive decoding does not return Ok or Err in production Wasm. It exhausts the Wasm stack and traps out of the runtime API. Consequently, actual_weight: None is never returned, and the block builder rolls back the transaction-payment withdrawal, nonce increment, and all pallet state. Neither PR #608 nor its review mentions a decode-depth limit, stack exhaustion, executor trap, transactional rollback, or same-nonce fresh-hash variants.
PR #640 separately lists MAX_EXTRINSIC_DEPTH enforced before unchecked-to-checked conversion and Depth-limited decode is applied before signature check as invalid findings. Those entries confirm that the outer Executive decoder is correctly bounded; they do not inspect opaque bytes that Multisig::propose decodes later during dispatch. The PoC proves both sides of this distinction using the same payload: raw bytes fail the Executive decoder, while the opaque proposal passes it and traps only at deferred decoding.
The published Equilibrium multisig review also contains three superficially related findings, but none covers this admission bypass or runtime trap:
EQ-QNT-MULTISIG-F-01reported that successful inner-call execution was not included in the multisig weight model. The current code fixes that by reservingMaxInnerCallWeight. This issue occurs earlier, while decoding the opaque bytes, before any inner call dispatches. The reserved weight cannot prevent stack exhaustion, and the executor trap rolls back the fee that would normally price the reservation.EQ-QNT-MULTISIG-O-01discusses the ordering of high-security validation and decoding relative to cheap proposal checks. It does not identify unrestricted recursion, a bypass ofMAX_EXTRINSIC_DEPTH, pool admission, or a Wasm trap.EQ-QNT-MULTISIG-O-03reported thatproposeaccepted an unbounded rawVec<u8>. Commit1d299f1704b519924df8fcd944e09c963bd3698cfixed that issue with the current 10,240-byteBoundedVec. The PoC deliberately stays within that fixed bound at 10,003 bytes; the defect is that byte length does not bound recursive decode depth.
Commit e127ffd810718e64c8a2c21393c270aa78ca3318 is PR #608's merged mitigation. It retained unrestricted Decode::decode, so its reservation and burn account for work only when decoding returns normally. The new defect is the demonstrated non-returning executor-trap path through that mitigation, not the already disclosed fact that ordinary nested decoding has structure-dependent cost.
This is also Quantus-specific code rather than an unchanged upstream pallet report. Quantus's multisig has a custom opaque proposal format, custom fees and deposits, custom high-security policy, and custom decode/execute flow.
Recommended Fix
References
frame/support/src/lib.rs:39-40- explicitMAX_EXTRINSIC_DEPTH = 256boundary.frame/executive/src/lib.rs:863-900- depth-limited outer extrinsic decode and dispatch.pallets/multisig/src/lib.rs:162-230- customRuntimeCall, call-size, fee, and inner-weight configuration.pallets/multisig/src/lib.rs:250-289- opaque bounded call and proposal storage types.pallets/multisig/src/lib.rs:517-649- declared weight, unrestricted proposal decode, and later fee/deposit charge.pallets/multisig/src/lib.rs:1060-1156- unrestricted execute-time decode.runtime/src/configs/mod.rs:643-657- 10,240-byte call bound, 1 UNIT proposal fee/deposit, and inner-call weight.runtime/src/configs/mod.rs:701-715- production multisig configuration.node/src/service.rs:599-605- production Wasm executor.client/transaction-pool/src/single_state_txpool/single_state_txpool.rs:384-393- invalid transaction removal.client/transaction-pool/src/graph/validated_pool.rs:784-813,862-899- exact-hash ban and removal.sc-block-builder-0.48.0/src/lib.rs:284-312- transactional candidate execution and rollback.sc-basic-authorship-0.53.0/src/basic_authorship.rs:547-603- trap handling, invalid report, and continued authorship.1d299f1704b519924df8fcd944e09c963bd3698c- fix for the published unbounded-Vecobservation.e127ffd810718e64c8a2c21393c270aa78ca3318- current nested-call weight mitigation retaining unrestricted decode.https://github.com/Quantus-Network/chain/pull/608- public nested-decode weight fix that this trap bypasses.https://github.com/Quantus-Network/chain/pull/640- public outer Executive-depth findings, which do not cover deferred opaque decoding.
Proof of Concept
Run
Observed output on frozen commit 3b243b870a5442a6e5f443056f67bbba87d472f5:
The optional native-stack reproduction intentionally aborts its test process:
It prints:
SHA-256 of the complete test below:
PoC Code
Was this helpful?