For the complete documentation index, see llms.txt. This page is also available as Markdown.
90398 bc high an unauthenticated attacker will force processing nodes to process unsigned wormhole mempool transactions beyond configured proposer limits
Causing network processing nodes to process transactions from the mempool beyond set parameters
Description
Brief/Intro
Cheap, verify-free validate_unsigned() admission for Wormhole batch proofs will cause the proposer to process candidate bytes and verification work beyond the block-size, block-weight, and skip-cap limits that should have bounded proposer processing, as an unauthenticated attacker submits canonical proof variants with fresh nullifiers that enter the mempool with Pays::No and are rejected only after pre_dispatch() runs the full ZK verifier.
Vulnerability Details
Root Cause
In pallets/wormhole/src/lib.rs:1006-1043validate_unsigned() admits verify_public_batch / verify_private_batch after pre_validate_*_batch_proof() only. That function deserializes the proof, checks canonical encoding, parses public inputs, and rejects used or duplicate nullifiers. It never calls verifier.verify(). The admitted transaction has fixed priority UNSIGNED_EXIT_PRIORITY = 1, longevity 5, propagate(true), and a provides tag from exit_bundle_provides_tag(), which hashes attacker-controlled nullifiers.
In pallets/wormhole/src/lib.rs:1045-1062pre_dispatch() is the first place that runs validate_*_batch_proof(), which calls the embedded verifier at lines 1167-1179 (private) or 1226-1238 (public). The choice to defer ZK verification to proposer-time pre_dispatch() while tagging pool entries by unverified nullifiers is the mistake: fresh nullifiers make each junk proof a distinct ready transaction, so the proposer pays full verification for fee-free unsigned traffic.
Details
Wormhole settlement is submitted as an unsigned extrinsic. author_submitExtrinsic puts the candidate in the transaction pool. The runtime validates it through Executive::validate_transaction(), which calls Wormhole::validate_unsigned(). The QPoW node constructs the proposer with that same pool (node/src/service.rs:464-471) and starts mining with a 10-second build window (node/src/service.rs:486-499). Basic Authorship then iterates ready transactions and push()es each one. push() applies the extrinsic, which runs Wormhole::pre_dispatch().
Pool admission is deliberately cheap:
pre_validate_public_batch_proof() is the guard the design relies on. It bounds proof size, deserializes with the embedded circuit, requires proof.to_bytes() == proof_bytes, parses public inputs, and runs validate_exit_bundle_common() (asset, fee bps, live block hash/number, unused/non-duplicate nullifiers). Those checks do not bind the nullifiers to a valid proof. A canonical fixture with rewritten live block metadata and unique nonzero nullifiers therefore returns ValidTransaction.
The intended throttle is the provides tag:
That tag stops resubmitting the same logical exit under a mutated encoding. It does not stop a new logical exit. Each fresh nullifier set is a new tag, so the pool can hold one junk candidate per nullifier tuple, up to the 256 MiB / 36,772 entry pool.
Inclusion is where the cost is paid:
Basic Authorship (sc-basic-authorship 0.53.0) is the consumer. Before push() it checks block_size + tx_size > DEFAULT_BLOCK_SIZE_LIMIT (4,194,816 bytes). After a failed pre_dispatch() the candidate is not added to the block, so block size and RuntimeBlockWeights never move. The final block itself does not exceed the block-size limit because the invalid transactions are not included. The invalid branch reports the transaction invalid and continues. MAX_SKIPPED_TRANSACTIONS = 8 applies only to overweight/oversize results, not to InvalidTransaction::Call. The proposer therefore keeps verifying 223,627-byte unsigned proofs until the ready iterator is empty or the 9-second hard deadline fires: it processes candidate bytes and verification work beyond the limits that should have bounded proposer processing.
Configured limits used in the PoC (production values, not invented). They bound included extrinsics, not failed pre_dispatch() work:
The proposer size cap is 4,194,816 bytes, so at most 18 included public-batch extrinsics of this shape would fit. Normal-class length (75% of 5 MiB) allows 17. Normal-class ref_time is 4.5 s and max-block ref_time is 6.0 s; one public-batch ZK verify is weighted at 21.077 ms, so 214 such verifies would exhaust normal weight if they were charged. They are not charged, because they die in pre_dispatch(). The PoC shows one proposal processing 500 of them.
A duplicate-nullifier variant is rejected at author_submitExtrinsic with Invalid Transaction, which shows the cheap guard is live and only fails to cover unique unverified nullifiers.
Attack Path
1
Read a canonical fixture
An unauthenticated RPC client reads a canonical public-batch fixture from pallets/wormhole/test-data/public_batch.hex. No valid exit, key, or balance is required.
2
Update live block metadata
The client reads chain_getHeader / chain_getBlockHash and writes the live block hash and number into the proof public inputs.
3
Generate unique nullifiers
The client replaces every nullifier with unique nonzero field limbs and reserializes with the production ProofWithPublicInputs::to_bytes().
4
Submit an unsigned extrinsic
The client submits a bare unsigned Wormhole::verify_public_batch extrinsic through author_submitExtrinsic(). validate_unsigned() accepts it and the pool stores a unique nullifier tag.
5
Fill and refill the ready pool
The client repeats with new nullifiers until a wave fills the ready pool, then refills after the proposer drops invalid entries. No fee is charged.
6
Force proposer-side verification
The production QPoW proposer iterates the ready pool, push()es each candidate, runs pre_dispatch() / verifier.verify(), logs Public-batch proof verification failed, and continues.
7
Observe the impact
The observer records pool occupancy, verifier-failure count, processed candidate bytes and verification work versus the limits that should have bounded proposer processing, proposer wall-clock, CPU, and honest-transfer inclusion delay.
Impact Details
The processing node suffers unpriced proposer-time ZK verification: the proposer processes candidate bytes and verification work beyond the block-size, block-weight, and skip-cap limits that should have bounded proposer processing. The attacker pays no fee and needs no Dilithium key or balance.
On the measured private --dev run, 1,501 unsigned candidates were accepted and 1,501 full verifier failures were logged. One proposal processed 500 invalid proofs (111,813,500 candidate bytes, 10.538 s of configured ZK ref_time) in 2,951 ms. The final block itself does not exceed the 4,194,816-byte block-size limit, because those invalid transactions are not included. The proposer nevertheless processed candidate bytes and verification work 26.66× that size limit and 27.78× the 18-extrinsic inclusion cap that should have bounded proposer processing. The skip cap of 8 was not applied. An honest transfer_keep_alive submitted while the pool was occupied took 6,950 ms to include versus 2,660 ms in steady state. Sampled attack hashes were not included in any block. Node CPU peaked at 245.3% during the flood versus ~72–99% while only mining.
The attacker gains no funds. The node does not halt: blocks continued and recovery still produced a block. This run did not hit the 9-second HitDeadline because native verification drained a 500-candidate wave in about 3 seconds. It also does not demonstrate a 24-hour 30% resource increase, theft, or freezing of funds.
Dashboard impact: High — causing network processing nodes to process transactions from the mempool beyond set parameters. The program text for that impact specifically names custom unsigned Wormhole validate_unsigned / Pays::No / pool provides tags.
Risk Breakdown
Severity is High under the project-specific High #2 impact (mempool processing beyond set parameters). Under the generic Immunefi table this family is often Medium DoS; the program's own impact list is the one to select on the dashboard.
Exploitability is moderate. The attacker needs only loopback or RPC access and the committed fixture. There is no privileged role, no signature, and no balance requirement. The attacker must keep public inputs synchronized with the live block hash/number and use unique nullifiers.
Repeatability is high. Four refill waves produced 1,501 accepted candidates and three ~3-second proposals each processing ~500 invalid proofs.
Likelihood is high on any node that accepts unsigned Wormhole extrinsics into the ready pool, which is the production quantus-node path.
This is not known issues #1 (referendum votes), #2 (miner plaintext), or #3 (zk-tree depth). It is not ML-DSA verification inside UncheckedExtrinsic::check(): the PoC submits a bare unsigned Wormhole call and never verifies a Dilithium signature.
Standalone attacker binary against a private quantus-node --dev over loopback RPC. It does not mock validate_unsigned(), patch the runtime, or write node storage. It submits bare unsigned Wormhole::verify_public_batch extrinsics via author_submitExtrinsic. The production QPoW proposer / Basic Authorship consume them.
Private disposable node only. Do not point this at a public network.
Prerequisites
Git, a Rust toolchain that can build this repo, clang/libclang, free ports 19945/30345/19616, fixture pallets/wormhole/test-data/public_batch.hex. Work from the chain crate root. --rpc-methods Unsafe is required. macOS: export LIBCLANG_PATH=/opt/homebrew/opt/llvm@21/lib. Set NODE_LOG_PATH before the harness starts (the runner does this).
Files to add
Do not modify pallets/wormhole or the runtime. Add two files from the following sections, then insert these PoC-only deps in node/Cargo.toml[dependencies]:
Place pallet-wormhole next to the other pallet-* entries, and the two verifier crates next to the other qp-* entries. After the edit the relevant region looks like:
cargo build will update Cargo.lock to record quantus-node now depending on pallet-wormhole, qp-plonky2-verifier, and qp-wormhole-verifier. Allow that lockfile update. Do not hand-edit Cargo.lock.
Do not add these crates to the node std / runtime-benchmarks / try-runtime feature lists. Do not change pallets/wormhole, the runtime, or QPoW. Do not replace the fixture.
Full PoC source
node/src/bin/wormhole_proposer_e2e.rs
Paste this file exactly.
node/scripts/run_wormhole_proposer_poc.sh
Paste this file exactly, then chmod +x.
How to run
1
Clone and check out the target revision
2
Add the dependencies and PoC files
Add the Cargo.toml lines and the two files from the Full PoC source section.
3
Configure libclang on macOS
Omit on Linux if libclang is visible.
4
Build the node and PoC binary
5
Run the PoC
Success (numbers can move with CPU; inequalities must hold): duplicate-nullifier control is Invalid Transaction; ATTACK_TOTAL_REJECTED=0 and ATTACK_TOTAL_ACCEPTED is in the thousands (measured 1501); NODE_LOG_VERIFIER_FAILURES matches accepted; IMPACT_EXCEEDS_PROPOSER_BLOCK_SIZE=true, IMPACT_EXCEEDS_NORMAL_ZK_WEIGHT=true, IMPACT_EXCEEDS_SKIP_CAP=true; ATTACK_CANDIDATES_INCLUDED_IN_CHAIN=0; control delay > steady delay; node log shows Prepared block for proposing at ... (29xx ms) during the attack vs (0 ms)/(1 ms) idle. Blocks still advance.
Measured-run excerpts
Revision 3b243b870a5442a6e5f443056f67bbba87d472f5. Public batch, 500 variants/wave, 24 s attack. Full poc.log / node.log are attached.
The final blocks themselves do not exceed the block-size limit, because the invalid transactions are not included (extrinsics_count stays 1–2). The proposer still processed candidate bytes and verification work 26.66× the 4,194,816-byte size limit, over the 4.5 s / 6.0 s ZK weight caps, and over the skip cap of 8. IMPACT_EXCEEDS_PROPOSER_BLOCK_SIZE=true compares processed candidate bytes against DEFAULT_BLOCK_SIZE_LIMIT; it does not mean the emitted block exceeded that limit.
Harness (poc.log)
Node log (node.log)
1,501 identical Public-batch proof verification failed lines omitted. Idle proposals 0–2 ms; attack proposals 2,951 / 2,946 / 2,955 ms; extrinsics_count stays 1–2 because invalid txs are not included.
fn validate_unsigned(_source: TransactionSource, call: &Self::Call) -> TransactionValidity {
// Pool admission runs on every gossiped transaction, so it deliberately performs
// only the cheap pre-validation ... and NOT the expensive ZK verification.
match call {
Call::verify_public_batch { proof_bytes } => {
let (_, _, bundle, _validity) =
Self::pre_validate_public_batch_proof(proof_bytes) // @audit: deserialize, canonical encoding, bundle/nullifier checks only. No verifier.verify().
.map_err(|_| InvalidTransaction::Call)?;
ValidTransaction::with_tag_prefix("WormholePublicBatch")
.and_provides(Self::exit_bundle_provides_tag(&bundle)) // @audit: tag is a hash of public-input nullifiers. Attacker chooses those nullifiers.
.priority(crate::UNSIGNED_EXIT_PRIORITY) // @audit: constant 1. No fee, no signed origin.
.longevity(5)
.propagate(true)
.build()
},
// verify_private_batch is the same cheap path
_ => InvalidTransaction::Call.into(),
}
}
fn exit_bundle_provides_tag(bundle: &ExitBundle) -> [u8; 32] {
let mut preimage = Vec::new();
for segment in &bundle.segments {
for nullifier in &segment.nullifiers {
preimage.extend_from_slice(nullifier.as_ref()); // @audit: nullifiers are still unverified public inputs here.
}
}
sp_io::hashing::blake2_256(&preimage)
}
fn pre_dispatch(call: &Self::Call) -> Result<(), TransactionValidityError> {
// Block-inclusion gate. ... this runs the FULL validation including ZK verification
match call {
Call::verify_public_batch { proof_bytes } =>
Self::validate_public_batch_proof(proof_bytes) // @audit: cheap checks again, then verifier.verify().
.map(|_| ())
.map_err(|_| InvalidTransaction::Call.into()),
_ => Err(InvalidTransaction::Call.into()),
}
}
fn validate_public_batch_proof(proof_bytes: &[u8]) -> Result<(ExitBundle, Vec<bool>), Error<T>> {
let (verifier, proof, bundle, validity) =
Self::pre_validate_public_batch_proof(proof_bytes)?;
verifier.verify(proof).map_err(|e| { // @audit: first cryptographic authentication. Runs in the proposer, not at pool admission.
log::error!("Public-batch proof verification failed: {:?}", e);
Error::<T>::ProofVerificationFailed
})?;
Ok((bundle, validity))
}