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

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/validation

  • client/network-sync/src/strategy/chain_sync.rs:1049-1144 — peer inserted with its claimed best_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

1

A miner is running normally

Any reachable QPoW validator. In the measurement below it produces ~51 blocks/minute at the reduced test difficulty.

2

An attacker connects one peer and asserts a false height

The peer completes an ordinary handshake but advertises a best_number far ahead of reality. It mines nothing, serves nothing, and holds no funds:

3

The victim records the claim without validating it

The claimed height enters ChainSync before any header or work check (§2.1).

4

The median becomes the lie, and the node declares itself syncing

The victim is now "syncing" toward a block that does not exist.

5

Mining stops

Repeated every 5 seconds for the duration.

6

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:

Claim
Status

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

1

Do not let unverified handshake metadata gate authoring

Count a peer's claimed height toward sync state only after receiving a header for that tip and validating its cumulative work.

2

Bound per-peer influence on the median

Weight by network identity/address group so that inbound slots — which are free to occupy — cannot dominate outbound, established peers.

3

Reject extreme outliers

A claimed height a million blocks beyond the local best, unaccompanied by any deliverable header, should be treated as evidence against the peer rather than as a sync target.

4

Penalise peers that cannot deliver what they advertise

Recovery currently happens by accident; making it an explicit reputation consequence would also close the rotation avenue.

5

Consider decoupling authoring from the sync oracle

For operators who have deliberately opted into --force-authoring, or document clearly that the flag does not cover this case.

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:

  • H must be shielded from the liars. On the first attempt the liars discovered H through V's DHT and poisoned the miner too, which stopped block production and would have confounded the result. Pinning H with --reserved-only to V fixes 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. V hard-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?