88969 bc high unbounded recursive decoding of opaque multisig calls makes block authors repeatedly execute unpaid mempool poison
Submitted on Aug 16th 2026 at 02:41:21 UTC by @IronsideSec for Audit Comp | Quantus
Report ID: #88969
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
An unprivileged multisig signer can place a canonical 10,239-byte call containing 3,412 nested Utility::batch wrappers inside Multisig::propose. The signed outer extrinsic passes the runtime transaction-queue gate because FRAME's 256-level extrinsic depth check sees the inner call only as opaque bytes. During block construction, however, Multisig::propose decodes those bytes again without any depth limit. Recursive decoding exhausts the runtime's available stack space, and production Wasmtime contains the resulting linear-memory out-of-bounds condition as a runtime-API trap. The block builder then rolls back the tentative nonce, fee (including tip), weight, and state, while basic authorship treats the trap as a generic invalid candidate rather than resource exhaustion and continues with the next ready entry. A wave of independently signed entries therefore makes each default author keep processing after authorship’s post-soft-deadline fixed-amount resource-exhaustion mode would stop, for no per-attempt on-chain charge, and can consume the full transaction-selection budget on affected blocks.
Vulnerability Details
Root cause: a protected outer decode followed by an unprotected inner decode
The runtime limits a multisig call by encoded byte length, not by decoded structural depth:
pub type BoundedCallOf<T> = BoundedVec<u8, <T as Config>::MaxCallSize>;The frozen runtime configures MaxCallSize = 10_240. Multisig::propose accepts that opaque BoundedCallOf<T> and later performs a plain recursive decode:
See BoundedCallOf<T> and the vulnerable propose decode. execute repeats the same unrestricted decode.
FRAME's Executive does use DecodeLimit::decode_all_with_depth_limit(MAX_EXTRINSIC_DEPTH, ...), with MAX_EXTRINSIC_DEPTH = 256, in both the apply_extrinsic path and the validate_transaction path when it decodes the signed outer extrinsic. That protection does not recurse into a BoundedVec<u8>'s contents. The second decode in the pallet creates a new recursive trust boundary after the protected outer decode has completed.
The PoC's inner bytes are canonical SCALE, all-consuming, and contain no trailing garbage:
At this commit:
runtime pallet index
9isUtility;Utility call index
0isbatch;compact vector length
1is04;runtime pallet index
0isSystem; andSystem call index
0isremark, whose empty byte vector is00.
Every one-child wrapper is therefore three bytes, and the terminal empty remark is three bytes:
A canonical re-encode comparison does not reject this value. The missing invariant is a depth bound on the inner decode.
Reachability through the signed transaction-queue gate
TaggedTransactionQueue_validate_transaction checks the signature, nonce, declared weight, and ability to pay the declared fee, but it does not dispatch Multisig::propose. Consequently it never reaches the recursive inner decode. The PoC proves both the single-item gate and a conservative, independently signed wave:
The wave is not 340 replays of one hash. It deterministically generates 340 ML-DSA accounts, creates four multisigs (respecting MaxSigners = 100), signs one nonce-zero poison per account, and asserts all 340 validation results have requires = [], distinct provides tags, propagate = true, and the same priority. This directly establishes 340 independently ready runtime-valid candidates at the transaction pool's admission boundary. The default node pool limits are 36,772 transactions and 256 MiB, so this 340-item/5.30-MB wave is far below both --pool-limit and --pool-kbytes.
The production executor traps during block construction
The shipped client is explicitly a sc_executor::WasmExecutor, instantiated for the full client at service.rs:599-605.
When BlockBuilder_apply_extrinsic dispatches the admitted extrinsic, recursive RuntimeCall::decode -> pallet_utility::Call::decode -> Vec<RuntimeCall>::decode frames exhaust the runtime's stack space. In the built runtime Wasm, Wasmtime contains the resulting linear-memory boundary violation as AbortedDueToTrap with out of bounds memory access; the captured backtrace repeatedly traverses the recursive decoder. This is distinct from Wasmtime's engine max_wasm_stack/StackOverflow trap and does not rely on that limit. A subsequent Core_version call succeeds, proving the trap is safely contained rather than a native-process-only abort.
The uncached path includes compilation, so the PoC also warms a module through the same cached CodeExecutor::call path and separately measures the frozen-runtime Wasm apply path. On the final v5 validation run the cached apply returned the specifically asserted AbortedDueToTrap/out-of-bounds-memory error in 55.639167ms, while the immediate recovery call completed in 143.125µs.
Why the work, fee, tip, nonce, and weight are free after each trap
The locked sc-block-builder 0.48.0 implementation wraps each apply_extrinsic in ApiExt::execute_in_transaction. A runtime-API Err chooses TransactionOutcome::Rollback, and the extrinsic is not appended to the block:
For the executor trap in this report, Error::from(error) converts the runtime API's ApiError into sp_blockchain::Error::RuntimeApiError(...); it is not an ApplyExtrinsicFailed::Validity result. The state-machine runtime boundary also calls OverlayedChanges::exit_runtime, which rolls back a dangling runtime-started storage transaction before the host transaction is rolled back.
The PoC models that exact split with:
Before the host rollback, the PoC directly observes the transaction extensions' tentative effects:
After the block-builder-shaped rollback:
The same nonce-zero tipped extrinsic then revalidates at priority 5,000,005. The multisig's own proposal fee/deposit/state are not written and then refunded; execution traps during decode before that pallet logic is reached. The tentative outer transaction fee, tip, nonce, block weight, and event are what the host transaction rolls back.
Why basic authorship exceeds its resource-skip parameter
Quantus passes a 10-second build duration to QPoW, and create_proposal forwards that build_time unchanged to the proposer. Locked sc-basic-authorship 0.53.0 sets the absolute proposal deadline to proposal start plus 90% of that duration. Runtime initialization and inherents happen first, so transaction selection has at most 9 seconds; using the full 9 seconds in the PoC cost model is conservative.
Basic authorship maintains skipped and MAX_SKIPPED_TRANSACTIONS = 8 for candidates that return Validity(e) where e.exhausted_resources(). The relevant locked branches are:
Before the 50% soft deadline it may continue even after eight skips; once the soft deadline has passed and skipped >= 8, the resource-exhaustion arm stops building. This trap instead arrives from sc-block-builder as sp_blockchain::Error::RuntimeApiError(...) and enters the generic Err(e) arm. That arm reports the iterator item invalid, records its hash for later pool reporting, and continues; it neither increments skipped nor sets the resource-limit reason. The author therefore remains outside the post-soft-deadline fixed-amount mode and tries separately ready poison entries until the absolute proposal deadline.
After the loop, the node reports the bad hashes and the single-state pool removes/bans them. That does not make the first wave cheap for the author. Because nonce and fee state rolled back, an attacker can also submit new hashes after removal (for example, by changing expiry), or use another independently ready account. Source support for removal is in single_state_txpool.rs and validated_pool.rs.
Priority and impact boundary
The attacker can attach a tip that pool validation believes will be paid, although it is refunded after the trap. With a one-micro-QUAN tip, the PoC observes:
This places the poison ahead of ordinary untipped transfers in the ready ordering. Priority is nevertheless finite and collateral-dependent. The PoC deliberately includes a control proving that a transfer tipped only 6,802 Planck has priority 5,000,205 and is ordered ahead of this particular poison.
For that reason, this report does not claim categorical “total network shutdown” and does not select the Critical impact “Network not being able to confirm new transactions.” Blocks/inherents continue, and sufficiently higher-priority candidates can be selected first. The directly supported impact is the program's High mempool-processing impact: default processing nodes are induced to execute a large sequence of expensive, ultimately free candidates from the mempool (roughly 162 on the measured host, with 340 independently ready entries validated), bypassing authorship's post-soft-deadline resource-exhaustion termination and consuming up to the available selection budget for all lower-priority traffic.
Bypass of the public PR #608 mitigation
Pre-freeze PR #608, merged as e127ffd810718e64c8a2c21393c270aa78ca3318, explicitly recognized that structured RuntimeCall decode is O(inner_call_count). It added the already configured MaxInnerCallWeight to propose's upfront reservation and added full-weight burns on returned decode/post-decode failures.
This report concerns the incomplete mitigation's separate structural-safety bypass, not merely generic decode underweighting:
PR #608 is the closest public prior art and creates a material duplicate/known-surface risk: it names the same raw decoder, nested Utility::batch, inner-call-count complexity, and availability concern. The technical invariant and remediation here are nevertheless distinct: reserving/charging a dispatch-weight proxy cannot bound structural recursion, and the demonstrated trap never returns to PR #608's charge logic. The program mentions fix bypasses as potentially new bugs, but because #608 was public pre-freeze, this report does not treat that clause as dispositive; eligibility turns on triage recognizing this structural trap/rollback/authorship path as the new bypass rather than the already-public underweighting issue.
Later canonical-byte checks (including a86becfa) do not fix this payload because it is canonical and traps before any re-encode comparison. The post-freeze upstream/main inspected during validation still performed the same unrestricted RuntimeCall::decode at propose.
The May 2026 quantus-substrate-audit-20260513.pdf discusses multisig weight/accounting and deferred decoding at a general level, but does not disclose the opaque second-decode depth-limit bypass, canonical stack poison, production Wasm trap, host rollback, or authorship-loop consequence. The program's listed known issues concern stale votes from removed referendum members, trusted-network miner/node plaintext, and future ZK-tree-depth/mining-quantization upgrades; none describes this decode-boundary or proposer-processing issue.
Recommended remediation
Apply a strict, all-consuming structural depth limit every time opaque bytes become a RuntimeCall, including both propose and execute. Schematically:
Use a small explicit MAX_MULTISIG_CALL_DEPTH that is covered by benchmarks and no greater than the runtime's outer extrinsic depth policy. Prefer accepting a typed/bounded call whose depth was already checked rather than storing arbitrary bytes and decoding them later. Keep the full-weight error handling as defense in depth, but do not treat a weight proxy as a recursion limit.
At the client layer, treat runtime-API traps during candidate application as resource exhaustion: stop after a small fixed number, apply per-origin/per-hash backoff before repeatedly executing replacements, and meter failed runtime calls outside state that will be rolled back. Those client mitigations reduce blast radius but do not replace fixing the runtime decode boundary.
Impact Details
Selected in-scope impact
High — “Causing network processing nodes to process transactions from the mempool beyond set parameters.” This exact impact appears in the Quantus Audit Competition's Impacts in Scope.
The relevant parameters are concrete: basic authorship enters a fixed-amount mode around its 50% soft deadline and, for exhausted_resources() results, terminates once the soft deadline has passed and its eight-skip allowance is exhausted. The crafted transaction surfaces depth/resource exhaustion as a generic RuntimeApiError, so neither that counter nor that termination condition is used. A default author instead processes separately ready poison entries until the absolute proposal deadline; because initialization and inherents precede selection, the available selection time is at most 9 seconds.
Directly demonstrated consequences
On the frozen commit, the self-contained PoC directly demonstrates:
a signed poison passes native and production-Wasm transaction-queue validation and is propagable;
340 independently signed nonce-zero poisons have 340 unique ready tags and total only 5,298,560 bytes;
a cached production-Wasm apply traps after approximately 56 ms and the executor immediately remains usable;
before rollback, exactly
1,016,491,093,201Planck is withdrawn, nonce becomes 1, and1,000,906,093,201 / 2,656,056weight is recorded;exit_runtimeplus the block-builder host rollback restores the entire balance (including tip), nonce, block weight, events, and overlay;the same nonce-zero poison revalidates at priority 5,000,005;
340 complete production-shaped apply/trap/runtime-cleanup/host-rollback attempts take 18.950986709 seconds on the validation host, more than twice the conservative 9-second upper bound for transaction selection, while ending with the same observed free/reserved balance, nonce, block weight, and event-count tuple; and
the hardware-portable cost check computes that 162 such entries exhaust 9 seconds on this host, versus a 17,225-entry limit imposed by the frozen default pool byte cap.
An author with this wave ahead of ordinary traffic expends the whole selection phase executing candidates that contribute no extrinsic, weight, fee, or nonce to the block. Transactions below the selected poison priority remain unvisited in that proposal. Repeating the wave against default producers can sustain the High availability/resource-consumption impact; the PoC does not claim that headers stop or that arbitrary higher bidders cannot escape.
Feasibility and limits
The PoC funds every wave account with 2 QUAN and pays four one-time multisig-creation operations. Each poison is 15,584 bytes. The one-micro-QUAN tip is only validation collateral and is restored when the apply traps. The 340-item wave fits comfortably inside the default pool limits. An attacker can tune the refundable tip upward to place poison above more normal traffic, but must have enough balance in each independently ready payer for validation.
Timing is machine-dependent. The rollback harness uses the exact OverlayedChanges/Ext transaction split over an in-memory backend rather than instantiating a full database-backed client. The ignored cost-model test therefore does not require a fixed count to exceed nine seconds on every machine: it measures the complete failed-push average, conservatively computes ceil(9-second upper bound / average), and asserts that the resulting wave fits both frozen default pool limits. The reproduced run used Darwin ARM64, rustc 1.93.0, and cargo 1.93.0; it required 162 measured entries while the byte limit alone allowed 17,225. The separate 340-entry runtime-validation test directly proves a conservative wave on this host, not a universal protocol constant.
Explicit non-claims
This report does not claim:
a process crash on production nodes (the production Wasm trap is contained);
that a live
BasicPool/P2P multi-producer network was instantiated by the test;that every possible transaction priority is censored;
that block headers or inherents stop; or
the Critical total-network-shutdown impact.
The signed runtime admission gate, distinct ready tags, production Wasm trap, production-shaped rollback, fee/nonce/weight restoration, priority boundary, and deadline-scale cost are directly tested. Pool ordering, generic-error iteration, deadline arithmetic, invalid-hash removal, and QPoW integration are tied to the exact locked source versions cited above.
References
Proof of Concept
What the PoC proves
The patch adds one self-contained integration-test file and only the test dependencies needed to run the frozen runtime through its production Wasmtime executor. It does not patch vulnerable production logic.
The tests cover:
The native stack-overflow control is marked ignored because executing it intentionally aborts the test process. The full normal command is safe.
Reproduction steps
The patch SHA-256 is:
I also verified git apply --check and applied this exact patch in a second clean detached worktree at the frozen commit. The 340-entry wave test passed there independently.
Observed functional-suite output
The following is the concise relevant output from the complete safe suite on the stated Darwin ARM64 host:
Observed stress output
Was this helpful?