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

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

1

Create a valid sparse public batch

The attacker deposits to an ordinary Wormhole address they control and creates one valid private-batch proof.

They invoke the supported PublicBatchAggregator with that one proof. The remaining 52 slots are filled with the circuit's pinned dummy proof.

2

Submit the unsigned public-batch extrinsic

They submit the resulting unsigned verify_public_batch extrinsic, carrying the 223,616-byte proof payload, before other priority-1 Wormhole exits.

3

Pass verification and settle one exit

pre_dispatch performs full ZK verification. Dispatch then successfully settles the attacker's one exit and skips 52 inert segments.

4

Retain the maximum declared weight

The success result supplies no actual_weight, so the block retains 3.744227 seconds instead of reclaiming the unexecuted storage and tree-insertion tail.

5

Repeat per target block

The attacker repeats with one fresh leaf/proof in each target block.

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.

Keep the all-742-exit declaration as the safe pre-dispatch maximum, but calculate successful weight from work actually performed:

1

Retain a fixed base

Retain a fixed base for deserialization, public-input parsing, ZK verification, and fixed-layout processing.

2

Count actual work

Count the non-inert valid segments, real exit accounts, nullifiers, and ZK-tree inserts actually processed.

3

Return successful actual weight

Return PostDispatchInfo { actual_weight: Some(base + actual_tail), pays_fee: Pays::No } on success.

4

Add a sparse-fixture regression

Add the existing one-real-leaf fixture as a regression and require its successful weight to be far below the all-742-exit maximum.

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 1 and 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: None and Pays::No;

  • the frozen RocksDB weight implementation charges exactly 3,744,227,000,000 ps;

  • 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

1

Clone the scoped asset

Clone the scoped asset and check out the exact frozen commit:

2

Create the patch file

Create the following file in the repository root and paste the complete unified diff from the end of this section into it:

3

Apply and run the PoC

Confirm that the patch applies cleanly, apply it, and run only the PoC test:

--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?