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

88219 bc medium blocks with invalid proof of work carry no peer penalty a hostile peer can force unlimited sync restarts and is re admitted as a stranger every time

#88219 [BC-Medium] Blocks with invalid proof-of-work carry no peer penalty: a hostile peer can force unlimited sync restarts and is re-admitted as a stranger every time

Submitted on Aug 12th 2026 at 09:50:40 UTC by @shadowHunter for Audit Comp | Quantus

  • Report ID: #88219

  • 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

Quantus removed upstream sc-consensus-pow and wrote its own import pipeline. Two consequences followed, and together they remove the economic asymmetry that makes proof-of-war spam self-limiting:

  1. The seal is verified in the wrong place. SimplePowVerifier::verify — the Verifier implementation the import queue calls — does not check the proof-of-work at all. It only pops the seal digest. The actual check moved into import_block, and it runs after a full WASM check_inherents call over the attacker's entire block.

  2. A seal failure is therefore not attributed to the peer. Because the check no longer happens in the verifier, InvalidSeal cannot surface as BlockImportError::VerificationFailed — the variant that carries a peer_id and triggers DropPeer. It surfaces as BlockImportError::Other, whose handler logs a warning, wipes sync state, and restarts — with no DropPeer and no reputation change.

The result is that a peer can serve blocks with garbage proof-of-work indefinitely and is never penalised. Measured over a 150-second run: 18 rejected blocks, 7 sync restarts, 9 peer re-admissions, 0 reputation actions, and the victim never advanced past genesis.

The node's own disconnect tracker logs the same peer as being added "for the first time" on separate occasions — direct evidence that no memory of the misbehaviour is retained.

2. Description

2.1 The verifier does not verify the proof-of-work

extract_pow_seal only length-checks the digest and pops the seal item. There is no difficulty lookup and no hash comparison. Upstream sc-consensus-pow's PowVerifier::verify performs the seal check at exactly this point; this relocation is the introduced delta.

2.2 Expensive work runs before the cheap check

In import_block, the block is first passed through check_inherents:

check_inherents is a runtime API call: WASM instantiation, parent-state access, and SCALE-encoding of the entire attacker-supplied block into WASM memory, preceded by two full block clones. node/src/service.rs:647 passes check_inherents_after = 0, so no block height escapes it.

Only afterwards is the seal checked:

That check is genuinely cheap — qpow-math/src/lib.rs:7-49 is one Poseidon2 sponge over a fixed 96-byte input plus a 512-bit division, with no attacker-controllable cost. It should run first.

2.3 The peer-attribution asymmetry

Err(Error::InvalidSeal) returned from import_block maps to BlockImportError::Other. These two arms sit adjacent in the same match:

The first drops the peer with rep::VERIFICATION_FAIL. The second does not. Invalid proof-of-work — the one thing a PoW chain must punish, because it is the only proof that a peer expended real work — lands in the second.

restart() (chain_sync.rs:1698-1726) clears self.blocks, resets the sync start point, and pushes SyncingAction::CancelRequest for every peer in a downloading state, not just the offending one.

3. Steps to reproduce

1

An attacker runs a node that publishes blocks with invalid proof-of-work

No special position is needed: any peer that can be connected to, or that a victim uses as a bootnode, will do. The attacker mines normally and then corrupts one byte of the seal before publishing, so the block is well-formed but carries no valid work:

2

A victim node connects and requests the attacker's chain

Ordinary sync behaviour. The victim sees the attacker announce a higher block and schedules a request:

3

The victim performs the expensive work first, then rejects the block

check_inherents runs over the whole block (WASM instantiation, runtime call), and only afterwards does the seal check fail:

Note the leading 255 — that is the ^ 0xFF corruption, confirming the seal reached verification unmodified.

4

The victim wipes its sync state and restarts, cancelling requests to all peers

5

The attacker is not penalised, and is re-admitted as a stranger

No reputation change is recorded. The node's disconnect tracker then logs the same peer as new again on a later cycle:

6

Repeat indefinitely at no cost

Because nothing was debited, the attacker simply serves the same block again. Over 150 seconds the victim logged 18 rejections across 6 rounds, 7 restarts and 9 re-admissions, and never advanced past block 0.

4. Impact

A peer can consume a victim's sync capacity indefinitely at negligible cost and is never penalised.

Each bad block costs the attacker essentially nothing — no proof-of-work, no funds, no identity, roughly 1 KB on the wire. Each one costs the victim two full block clones, a WASM instantiation, a whole-body runtime call, and — most damagingly — a full sync restart that cancels in-flight block requests to every connected peer, not merely the offending one.

Consequences:

  • A node that is behind can be held there. A freshly started node, or one recovering after downtime, repeatedly discards its download progress. In the two-node experiment the victim never left genesis across 150 seconds despite a peer advertising a longer chain.

  • The punishment mechanism that exists is bypassed. rep::VERIFICATION_FAIL and DropPeer are implemented and used for the adjacent error class. Invalid proof-of-work simply never reaches them.

  • No accumulation, so no eventual mitigation. Because the peer is recorded as new each cycle, there is no threshold it can ever cross. Peer-reputation decay and ban thresholds are irrelevant to an offence that is never reported.

  • Verification ordering amplifies the cost. The expensive check runs before the cheap one, so a peer with zero hashrate makes the victim do runtime-level work per junk block.

The cross-peer effect — measured (§6.7)

A three-node experiment (honest miner H, victim V, attacker A) settles the question of whether a hostile peer degrades a victim's legitimate downloads from honest peers. It does.

Phase

Duration

V imported

H produced

V keeps up with H

V lag at end

Invalid Seal

Baseline (no A)

120 s

35

35

100.0 %

0

0

Attack

240 s

59

74

79.7 %

15

2

Recovery (A detached)

150 s

65

65

100.0 %

0

0

The phase totals understate it, because V did not slow down evenly — it stalled outright and then caught up in bursts. Per-10-second interval:

V imported nothing in 21 of 24 attack intervals (87.5 %). Its own status line during the stall, with the honest peer connected and producing:

Upload rises from ~0.1 kiB/s at baseline to ~12.4 kiB/s during the attack — the victim is spending its bandwidth on the attacker rather than on the chain. (It settles at 0.3 kiB/s in recovery, above baseline because V now has a longer chain to serve.)

The request counts come from a separate, sync=debug-level run of the same three-node topology, not from the info-level run tabulated above — debug logging is what makes the per-request lines visible at all, and mixing the two would be misleading. In that run, over a 68.4-second window V issued 84,325 block requests to A (~1,230/sec) against 32 to H — an asymmetry of about 2,600 : 1. Across the whole run the totals were 84,325 to A and 292 to H. The debug log names the starved peer directly:

Two caveats on this second run, stated because they are the kind of thing that should not surface first in triage. Its orchestrator's 60-second attach poll timed out before A connected and printed PHASE 2 IS VOID; A attached shortly afterwards and the attack did land (a_peers=1, 2 Invalid Seal, 4 sync restarts), but the run was not clean by its own gate. And 240 MB of debug log in 68 seconds is itself a load — which is precisely why the info-level run above, not this one, is the authoritative measurement of the stall.

The contrast that makes this a defect rather than a design choice

In the info-level run, both nodes reported the attacker within about four seconds of it attaching (A attached at 16:44:12), and both for the same reason — request spam, not invalid proof-of-work:

The two outcomes then diverge completely:

  • On H, the attacker never persisted. H's peer count never exceeds 1 in any status line of the entire run ((1 peers) ×111, (0 peers) ×15, (2 peers) ×0) — its one peer being V. A's connection was short-lived enough never to appear in a 5-second sample, and A never reappears after the report. Note this is an absence of A from H's peer count, not an observed 2→1 transition; the report line is the direct evidence, the peer count is corroborating.

  • On V, the ban changed nothing. V held peers: 2 for the entire 240-second attack window ((2 peers) on every status line from 16:44:14 to 16:48:14, dropping to Idle (1 peers) only at 16:48:19, after A was detached externally). V stayed degraded for the full four minutes despite having emitted the ban four seconds in.

Two things follow, and they are the actual finding:

  1. The reputation system never sees the offence that matters. All 8 of this run's import failures (2 Invalid Seal, 6 unknown parent) produced no reputation change. The only report V emitted in the entire run was for duplicate block requests — a side-effect of the retry loop, not a judgement about the blocks. grep -icE "banned|report |reported|disconnecting|reputation" victim.log returns 1, and that one line is the request-spam report. In the two-node PoC of §6, where V is not driven into the same request loop, the equivalent case-insensitive search returns 0 across 18 invalid seals.

  2. A ban does not evict a reserved peer. V had A in its reserved set (the topology requires V to dial A, see §6.7), and the ban did not disconnect it. So even when the machinery does fire, it does not remove the attacker.

The ban machinery exists and works — it fired on both nodes, within seconds, for a lesser offence. Serving blocks with forged proof-of-work never reaches it at all.

Correction note. An earlier revision of this report stated that the victim "never banned it, never disconnected it". That was wrong, and the error was mine: the harness grepped V's log with a case-sensitive pattern (Reported|banned|Disconnecting) that cannot match the actual text (Report , Banned,, disconnecting.), printed an empty section, and the claim was written from that empty output rather than from the log. The corrected facts are above. The finding is unchanged — invalid proof-of-work still produces no reputation change — but the contrast is narrower than originally stated.

Scope, stated honestly

  • Attribution is broader than a single line. The stall is not purely restart()'s CancelRequest. V is also locked in an ancestor-search / re-request loop against A (~1,230 req/s in the debug-level run), during which no request is produced for H. restart() clearing self.blocks is part of that loop — and the periodic restarts are in fact what let V briefly catch up. The consequence (honest downloads disrupted) is measured; pinning it on the CancelRequest line alone would be narrower than what was observed.

  • Magnitude depends on the attacker's chain shape. Here A served a private bad-seal fork from genesis, so V's ancestor search resolved all the way to genesis and it looped hard. A variant where A instead reorged onto the victim's chain and appended one invalid block at tip+1 produced only 1–2 rejections. A real adversary would choose the former; the latter was not measured.

  • The attacker held reserved status, and that bounds the duration claim. The harness connects A by calling system_addReservedPeer on V (§6.7), because --reserved-only prevents A from dialing out on this backend. A is therefore in V's reserved set — and that is why the ban V emitted at 16:44:12, four seconds after A attached, did not disconnect it. A real attacker cannot place itself in a victim's reserved set. The cross-peer disruption itself is not in question — the reject → restart() → re-request loop runs regardless of reserved status while the peer is connected — but the measured 240-second duration is specific to this topology. Against an ordinary inbound peer the ban would be expected to evict the attacker within seconds, converting a sustained stall into a reconnect loop. That variant was not measured, and this report does not claim the 79.7 % figure holds for it. Establishing the non-reserved magnitude requires a separate run with A as an ordinary inbound peer and H shielded from A.

  • n = 1 per phase, single run. The effect is large and both controls are clean, but this is one trial.

5. Recommendation

  1. Check the seal in the verifier, before the expensive work. Move the proof-of-work check into SimplePowVerifier::verify, restoring upstream sc-consensus-pow ordering. The check is a single Poseidon2 hash and a comparison; performing it before check_inherents means a junk block is discarded for microseconds of work instead of a WASM runtime call.

  2. Attribute seal failures to the peer. Return BlockImportError::VerificationFailed(peer_id, ..) for invalid proof-of-work so the existing DropPeer(BadPeer(peer, rep::VERIFICATION_FAIL)) path applies. Once 5.1 is done this happens naturally, since verifier failures already carry the peer id.

  3. Consider a negative cache of rejected header hashes. There is currently no known_bad set in client/consensus/qpow, so the identical block can be re-offered without limit. Even with peer punishment in place, caching rejected hashes prevents repeated expensive re-validation of the same block from different peers.

  4. Reconsider the blast radius of restart(). Cancelling in-flight requests to every peer because one peer sent a bad block lets a single hostile peer disrupt downloads from honest ones. Scoping the reset to the offending peer would remove that lever.

Proof of Concept

6. Proof of concept

Everything below is complete and self-contained.

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 the repo's rust-toolchain)

Targets

wasm32v1-none

6.2 Setup

Build the VICTIM first, from pristine source, and set it aside:

The victim must be built before any patch is applied. This is the binary under test; its rejection and peer-handling paths are completely unmodified.

6.3 The attacker instrumentation

The attacker simulates a hostile peer. Everything is gated behind the environment variable QPOC_EVIL_SEAL, so with the variable unset the source behaves exactly like the stock node.

Save as poc3_attacker.diff in the repository root and apply with git apply poc3_attacker.diff:

Then build the attacker binary:

Why this does not weaken the result. The patch changes only the attacker. It makes that node (a) publish a seal that is invalid, and (b) accept its own invalid block so it has a chain to announce — precisely what a real malicious peer would do with a custom client. The victim binary is untouched and was built before the patch existed. Nothing in the patch affects how the victim verifies, rejects, or attributes blame.

6.4 The orchestrator

Save as poc3_run.sh, chmod +x, and run. It starts both nodes, proves the code asymmetry against the victim's own source, drives the experiment, and reports results.

6.5 PoC output

Binaries under test — note the victim is the pristine build, saved before the attacker patch existed:

Progression of the run:

The rejection, verbatim from the victim log:

The restart, and the peer being treated as new again:

Final tallies:

6.6 Explanation of the output

penalties: 0 is the finding. Across 18 rejected blocks carrying invalid proof-of-work, the victim recorded not one reputation change, ban, or DropPeer action. A case-insensitive search over the entire victim log of this two-node run returns zero:

The punishment machinery exists and is wired to the adjacent error class — it is simply never reached by this offence.

(In the three-node cross-peer run of §6.7 the same search returns 1 — a report for duplicate block requests, triggered by the retry loop that topology induces, which did not disconnect the peer. See §4. It is still not a penalty for the invalid proof-of-work.)

Invalid Seal [255, 0, 0, …] proves the corruption arrived intact. Byte 0 is 255, which is 0x00 ^ 0xFF — the attacker's modification. The block was structurally valid enough to be decoded, requested, and pushed all the way through check_inherents into seal verification.

restarts: 7 shows the Other(_) arm firing. Each 💔 Error importing block is immediately followed by Restarting sync with client info and Restarted with 0. That is the exact code path quoted in §2.3 — warn, wipe state_sync, restart — with the DropPeer line conspicuously absent compared to the arm 30 lines above it.

"Added peer … for the first time", twice, is the clearest single piece of evidence. The node's own disconnect tracker has no recollection of this peer between cycles. Whatever reputation decay or ban threshold exists elsewhere is irrelevant: an offence that is never reported can never accumulate.

import_errors climbing 6 → 12 → 18 while best stays 0 shows the loop is not a one-off. Six rounds in 150 seconds, and the victim ends exactly where it started, despite a peer continuously advertising a longer chain.

Honest note on peers=0 in the samples. The connection is transient — the attacker serves its short chain, the request fails, the peer disconnects, and --reserved-nodes brings it back roughly every 70 seconds. The sampling happens to catch the gaps. This does not weaken the result: the point is that the peer is always welcome back, which is precisely what the absence of a penalty means. A real attacker with a longer bad chain, or simply more patience, holds the connection continuously.

6.7 Second experiment — cross-peer disruption of honest downloads

Three nodes, same binaries and chain spec as §6.2–6.3:

  • H — honest miner, pristine binary, --validator --force-authoring

  • V — victim, pristine binary, not a validator (syncs only), bootnode + reserved = H

  • A — attacker, QPOC_EVIL_SEAL=1, attached to V only during the attack phase

A is kept mining in all three phases so CPU load is constant; only its connection to V changes. The metric is deliberately V's keep-up ratio against H, not absolute blocks/min, because difficulty ramps during a run.

Two setup facts that cost three void runs — record these:

  1. --reserved-only silently prevents a node from dialing its own bootnodes/reserved peers on this litep2p backend. The attacker sat at 0 peers for minutes while system_addReservedPeer returned {"result":null} (success). The victim must dial the attacker, not the reverse.

  2. To keep A isolated from H while still reachable by V, start A with --in-peers 0 --out-peers 1 --reserved-nodes <V>; for the isolated phases use --in-peers 0 --reserved-only with an empty reserved set.

Load-bearing part of the harness:

Rejections observed on V (plain info level):

Both invalid seal (Other(_)) and unknown parent (UnknownParent) land on the same match arm at client/network-sync/src/strategy/chain_sync.rs:777-781 — the arm without DropPeer.

Note that of the 8 import errors in this phase, only 2 are Invalid Seal; the other 6 are block has an unknown parent, a downstream consequence of V never accepting A's chain. Both classes take the same no-DropPeer arm, which is the point, but the report's framing should not be read as 18 distinct seal failures.

On peer reporting in this run — a case-insensitive grep of V's log:

Exactly one report in the whole run, and it is for duplicate block requests, not for any of the rejected blocks. None of the 8 import failures produced a reputation change, and the ban that did fire left A connected — (2 peers) persisted for the full 240 s (see §4). An earlier revision of this report claimed the grep "returns nothing"; that used a case-sensitive pattern that could not match Report /Banned,/disconnecting. and was wrong.

Two measurement controls worth noting:

  • Logging volume was ruled out as a cause. An earlier run with V at sync=debug produced 240 MB in 68 s, which could plausibly slow a node by itself. The measurement run used plain info (27 KB total) and reproduced the stall, so the effect is not a logging artifact.

  • One log-derived figure is an artifact and must not be used. At info level V stops emitting 🏆 Imported #N lines once it enters major-sync (⚙️ Syncing replaces them) — 35 / 0 / 65 Imported lines across the three phases. The correct attack-phase figure is the RPC-derived 59, not zero.

Was this helpful?