90210 bc medium quantus s 36 772 transaction default crosses the 10 239 entry gossip cache causing repeated full peer disconnects and node resource amplification
Submitted on Aug 23rd 2026 at 18:52:29 UTC by @tinnohofficial for Audit Comp | Quantus
Report ID: #90210
Report Type: Blockchain/DLT
Report severity: Medium
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Increasing network processing node resource consumption by at least 30% without brute force actions, compared to the preceding 24 hours
Description
Brief/Intro
Quantus increased the compiled transaction-pool defaults from 8,192 transactions / 20 MiB to 36,772 transactions / 256 MiB to accommodate its approximately 7.3 KB post-quantum transactions. This crosses a separate transaction-networking limit that retains only 10,239 known hashes per peer.
At exactly 10,240 ready transactions, the per-peer LRU enters an all-miss cycle. Every 2.9 seconds the node tries to resend all 10,240 large transactions to each full peer. The 2,048-message Litep2p queue fills and force-closes the entire transport connection, including block announcements. Reconnection clears the known-hash cache and restarts the same cycle.
The PoC uses ordinary signed, fee-paying System.remark transactions from one endowed ML-DSA-87 account. The attacker submits them to its own Quantus peer and gossips them over P2P; no RPC access to a victim is required. After the threshold is crossed, nodes repeatedly resend, allocate, disconnect, and reconnect without further attacker traffic.
Vulnerability Details
Quantus raises the pool above the networking boundary
The frozen node compiles these defaults:
See client/cli/src/params/transaction_pool_params.rs:45-55. node/src/service.rs:619-629 passes this configuration directly to the production pool builder.
The transaction protocol independently uses:
See sc-network-transactions-0.54.0/src/config.rs:27-36.
This boundary became reachable because of Quantus's pool-size change:
Commit
d0511b169036d181049dd9764a9f6533ade4532bfirst hardcoded a pool of 36,772 transactions / 256 MiB.Commit
8f4e0d5fbcddc8299ad13ccc10840044e3e702f2moved the values into the current CLI defaults.The corresponding upstream CLI defaults are 8,192 transactions / 20 MiB, both below the trigger.
No operator override is required. A node started without --pool-limit selects 36,772 automatically.
The Quantus change introduces the capacity mismatch by compiling a ready-pool limit above the independently bounded gossip cache into every default node. The preceding 8,192-transaction / 20 MiB defaults remain below the trigger.
The nominal 10,240-entry cache retains 10,239 hashes
LruHashSet documents an exclusive limit and evicts when its length reaches the configured value:
See client/network/src/utils.rs:32-60.
With 10,239 transactions, every hash remains cached and later scans send nothing. With 10,240 transactions in stable pool order, the first hash misses and evicts the second, the second evicts the third, and so on. Every hash misses on every scan.
Each peer receives a cache configured with MAX_KNOWN_TRANSACTIONS. Closing its transaction stream removes the entire peer record and cache: sc-network-transactions-0.54.0/src/lib.rs:344-364.
One all-miss scan closes the whole transport
Every miss is sent as a separate synchronous notification:
See sc-network-transactions-0.54.0/src/lib.rs:470-509. The production signed remarks are 7,263-7,264 bytes each, so a 10,240-entry pool contains approximately 70.93 MiB of encoded transactions.
Litep2p's synchronous queue holds 2,048 messages (client/litep2p/src/protocol/notification/types.rs:30-34). When it clogs, send_sync_notification queues NotificationCommand::ForceClose (notification/handle.rs:378-404). force_close then closes the connection even when other protocols have open substreams:
See client/litep2p/src/protocol/transport_service.rs:601-619.
The result is a deterministic loop:
Distinction from public upstream issues
Polkadot SDK issue #8873 discusses generic notification clogs under CPU starvation, but not transaction gossip, the LRU cycle, or the 10,240 boundary.
Issue #11540 observed whole-transport
ForceClose, but protocol attribution found every event came fromgrandpa/1and zero fromtransactions/1orblock-announces/1.Neither cited issue identifies the pool-size/cache interaction or this valid-transaction trigger.
Impact Details
The issue was reproduced on two frozen-HEAD Quantus full nodes using default Litep2p and default pool settings.
At 10,239 ready transactions, both nodes retained their peer, notification counters stopped changing, no transaction or block stream closed, and CPU increased by only approximately 0.50 seconds during a 46-second stable window.
Submitting one additional signed transaction crossed the boundary. During the following 48 seconds, with no further submission:
The one-transaction A/B transition isolates the cache boundary from startup load, slow-receiver behavior, and generic pool pressure.
In a separate four-node run, an original 74,393,535-byte transaction set produced 13,486,465,552 bytes and 1,856,363 outgoing notifications in approximately 18 minutes: 181 times the original set size. The nodes recorded 1,220 transaction-stream closures, with matching block-announcement closure counts.
Sustainability was demonstrated by replenishing the 541 remarks admitted by one block in 7.793 seconds, below the 12-second target block interval. Maintaining the threshold costs approximately 4.267 UNIT per block in ordinary inclusion fees. Each valid transaction is submitted once; the affected nodes autonomously produce the repeated work.
The measured 159% increase in victim CPU exceeds the selected Medium impact's 30% resource-consumption threshold. Total network shutdown was not observed; blocks eventually propagated in the four-node test.
Recommended Fix
Restore the invariant in Quantus's node configuration:
As defense in depth, increase the per-peer cache, bound each propagation pass, and avoid closing the entire transport when one notification protocol clogs. Changing only the byte limit is insufficient unless it guarantees fewer than 10,240 of the smallest accepted extrinsics.
References
client/cli/src/params/transaction_pool_params.rs:45-80- Quantus pool defaults.node/src/service.rs:619-629- production pool wiring.client/network/src/utils.rs:32-60- exclusive LRU capacity.client/litep2p/src/protocol/notification/types.rs:30-34- 2,048-message queue.client/litep2p/src/protocol/notification/handle.rs:378-404- queue clog schedulesForceClose.client/litep2p/src/protocol/transport_service.rs:601-619- whole-connection closure.sc-network-transactions-0.54.0/src/config.rs:27-39- scan interval and cache limit.sc-network-transactions-0.54.0/src/lib.rs:344-364,470-537- cache lifecycle and send loop.Quantus commits
d0511b169036d181049dd9764a9f6533ade4532band8f4e0d5fbcddc8299ad13ccc10840044e3e702f2.
Proof of Concept
PoC 1: Exact LRU Boundary
Create client/network/examples/quantus_lru_cycle.rs in the frozen chain repository with this code:
Run it with:
The cache PoC prints:
PoC 2: Live Quantus Transaction Gossip
Create runtime/examples/quantus_transaction_gossip_dos.rs with this code. It generates production ML-DSA-87 signed System.remark extrinsics with every frozen-runtime transaction extension and submits them in JSON-RPC batches.
Build the node and live PoC from the repository root:
Run Two Nodes
Node A
Node B
Both are non-authorities, so the chain remains at genesis and isolates transaction gossip.
Submit the non-triggering side of the boundary to node A:
Wait at least 20 seconds. Both pools remain at 10,239, both nodes retain one peer, and notification counters stop changing.
Cross the boundary with one transaction:
Within seconds, transaction and block-announcement closure counters rise together, peers repeatedly fall to zero, and outgoing transaction bytes grow on every reconnect without further submissions.
Relevant metrics are available on http://127.0.0.1:9647/metrics and :9648/metrics:
Was this helpful?