88230 bc medium pow seal verified in import block instead of the verifier bad seal blocks from a peer trigger an unpenalised sync restart loop stalling block import and mining
Submitted on Aug 12th 2026 at 10:15:32 UTC by @Lun3x for Audit Comp | Quantus
Report ID: #88230
Report Type: Blockchain/DLT
Report severity: Medium
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Shutdown of greater than or equal to 30% of network processing nodes without brute force actions, but does not shut down the network
Increasing network processing node resource consumption by at least 30% without brute force actions, compared to the preceding 24 hours
Description
Brief/Intro
Quantus moved PoW seal verification out of the import queue's Verifier and into BlockImport::import_block. That relocation changes the error channel: import-stage failures surface as BlockImportError::Other, which — unlike VerificationFailed — carries no peer identity, so the sync strategy handles it by discarding all downloaded blocks and restarting sync without penalising or disconnecting the peer that sent the bad block. A single unauthenticated peer with zero hash power can therefore feed a victim node blocks carrying a random 64-byte seal, indefinitely and for free, keeping that node from importing anything. Targeted at a miner, this pauses its mining loop entirely.
Vulnerability Details
Upstream sc-consensus-pow verifies the seal in the Verifier, where a failure is attributable to the peer that supplied the block. Quantus's verifier does no PoW check at all — it only moves the seal digest into post_digests:
client/consensus/qpow/src/lib.rs:443-453
The actual check happens later, inside import_block:
client/consensus/qpow/src/lib.rs:282-296
sc-consensus's import queue maps an Err from import_block to BlockImportError::Other(ConsensusError). Compare the two error variants:
The sync strategy's handling of these variants sits in three adjacent match arms, and the asymmetry is the whole bug:
client/network-sync/src/strategy/chain_sync.rs:747-781
VerificationFailed drops the peer. BadBlock drops the peer. Other calls restart() and nothing else — the peer id is not even available to it. Every Quantus-specific rejection lands in that last arm: InvalidSeal (:295), DigestTooLong (:244), the CheckInherents failure (:192, e.g. a bad timestamp), and a seal that is not 64 bytes (:277).
Two pieces of corroborating evidence that this is an unintended consequence of the relocation rather than a deliberate design:
client/consensus/qpow/src/lib.rs:51declaresError::FailedPreliminaryVerify. Grepping the whole repository for that identifier returns only the declaration — it is never constructed. That variant is the slot for upstream'salgorithm.preliminary_verifyhook, which was deleted.There is no per-peer import-failure counter anywhere in the sync code.
grep -rn "import_fail\|failed_import\|bad_import" client/network-sync/src/returns nothing, so nothing else compensates.
restart() is not a cheap operation. client/network-sync/src/strategy/chain_sync.rs:1698-1743 clears the entire download buffer, pushes a CancelRequest for every busy peer, and re-adds all peers:
So one attacker-supplied bad block discards work downloaded from every peer, not just the attacker's.
Nothing memoises the offending block as bad either — Client::check_block consults only the chain spec's bad_blocks list — so the identical block can be re-sent immediately and produce the same effect again.
The attacker's cost is a well-formed header and body. validate_blocks (chain_sync.rs:2081-2145, 2539-2553) requires only that the header hash matches the advertised hash and that the extrinsics root matches the body, both of which the attacker computes locally. The 64-byte seal is random. No mining, no stake, no privileges, no Sybil.
Escalation against miners
A node stuck in restart stops making sync progress and falls behind. MAJOR_SYNC_BLOCKS = 5 (chain_sync.rs:96), so once it is more than five blocks behind, is_major_syncing() becomes true, and two things follow:
node/src/service.rs:342-349pauses the mining loop while major-syncing.client/consensus/qpow/src/worker.rs:171-176makesMiningHandle::submitdiscard locally mined candidates.
A miner held in this state produces no blocks at all, while paying nothing to the attacker.
Impact Details
Two in-scope impacts apply, quoted verbatim from the program page.
Demonstrated effect — Medium:
Shutdown of greater than or equal to 30% of network processing nodes without brute force actions, but does not shut down the network
The attack is per-victim and one attacker node can hold many simultaneous peer connections (the node's own default is --in-peers 32), so the fraction of the network affected scales with the connections the attacker opens rather than with any resource they must spend. Each affected node stops importing blocks and, if it mines, stops producing them. This is not brute force: it is one malformed block per victim per restart cycle, and the seal is random rather than mined.
Conditional escalation — High:
Temporary freezing of network transactions by delaying one block by 500% or more of the average block time of the preceding 24 hours beyond standard difficulty adjustments
If the peers held in restart include miners representing a large enough share of hash power, block production slows proportionally and the 12-second target block time is exceeded by well over 500%. That threshold depends on the stalled miners holding a sufficient hash-power share, which is a property of the live network's miner distribution and cannot be measured from the repository. The Medium above does not depend on it.
No funds are stolen or frozen on-chain, so there is no funds-at-risk figure. The loss is availability of block import and block production on every node an attacker chooses to peer with.
The fix is small and the codebase already contains the intended shape: give the Verifier an Arc<C> and run verify_and_get_achieved_difficulty there, so a bad seal becomes VerificationFailed(peer, …) and the existing DropPeer path applies. The unused Error::FailedPreliminaryVerify variant is the slot for it.
References
client/consensus/qpow/src/lib.rs:443-453—SimplePowVerifier::verifyperforms no PoW checkclient/consensus/qpow/src/lib.rs:282-296— the seal check, inimport_block, returningError::InvalidSealclient/consensus/qpow/src/lib.rs:51—Error::FailedPreliminaryVerify, declared and never constructedclient/consensus/qpow/src/lib.rs:244, 192, 277— the other rejections that land in the same error armclient/network-sync/src/strategy/chain_sync.rs:747-781— the three adjacent arms; onlyOtherfails to drop the peerclient/network-sync/src/strategy/chain_sync.rs:1698-1743—restart()clears all downloaded blocks and cancels all requestsclient/network-sync/src/strategy/chain_sync.rs:96—MAJOR_SYNC_BLOCKS = 5client/network-sync/src/strategy/chain_sync.rs:2081-2145, 2539-2553— whatvalidate_blocksactually requiresnode/src/service.rs:342-349— mining pauses while major-syncingclient/consensus/qpow/src/worker.rs:171-176— locally mined blocks discarded while major-syncingAsset revision:
audit-comp-quantus-chain@3b243b870a5442a6e5f443056f67bbba87d472f5, branchaudit-comp-ready
Proof of Concept
Local two-node devnet, run with the project's own release binary. Nothing touches mainnet or any public testnet. This is not a unit test: it is the real node, the real import queue, the real network stack and the real sync strategy, with one node serving an invalid-seal block to another over libp2p and the victim's own logs showing how it handles it.
A note on why the attacker node needs a patch. The defect is on the victim side, and the victim runs the unmodified binary. But a node cannot emit an invalid-seal block by accident: MiningHandle::submit verifies the seal before consuming the build (worker.rs:187-226), and then the node imports its own block through the same import_block that rejects bad seals. To stand in for a malicious peer I therefore gate two behaviours behind environment variables that only the attacker sets. A real attacker needs no patch at all — they craft the block bytes directly, and validate_blocks (chain_sync.rs:2081-2145, 2539-2553) only requires the header hash to match the announced hash and the extrinsics root to match the body, both of which they compute locally. The 64-byte seal is random.
Attacker patch (two files, env-gated)
client/consensus/qpow/src/worker.rs, replacing the single line let seal = DigestItem::Seal(POW_ENGINE_ID, seal); at line 229 — corrupt the seal after the local verification passes:
client/consensus/qpow/src/lib.rs, around the rejection at lines 293-296 — let the attacker accept its own bad block so it will import and gossip it:
Commands
Measured result — the victim's own log
The attacker connects, serves block 7 with a corrupted seal, and the victim does this (verbatim, /tmp/victim.log):
The [255, 0, 0, …] first byte is the corrupted seal (0x00 ^ 0xff), so the block is provably the attacker's invalid one.
Three things this establishes:
The rejection lands in the un-penalised error arm
The message is 💔 Error importing block …: consensus error: — the UnknownParent | Other(_) arm at chain_sync.rs:777-781 — and it is followed immediately by Restarting sync. Not 💔 Verification failed for block …, which is the arm that carries a peer id and drops the peer.
No penalty of any kind is applied
Grepping the whole victim log for Report, reputation, banned, Verification failed, blacklisted, DropPeer, BadPeer returns 6 matches, and all six are reputation_change: None inside routine block-request handling:
There is no DropPeer, no BadPeer, no reputation adjustment, and no ban anywhere in the run.
The peer is re-added immediately and the cycle repeats
One millisecond after the restart the victim logs New peer QmNxh7SBc…QVk9 … searching for common ancestor, re-requests, receives the same invalid block and fails again. Over the run:
The restart timestamps come in same-millisecond pairs — 14:08:06.553 / 14:08:06.554, 14:09:15.357 / 14:09:15.359, 14:09:18.918 / 14:09:18.919 — so this is a tight spin, not a slow degradation. Each restart also runs self.blocks.clear() (chain_sync.rs:1698-1743), discarding blocks downloaded from every other peer, not just the attacker's.
Scope of this measurement
It shows, on the real binary and the real network stack, that an invalid-seal block from a peer is rejected in import_block, surfaces as the un-attributable Other error, triggers a full sync restart, and results in no penalty and no disconnection of the sender, which is then re-added and re-serves the same block.
It does not measure a network-wide block delay. The devnet is two nodes and the victim keeps mining its own chain, so there is no measured 500% block-time figure from this run; that depends on the live miner distribution. What the run establishes is the mechanism and the absence of any rate limit on repeating it.
The victim and attacker did also disconnect and reconnect during the run (2 connects, 4 disconnect events). None of those is attributable to a penalty for the invalid blocks: every reputation value in the log is None, and no drop action appears.
Restoring the tree
The two patched files are the attacker stand-in only and must be reverted before any other measurement:
Suggested fix and regression property
Give the Verifier an Arc<C> and run verify_and_get_achieved_difficulty inside SimplePowVerifier::verify rather than in import_block. A bad seal then becomes VerificationFailed(peer, …) and the existing DropPeer(BadPeer(peer, rep::VERIFICATION_FAIL)) path applies unchanged. The unused Error::FailedPreliminaryVerify variant at lib.rs:51 is the slot for exactly this.
Regression property: with a peer supplying a block whose seal fails PoW verification, the sync strategy must emit a DropPeer action for that peer. A test can assert that the action queue contains SyncingAction::DropPeer after feeding one invalid-seal block, which fails against the current code and passes once the check moves into the verifier.
Was this helpful?