88224 bc medium a peer advertising a forged block height suspends a victim node s mining and silently blackholes its block import notifications
Submitted on Aug 12th 2026 at 09:57:53 UTC by @shadowHunter for Audit Comp | Quantus
Report ID: #88224
Report Type: Blockchain/DLT
Report severity: Medium
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Description
1. Summary
The block-announces handshake carries a peer's claimed best_number and best_hash. The receiving node inserts that claimed height into ChainSync before validating any header or any cumulative work. median_seen() then reports the median of all peers' claimed heights, and if that median is more than five blocks ahead the node enters major-sync state. The QPoW mining loop declines to propose while the sync oracle reports major syncing.
With one attacker peer and no honest peers, the median is simply the attacker's lie. Measured result: the victim entered ⚙️ Syncing … target=#1000000, logged 22 Mining paused: node is still syncing with network events, and produced 14 blocks in 120 seconds against a baseline of ~102.
2. Description
2.1 Claimed height is trusted before validation
The handshake is validated for encoding and genesis hash, then the claimed height is recorded:
client/network-sync/src/engine.rs:856-897— handshake decode/validationclient/network-sync/src/strategy/chain_sync.rs:1049-1144— peer inserted with its claimedbest_number
No header for the claimed tip is required, and no cumulative work is checked, before the number is used.
2.2 The median is the upper median
For an even peer count, index len / 2 is the upper median, which favours the higher-claiming side. With n honest peers, n attacker peers suffice.
2.2b Major-sync state ALSO suppresses block-import notifications
This is the second, larger consequence, and it affects nodes that never mine.
While is_major_syncing() is true, imported blocks are labelled with a different origin:
and that origin decides whether any import notification is emitted at all:
So every consumer of the import-notification stream — chain_subscribeNewHeads, chain_subscribeFinalizedHeads, the transaction pool's maintenance signal, and the node's own informant — receives nothing, while the node continues importing normally. Nothing in the node's status output indicates that subscribers are being starved.
A third effect, verified in code but not separately measured: download parallelism collapses while in this state.
2.3 Mining stops while the sync oracle reports major syncing
Note this check sits before the zero-peer guard, and --force-authoring does not bypass it: that flag only sets allow_mining_without_peers, which gates the separate is_offline() branch at service.rs:351. A miner using --force-authoring is still subject to this pause.
2.4 Peer slots make the multi-peer case cheap
Occupying inbound slots is not the same as eclipsing a node. With 8 honest outbound peers and up to 32 attacker-supplied inbound peers, controlling the median does not require displacing anyone.
3. Steps to reproduce
The victim eventually recovers, unaided
After roughly 111 seconds the node stops believing the peer, target falls back to its own height, and block production returns to baseline — even though the attacker remains connected and continues to advertise the forged height. This is the honest limit of the finding.
4. Impact
What is demonstrated: an unauthenticated peer, at essentially zero cost and with no proof-of-work, suspends a specific miner's block production for ~111 seconds. On a chain targeting 12-second blocks that is roughly 9 blocks of lost revenue per attempt for the targeted miner. The victim's own --force-authoring flag does not protect it.
The asymmetry is the point: the attacker sends one integer in a handshake field. The victim stops doing the thing the network pays it for.
4b. The notification blackout (measured separately, §6.7)
A non-mining victim, syncing legitimately from an honest miner, was subjected to the same forged-height attack:
Segment
Duration
Victim imported
Notifications delivered
isSyncing
Baseline
49 s
#44 → #70 (27 blocks)
27
false
Attack
112 s
#70 → #129 (59 blocks)
0 for 101.0 s
true, highestBlock=1000000
Recovery
84 s
#135 → #188 (53 blocks)
54
false
Notification #38 delivered block 81; notification #39 delivered block 134, 101.0 seconds later. Blocks 82–133 were never delivered at all.
Independent in-node corroboration, which rules out any fault in the test client: the victim's own informant — a separate consumer of the same gated stream — printed zero Imported #N lines for blocks 82–133, while simultaneously reporting 20 distinct best: #N heights inside that same range. It knew it had those blocks; it just never announced them.
Counts from the victim's log:
Block range
Imported lines
44–81 (before)
38
82–133 (blackout)
0
134–188 (after)
55
Why this matters more than the mining pause. An exchange, indexer, or RPC provider driving off chain_subscribeNewHeads would silently miss 52 blocks. The node answers system_health normally, keeps importing, and logs nothing at info to indicate its subscribers are being starved. The failure is invisible from both sides: the operator sees a healthy node, and the consumer sees an idle chain.
What is not demonstrated, and is explicitly not claimed:
Miner pauses
Confirmed (~111s, 22 pause events)
Non-mining node's notifications blackholed
Confirmed (101.0s, 52 heads lost)
Node keeps importing during the blackout
Confirmed (block 70 → 129 while silent)
Failure is silent to operator and consumer
Confirmed (system_health normal; nothing at info)
Pause is permanent from ONE static peer
Refuted — peer is hard-banned in ~30-45s; recovery follows
Sustained via identity rotation
Confirmed — a fresh liar every ~38s held it; node keys are free
finalizedHeads / tx-pool channel also starved
Not measured — same code gate, but unverified
Network-wide halt
Not claimed — every prior chain-halt framing is unsupported
Other nodes affected
No — each victim must be targeted individually
How these two effects combine into the rating. Because the node self-heals, the mining pause considered alone is a nuisance-grade availability issue against an individual miner — not a consensus or liveness threat — and on its own it would not warrant more than Low. That was this report's original assessment.
The Medium rating rests on §4b instead: the notification blackout applies to any reachable node rather than only miners, destroys data rather than delaying it (the 52 intervening heads are never delivered, not queued), and is silent to the operator and the consumer simultaneously. A downstream exchange or indexer has no signal that it has stopped receiving blocks. Neither effect is a consensus or liveness threat, and neither is claimed to be.
Why it is still worth fixing: the underlying property is that unverified metadata from an unauthenticated peer can suspend authoring. The recovery is incidental — it happens because the lying peer fails to deliver blocks — rather than the result of any validation. A more careful attacker (one that also serves plausible headers, or that rotates identities as the victim loses confidence) is not obviously bounded by the same 111 seconds, and that possibility is exactly what the current design leaves open.
5. Recommendation
Proof of Concept
6. Proof of concept
6.1 Environment
OS
Ubuntu 24.04.1 LTS (WSL2 on Windows)
CPU
AMD Ryzen 7 5800H (12 cores), 9.9 GB RAM
Rust
1.93.0 (pinned by rust-toolchain)
6.2 Setup
Build the VICTIM first, from pristine source:
A reduced-difficulty chain spec is used so blocks take ~1s instead of ~35s, making the effect measurable in minutes. This is a supported chain-spec override (pallets/qpow/src/lib.rs:92-94: "Use the genesis config value, not the runtime constant. This allows chain-spec overrides of initial difficulty.") and touches nothing in sync, mining, or peer handling:
The scripts in §6.4 and §6.7 both read the raw spec from $HOME/quantus-poc/poc4-split/poc4-raw.json, so build it at that path:
The scripts also read the checked-out source from $HOME/quantus-poc/chain to print the code under test. Either clone there, or symlink the working tree:
6.3 The attacker instrumentation
Applied only to the attacker binary, gated behind QPOC_FAKE_HEIGHT; with the variable unset the code is byte-for-byte stock. It inflates the best_number advertised in the block-announces handshake at both construction sites — the initial one baked into the protocol config, and the refresh on every new best block.
Save as poc7_attacker.diff and apply with git apply poc7_attacker.diff:
Why this does not weaken the result. The patch changes only what the attacker advertises about itself — precisely what a hostile peer with a custom client would send. The victim binary is pristine and was built before the patch existed. Nothing in the patch touches how the victim validates, medians, or decides to mine.
6.4 The experiment
Save as poc7_run.sh. It measures a baseline with no attacker, then attaches one forged-height peer and samples per-interval block deltas.
6.5 PoC output
Baseline (no attacker): +51 blocks in 60s
Per-interval, with one forged-height peer attached:
The victim's own log — it adopted the forged height and stopped mining:
And the recovery, with the attacker still connected and still lying:
6.6 Explanation of the output
target=#1000000 is the finding in one line. That value is the attacker's forged bump. The victim abandoned block production to chase a block that does not exist, on the word of a peer that supplied no header, no work, and no evidence of any kind.
The pause is real and measured, not inferred. 22 Mining paused: node is still syncing with network events spanning 14:44:10–14:46:01 (~111 seconds), with d_blk = +0 across four consecutive 15-second intervals in the middle of the window. Baseline over the same duration would be ~102 blocks; the victim produced 14.
Note the log level. That pause message is emitted at debug!. An earlier run at RUST_LOG=info reported zero occurrences — not because the pause did not happen, but because the message could not be printed. The count is only evidence at pow=debug.
The recovery is equally measured, and bounds the finding. From t=135s the victim's isSyncing returns to False, target falls back to its real height, and block deltas return to baseline (+13, +16, +12, +10 ≈ 51/minute). The attacker is still connected (peers=1) and still advertising the lie at 14:47:04. The node therefore recovers by ceasing to trust a peer that cannot deliver, not by validating anything up front.
Why the aggregate figure is misleading. Averaged across the whole 180s window the result reads "21 blocks/min vs 51 baseline", which understates the effect during the pause and overstates it after recovery. The per-interval deltas are the meaningful measurement, which is why they are reported here rather than a single ratio.
6.7 Second experiment — the notification blackout
This is a separate three-node experiment measuring the effect on a non-mining node. Same binaries, same chain spec, same setup as §6.2–6.3.
Topology. H an honest miner, V a non-validator victim with RPC enabled, and rotating liars B1..B3 pointed only at V.
Two topology details are load-bearing:
Hmust be shielded from the liars. On the first attempt the liars discoveredHthroughV's DHT and poisoned the miner too, which stopped block production and would have confounded the result. PinningHwith--reserved-onlytoVfixes it. In a real network an attacker would poison both, but shielding is what isolates the notification failure from the mining pause.Liars must be rotated.
Vhard-bans a peer that advertises blocks it cannot deliver within ~30–45 s. The experiment launches a fresh liar (fresh node key ⇒ fresh peer id) every ~38 s to sustain the window.
Transport gotcha: this build's litep2p listen addresses are /ip4/…/tcp/PORT/ws/p2p/ID. A bootnode multiaddr without the /ws segment silently fails to dial, with no log at info.
The WebSocket subscriber (ws_sub.py). A raw RFC6455 client, since pip/websockets were unavailable offline:
The experiment (recovery_run2.sh) — baseline, attack and recovery under one continuous subscription:
poll.sh:
Output
Subscriber, showing the blackout boundary and the resumption:
Victim in major sync while importing:
RPC concurred: {"currentBlock":102,"highestBlock":1000000}, {"peers":3,"isSyncing":true}.
The honest miner never stopped (control):
The ban that forces rotation:
Independent verification of the key result
The strongest evidence is not the WebSocket client at all — it is the victim's own informant, a separate consumer of the same gated stream. Counting Imported #N lines in V.log by block range:
Block range
Imported lines
44–81 (before)
38
82–133 (blackout)
0
134–188 (after)
55
Meanwhile the same log file reports 20 distinct best: #N values inside 82–133, proving the node did reach those heights. It had the blocks and never announced them. Because this consumer is independent of the test client, the silence cannot be a client-side artifact.
Was this helpful?