89154 bc medium sparse public batches retain 742 exit weight for one exit letting an unsigned proof stuff 83 of the normal quota
Submitted on Aug 17th 2026 at 14:32:19 UTC by @IronsideSec for Audit Comp | Quantus
Report ID: #89154
Report Type: Blockchain/DLT
Report severity: Medium
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Block stuffing
Description
Brief/Intro
The level-2 Wormhole circuit deliberately permits a public batch containing one real private-batch proof and 52 reusable dummy proofs. The chain correctly skips those 52 inert segments and can settle exactly one real exit. However, verify_public_batch always declares storage and ZK-tree work for the fully populated maximum of 2 * 7 * 53 = 742 exits, and every successful sparse execution returns actual_weight: None. The all-742-exit declaration is therefore never reduced to the work actually performed.
The resulting unsigned extrinsic carries a fully valid 223,616-byte proof payload and is Pays::No, yet declares and retains 3.744227 seconds of call weight, or 83.205% of the 4.5-second Normal block quota, for one real exit. The same one-exit settlement through the private-batch path reserves 87.137 ms, so wrapping it in the supported public format gives an unprivileged attacker a 42.97x weight-reservation amplifier. One fresh sparse proof per block reduces available same-priority Wormhole exit capacity from roughly 24 to 8 without malformed proofs, verifier bypasses, a colluding miner, or brute-force traffic.
Vulnerability Details
Sparse level-2 batches are an intended, valid proof shape
The frozen production circuit is built with seven leaf proofs per private batch and 53 private batches per public batch. build.rs sets those defaults:
The corresponding scoped ZK circuit explicitly supports partial public batches. circuit_logic.rs:143-156 says dummy inner proofs are supported for padding, exempt from consistency checks, and have their exits and nullifiers zeroed so the on-chain verifier can skip them. circuit_logic.rs:280-313 enforces the zeroing while retaining the fixed structural layout.
The pallet implements the same design. segment_is_inert classifies all-zero-nullifier segments as inert. process_exit_bundle skips every inert segment and zero exit slot:
A one-real-exit public batch is consequently normal supported behavior, not a malformed or invalid proof.
The declaration always charges the fully populated 742-exit tail
The public call uses one fixed declaration:
See pallets/wormhole/src/lib.rs:613-645.
public_batch_max_exits returns the structural maximum:
verify_public_batch() then scales the storage tail and ZK-tree hash charge by all 742 possible slots, irrespective of how many segments or accounts are real. See weights.rs:154-196:
Using this as an upfront maximum is safe. The defect is that a successful sparse dispatch never refunds the unexecuted storage/tree tail. The common success return at lib.rs:997-998 is:
actual_weight: None tells FRAME to retain the entire declared weight. The call is unsigned (ensure_none) and Pays::No. Pool admission also assigns every Wormhole exit the fixed priority 1; see UNSIGNED_EXIT_PRIORITY and the verify_public_batch unsigned-validity branch.
Exact frozen-runtime charge
The frozen runtime permits six seconds of block compute and assigns 75% to Normal dispatches, giving a 4.5-second Normal quota. See runtime/src/configs/mod.rs:72-95. The call is in the default Normal dispatch class. FRAME derives a Normal max_extrinsic of 3,899,460,229,000 ps from this configuration, so the 3,744,227,000,000-ps call fits as a single extrinsic.
Applying the frozen WeightInfo and RocksDB constants gives:
The PoC obtains these values by calling the real frozen WeightInfo functions rather than reimplementing the arithmetic.
End-to-end attack path
The scoped aggregator documents public proving at approximately 16 seconds for 53 slots on Apple-Silicon-class hardware; see aggregator.rs:14-20. Two public-aggregation workers can therefore outpace the 12-second target cadence once the private proofs are available; the preceding proving stages are independently parallelizable, and complete proofs can be prepared ahead of time within the runtime's 4,096-block hash-retention window. Bandwidth is approximately 224 KB per block, or only about 19 KB/s at the target cadence. This is not a brute-force packet or transaction flood.
A smallest-path attacker locks two 0.01 QUAN input quanta, receives a one-quantum exit, and can direct the configured public-aggregator rebate to themselves. The net volume-fee loss is on the order of one quantum, plus the ordinary deposit transaction fee. For larger exits the configured fee approaches four basis points. Nothing charges the attacker in proportion to the 83% block reservation.
Why this is distinct from the published Eiger findings
EQ-QNT-WORMHOLE-F-02 used invalid proof admission. It concerned arbitrary/invalid unsigned proof bytes that entered the pool and failed during verification. The frozen code added cheap pre-validation, full verification in pre_dispatch, and early-failure post-dispatch corrections. This report's proof is genuine, passes the full ZK inclusion gate, and settles successfully. Invalid-proof filtering and early-failure refunds therefore cannot mitigate it.
EQ-QNT-WORMHOLE-R-09 reviewed the old verify_aggregated_proof placeholder. That finding centered on a hand-written 8x verifier estimate in the pre-level-2 endpoint and did generally suggest scaling its hard-coded storage components with actual exits/nullifiers. The verify_public_batch endpoint, 53-inner-proof circuit, inert-segment settlement design, and successful sparse execution path did not yet exist; they were introduced later in July 2026, after the Eiger audit was integrated. The current public ZK-verification base was separately benchmarked, and this report does not dispute that base. The new reachable regression is that this post-audit level-2 endpoint charges the maximum 742-exit storage/tree tail after successfully executing only one exit and provides no sparse success refund.
A later pre-freeze hardening commit, ca3978fd, also publicly notes that the fixture has one real segment plus 52 dummies and changes the pre-validation compute floor to the all-valid worst case. That fix does not add a successful actual_weight, does not make the public batch require 53 real segments, and does not address the dominant 742-exit storage/tree tail demonstrated here. The report therefore treats this as a success-refund bypass left after the all-valid admission-metering hardening, not as a claim that worst-case upfront declaration is itself wrong.
This is a distinct new endpoint and successful attack path rather than a resubmission of malformed-proof admission or the deprecated placeholder timing. The general R-09 recommendation and the closest sparse-fixture hardening are both disclosed here so triage can assess the distinction on the complete public record.
Recommended remediation
Keep the all-742-exit declaration as the safe pre-dispatch maximum, but calculate successful weight from work actually performed:
Alternatively, enforce a minimum fill ratio for the public path and direct sparse users to the existing private-batch endpoint.
Impact Details
Selected in-scope impact
Medium — “Block stuffing.” This exact impact appears in the Quantus Audit Competition's Impacts in Scope.
The checked-in public fixture is 223,616 bytes and contains exactly one real exit. A private fixture is 157,500 bytes. The Normal length allowance is 75% of 5 MiB, so an otherwise empty block can carry approximately 24 one-exit private proofs by length; compute would allow more. After a sparse public proof reserves 3.744227 seconds, only 755.773 ms remains, enough for at most eight 87.137-ms private proofs. Available same-priority Wormhole capacity therefore falls from roughly 24 to 8, a 66.7% reduction. Including the attacker's own exit, the block processes about nine exits instead of 24.
One sparse public proof fits; two cannot fit the Normal weight quota. Once selected early in a block with sufficient Normal headroom, its displacement is deterministic rather than dependent on saturating a network interface or racing hundreds of transactions. Each accepted proof occupies most of the block's Normal compute accounting even though its 52 dummy segments perform none of the priced storage writes or tree insertions.
Ordinary signed transactions normally have payment-derived priority greater than the Wormhole priority of 1. This report therefore does not claim total network shutdown, a 500%-delayed block, or categorical censorship of higher-priority signed calls. The directly demonstrated impact is fee-free block stuffing and severe displacement of other same-priority Wormhole exits.
The same result also demonstrates a greater-than-50% successful-path weight overcharge, but “Block stuffing” is selected because it captures the concrete adversarial effect without depending on an estimate of the exact sparse-path benchmark.
References
Proof of Concept
What the PoC proves
The scoped repository already commits a real Plonky2 public-batch proof whose test documentation states that it contains one real private batch, itself containing one real leaf, plus dummy private-batch padding. Existing assertions establish 742 structural exit slots, exactly one real exit, and only one non-inert segment. The existing success test settles that fixture.
The single-file regression below executes the same authentic proof and additionally proves that:
its serialized proof is 223,616 bytes and its public inputs contain exactly one real exit;
it passes permissionless external transaction-pool validation at the fixed priority
1and is propagated;it passes
ValidateUnsigned::pre_dispatch, which invokes the full public-batch ZK verifier;dispatch then settles exactly one ZK-tree exit leaf with
actual_weight: NoneandPays::No;the frozen RocksDB weight implementation charges exactly
3,744,227,000,000ps;this reserves more than 83% of the production Normal quota; and
it is more than 42 times the private-batch reservation.
The patch adds only one pallet-externalities test to pallets/wormhole/src/tests.rs. It does not modify production logic, mock or replace the verifier, or alter the committed proof fixture. It injects the fixture's referenced block hash rather than running a live node; the block-capacity calculation comes directly from the frozen production weight and block-limit implementations.
Reproduction — one patch file against the scoped repository
--test-threads=1 only makes the printed evidence deterministic. It does not change runtime state or any assertion path. The repository's pinned Rust toolchain is selected automatically; the first build may also generate the pinned circuits.
Expected result
The significant output is:
The output and assertions establish that the committed one-exit proof is admitted through the unsigned pool path, passes the full ZK inclusion gate, and settles exactly one exit leaf without a fee or successful-path weight correction. The production RocksDB formula consequently retains 83.205% of the Normal quota even though 52 inner segments are inert.
Complete single-file PoC — poc-medium-sparse-public-batch-block-stuffing.patch
Copy this file exactly as shown:
Was this helpful?