From 3ac8a09258a21915b72527d0b9a5abdb12dabf61 Mon Sep 17 00:00:00 2001 From: VictorLux Date: Tue, 9 Jun 2026 20:58:01 +0200 Subject: [PATCH] Enforce checkpoint hash at header acceptance ContextualCheckBlockHeader rejected only forks strictly below the last checkpoint present in mapBlockIndex; it never rejected a header presented AT a checkpoint height with the wrong hash, and depended on chain state. A fresh or eclipsed node could therefore accept a forged chain that does not pass through the compiled checkpoint hashes (upstream Bitcoin's CheckIndexAgainstCheckpoint has no equivalent here). Add Checkpoints::CheckBlock(data, nHeight, hash) and call it in ContextualCheckBlockHeader under fCheckpointsEnabled: a header at a checkpoint height whose hash != the compiled checkpoint hash is rejected (DoS 100, REJECT_CHECKPOINT, "bad-fork-checkpoint"). It reads the hardcoded checkpoint map directly, so it is independent of mapBlockIndex state and also protects a fresh/eclipsed node during bootstrap. Pure tightening: canonical headers at checkpoint heights match by definition, so honest peers are never rejected; an empty checkpoint map (regtest) is a no-op. Unit test covers the no-checkpoint / match / mismatch branches. Co-Authored-By: Claude Opus 4.8 --- src/checkpoints.cpp | 10 ++++++++++ src/checkpoints.h | 5 +++++ src/main.cpp | 11 +++++++++++ src/test/Checkpoints_tests.cpp | 22 ++++++++++++++++++++++ 4 files changed, 48 insertions(+) diff --git a/src/checkpoints.cpp b/src/checkpoints.cpp index 5d09f7dbee2..78b5c011367 100644 --- a/src/checkpoints.cpp +++ b/src/checkpoints.cpp @@ -81,6 +81,16 @@ namespace Checkpoints { return NULL; } + bool CheckBlock(const CCheckpointData& data, int nHeight, const uint256& hash) + { + const MapCheckpoints& checkpoints = data.mapCheckpoints; + + MapCheckpoints::const_iterator i = checkpoints.find(nHeight); + if (i == checkpoints.end()) + return true; + return hash == i->second; + } + static std::string FastSyncAnchorPayload(const CChainParams& chainparams, const CFastSyncAnchorData& anchor) { return strprintf("zclassic-fastsync-anchor-v1|%s|%d|%s", diff --git a/src/checkpoints.h b/src/checkpoints.h index 5b65ba5736a..baa410e6932 100644 --- a/src/checkpoints.h +++ b/src/checkpoints.h @@ -27,6 +27,11 @@ int GetTotalBlocksEstimate(const CCheckpointData& data); //! Returns last CBlockIndex* in mapBlockIndex that is a checkpoint CBlockIndex* GetLastCheckpoint(const CCheckpointData& data); +//! Returns false only if there is a checkpoint at nHeight and hash does not +//! match it. A block presented at a checkpoint height with a different hash is +//! a forgery and must be rejected (eclipse / bootstrap lock-in). +bool CheckBlock(const CCheckpointData& data, int nHeight, const uint256& hash); + double GuessVerificationProgress(const CCheckpointData& data, CBlockIndex* pindex, bool fSigchecks = true); //! Validate the compiled fast-sync anchor against the checkpoint set and digest fields. diff --git a/src/main.cpp b/src/main.cpp index eb98a68c72d..e9dce0340ec 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4389,6 +4389,17 @@ bool ContextualCheckBlockHeader(const CBlockHeader& block, CValidationState& sta CBlockIndex* pcheckpoint = Checkpoints::GetLastCheckpoint(chainParams.Checkpoints()); if (pcheckpoint && nHeight < pcheckpoint->nHeight) return state.DoS(100, error("%s: forked chain older than last checkpoint (height %d)", __func__, nHeight)); + + // Enforce the exact hash at checkpoint heights. A header presented at a + // checkpoint height with a different hash is a forgery. The forked-chain + // check above only covers heights strictly below the last checkpoint + // *present in mapBlockIndex*; this closes the gap at the checkpoint + // height itself and is independent of mapBlockIndex state, so it also + // protects a fresh/eclipsed node during bootstrap. + if (!Checkpoints::CheckBlock(chainParams.Checkpoints(), nHeight, hash)) + return state.DoS(100, error("%s: rejected by checkpoint lock-in at height %d (hash %s)", + __func__, nHeight, hash.ToString()), + REJECT_CHECKPOINT, "bad-fork-checkpoint"); } // Reject block.nVersion < 4 blocks diff --git a/src/test/Checkpoints_tests.cpp b/src/test/Checkpoints_tests.cpp index e31c8903cf9..755bfbb91e1 100644 --- a/src/test/Checkpoints_tests.cpp +++ b/src/test/Checkpoints_tests.cpp @@ -97,4 +97,26 @@ BOOST_AUTO_TEST_CASE(fast_sync_anchor_negative_branches) } } +BOOST_AUTO_TEST_CASE(checkpoint_hash_lockin) +{ + const uint256 hashA = uint256S("0x00000000000000000000000000000000000000000000000000000000000000aa"); + const uint256 hashB = uint256S("0x00000000000000000000000000000000000000000000000000000000000000bb"); + + CCheckpointData data{}; + data.mapCheckpoints[30000] = hashA; + data.mapCheckpoints[160000] = hashB; + + // No checkpoint at this height -> always accepted, regardless of hash. + BOOST_CHECK(Checkpoints::CheckBlock(data, 12345, hashA)); + BOOST_CHECK(Checkpoints::CheckBlock(data, 12345, hashB)); + + // Correct hash at a checkpoint height -> accepted. + BOOST_CHECK(Checkpoints::CheckBlock(data, 30000, hashA)); + BOOST_CHECK(Checkpoints::CheckBlock(data, 160000, hashB)); + + // Wrong hash at a checkpoint height -> rejected (the forgery case #2 closes). + BOOST_CHECK(!Checkpoints::CheckBlock(data, 30000, hashB)); + BOOST_CHECK(!Checkpoints::CheckBlock(data, 160000, hashA)); +} + BOOST_AUTO_TEST_SUITE_END()