Skip to content

checkProofOfWork accepts any target: no powLimit bound, no overflow check #5

Description

@melvincarvalho

Codec.checkProofOfWork compares the block hash against the expanded target and nothing else:

checkProofOfWork(header) {
  return BigInt('0x' + this.blockHash(header)) <= this.expandCompact(header.bits);
}

Bitcoin Core's CheckProofOfWork applies four range checks to the target before comparing:

bnTarget.SetCompact(nBits, &fNegative, &fOverflow);
if (fNegative || bnTarget == 0 || fOverflow || bnTarget > UintToArith256(params.powLimit))
    return false;

None are present. The two that matter:

No powLimit bound. Any nBits claiming an easier target than the network minimum is accepted. HeaderEngine already holds this.powLimit and uses it in retarget() — it is simply never applied to the work check. SpvEngine does not load it at all.

No overflow detection. expandCompact masks the sign bit off (bits & 0x007fffff) and, being BigInt-based, cannot overflow the way arith_uint256 does. nBits = 0xff123456 produces a target of ~2040 bits, which Core rejects via fOverflow:

OVERFLOW   bits=0xff123456  target=12345600000000000000...(510 hex digits)

The negative-flag and zero-target cases happen to return false anyway through the arithmetic, so they are not exploitable — but they are also not checked.

Impact is worst in the SPV path

btc:rule-spv-pow binds directly to checkProofOfWork, and the SPV ruleset has no difficulty rule to compensate. In validateChain, btc:rule-header-difficulty masks the problem when prev and epochFirst context is available, and returns null when it is not.

So a forged inclusion proof costs nothing. Fabricating a "block" containing a transaction that was never mined, with nonce: 1 and no work performed:

forged block hash : 55026a669e2f1dcca0dab82e8c18071aeadba6d381b94ae73211a166e9315760
work performed    : none (nonce = 1)

  true   btc:rule-spv-pow
  true   btc:rule-spv-tree
  true   btc:rule-spv-merkle-root
  true   btc:rule-spv-inclusion

SPV VERDICT ok    : true
claimed inclusion : [ 'babababababababababababababababababababababababababababababababa' ]

Reproduction:

import { createKernel } from '@bitcoin-kernel/kernel';
const k = createKernel();
const fakeTxid = 'ba'.repeat(32);
const header = {
  version: 1, prevBlockHash: '0'.repeat(64), merkleRoot: fakeTxid,
  time: 1700000000, bits: 0x207fffff, nonce: 1,
};
const merkleBlock = { header, txCount: 1, hashes: [fakeTxid], flags: '01' };
k.spv.verify(merkleBlock, { txid: fakeTxid }).ok;  // => true

bits: 0x207fffff is the regtest-style target, evaluated by a mainnet-configured kernel.

SPV correctly requires the caller to anchor the header to a known chain, and a caller that does so is protected. But SpvEngine.verify() returns ok: true with no indication that an external check is required, and a rule named btc:rule-spv-pow that provides no work guarantee is worse than no rule — it reads as assurance.

Suggested fix

Give Codec the network's powLimit (or pass a target bound into checkProofOfWork), and reject when the expanded target is zero, exceeds 256 bits, or exceeds powLimit. Surfacing the negative flag would complete the parity with Core.

Affects every vendored copy — kernel, browser-node, health, forks, mempool — since codec.js is byte-identical across all five.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions