Skip to content

Conversation

@davidgumberg
Copy link
Contributor

@davidgumberg davidgumberg commented May 23, 2025

Processing unsolicited CMPCTBLOCK's from a peer that has not been marked high bandwidth is not well-specified behavior in BIP-0152, in fact the BIP seems to imply that it is not permitted:

"[...] method is not useful for compact blocks because cmpctblock blocks can be sent unsolicitedly in high-bandwidth mode"

See https://github.com/bitcoin/bips/blob/master/bip-0152.mediawiki#separate-version-for-segregated-witness

This partially mitigates a mempool leak described in #28272, making it only possible for peers selected as high bandwidth to discover a node's mempool.

@DrahtBot
Copy link
Contributor

DrahtBot commented May 23, 2025

The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

Code Coverage & Benchmarks

For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/32606.

Reviews

See the guideline for information on the review process.

Type Reviewers
ACK polespinasa, w0xlt
Concept ACK jonatack, mzumsande, hodlinator, instagibbs, ajtowns, Crypt-iQ

If your review is incorrectly listed, please react with 👎 to this comment and the bot will ignore it on the next update.

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #33738 (log: avoid collecting GetSerializeSize data when compact block logging is disabled by l0rinc)

If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

@jonatack
Copy link
Member

jonatack commented May 23, 2025

Concept ACK. BIP152 is marked as Final but perhaps could be clarified on this point.

@ajtowns
Copy link
Contributor

ajtowns commented May 27, 2025

Concept ACK. LLM linter's punctuation advice seems right to me. :)

Copy link
Contributor

@mzumsande mzumsande left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concept ACK

In the commit message:
"making it only possible for peers selected as high bandwidth to discover a node's
mempool" is a bit too strong, since mempools are discoverable, just not transactions that were added to them after the last inv to the peer.

@davidgumberg davidgumberg force-pushed the 5-23-25-ignore-unsolicited branch from 28086e7 to 1fc95e0 Compare May 28, 2025 01:36
@davidgumberg
Copy link
Contributor Author

Rebased to address @ajtowns and @mzumsande feedback.

Copy link
Contributor

@w0xlt w0xlt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK 1fc95e0

Copy link
Contributor

@Crypt-iQ Crypt-iQ left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My main concern here is if this disables a FIBRE future; FIBRE apparently used unsolicited compact blocks (see: #28272 (comment)). If we are not concerned about that, then Concept ACK.

@@ -4435,6 +4435,11 @@ void PeerManagerImpl::ProcessMessage(CNode& pfrom, const std::string& msg_type,
range_flight.first++;
}

if (!requested_block_from_this_peer && !pfrom.m_bip152_highbandwidth_to) {
Copy link
Contributor

@Crypt-iQ Crypt-iQ May 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this check is still permissive enough such that a NetMsgType::CMPCTBLOCK message can be sent to a node in -blocksonly mode and still get processed. This would require the -blocksonly node to request the block from the peer.

Maybe something like this instead?

+        if ((!requested_block_from_this_peer && !pfrom.m_bip152_highbandwidth_to) || m_opts.ignore_incoming_txs) {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed - even if we are in blocks-only mode we might have requested_block_from_this_peer, but we won't be able to reconstruct a block from our barren mempool unless the block is empty (or only contains transactions we originated).

One could special-case it to account for allowing empty block reconstruction on blocks-only nodes but it feels like dead weight in the code.


In case C of the diagram at https://github.com/bitcoin/bips/blob/master/bip-0152.mediawiki#intended-protocol-flow, we might have explicitly asked for a compact block from a low bandwidth peer. But we appear to not be doing so in blocks-only mode, which makes sense:
https://github.com/bitcoin/bitcoin/blob/6e1a49b66e77c2e6d158c51f51a19ec43cd74707/src/net_processing.cpp#L2780-L2788

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One could special-case it to account for allowing empty block reconstruction on blocks-only nodes but it feels like dead weight in the code.

Yeah, I feel like the complexity isn't worth it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this check is still permissive enough such that a NetMsgType::CMPCTBLOCK message can be sent to a node in -blocksonly mode and still get processed. T

Should we only send SENDCMPCT hb=false after VERACK if we're not blocksonly? Making all the compact block stuff conditional on not being -blocksonly=1 would make sense to me, but probably isn't needed for this PR.

Copy link
Contributor

@Crypt-iQ Crypt-iQ Oct 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we only send SENDCMPCT hb=false after VERACK if we're not blocksonly?

I think this breaks -blocksonly nodes being able to send compact blocks since m_provides_cmpctblocks is never set (a peer won't set them as HB)? I can PR the -blocksonly change I suggested above separately if it's tangential.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Future improvements to fully "lock down" this code path could be to:

  • check m_opts.ignore_incoming_txs
  • check m_provides_cmpctblocks is set
  • check that if the block was requested, we used MSG_CMPCT_BLOCK (requires state)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if in the case of being in -blocksonly mode and receive a CMCTBLOCK message we instantly respond with a GETBLOCKTXN asking for all transactions (skipping all InitData logic)?

diff --git a/src/net_processing.cpp b/src/net_processing.cpp
index dad2c4ce6d..d46c9b003b 100644
--- a/src/net_processing.cpp
+++ b/src/net_processing.cpp
@@ -4413,6 +4413,15 @@ void PeerManagerImpl::ProcessMessage(CNode& pfrom, const std::string& msg_type,
         }
 
         if (!requested_block_from_this_peer && !pfrom.m_bip152_highbandwidth_to) {
+            if (m_opts.ignore_incoming_txs) {
+                BlockTransactionsRequest req;
+                for (size_t i = 0; i < cmpctblock.BlockTxCount(); i++) {
+                    req.indexes.push_back(i);
+                }
+                req.blockhash = pindex->GetBlockHash();
+                MakeAndPushMessage(pfrom, NetMsgType::GETBLOCKTXN, vInv);
+                return;
+            }
             LogDebug(BCLog::CMPCTBLOCK, "Peer %d%s, not marked as high-bandwidth, sent us an unsolicited compact block!", pfrom.GetId(), pfrom.LogIP(fLogIPs));
             return;
         }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't really ever make sense to process a CMPCTBLOCK message as blocks-only, the peer should just send the block, if we want to do anything with the message as a blocks-only, we should just process the header of the CMPCTBLOCK:

fRevertToHeaderProcessing = true;

This will call ProcessHeadersMessage() and that can request the block.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree that it doesn't make sense to process, we should drop it asap. Haven't tested either of the below things:

ProcessHeadersMessage will trigger a GETDATA and if the block turns out to be invalid, our peer who sent us the CMPCTBLOCK won't respond and will get disconnected for stalling. If our peer is modified to relay these in the first place to all peers, they could isolate themselves from -blocksonly nodes (assuming they aren't a mining node and don't validate what they send out). This is #13370.

A tangent, but I think a miner could trigger this statement in honest peer's connections if they mined and withheld enough blocks (~4-5) and if the last CMPCTBLOCK is for an invalid block. I'm leaving out a few steps and can write out what I think on the linked issue if it's worth addressing?

@dergoegge
Copy link
Member

  • Looking at MaybeSetPeerAsAnnouncingHeaderAndIDs, becoming a node's hb peer is pretty easy? I.e. be the first to supply the latest valid block at the tip and you'll be selected as high bandwidth (we process unsolicited block messages, so this should be trivial).
  • Even if you're low-bandwidth can't you just send a headers announcement immediately followed by the compact block? As long as your headers message is the first announcement of the block, the compact block will be requested and since these messages are processed sequentially on the same thread, simply sending the compact directly after the headers (without waiting for the getdata) will cause it to be processed in the next ProcessMessages call.

@Crypt-iQ
Copy link
Contributor

Crypt-iQ commented May 30, 2025

I.e. be the first to supply the latest valid block at the tip and you'll be selected as high bandwidth (we process unsolicited block messages, so this should be trivial).

Yup I think this means the defense is ineffective.

Even if you're low-bandwidth can't you just send a headers announcement immediately followed by the compact block? As long as your headers message is the first announcement of the block, the compact block will be requested and since these messages are processed sequentially on the same thread, simply sending the compact directly after the headers (without waiting for the getdata) will cause it to be processed in the next ProcessMessages call.

This is interesting, but in this case I would still consider the compact block as "requested" as the peer has updated its internal state to request the compact block.

The fibre patchset is being revived and while I haven't tested it so I don't know if it breaks fibre, I would rather be cautious and only include the defense for -blocksonly nodes.

@ajtowns
Copy link
Contributor

ajtowns commented May 30, 2025

My main concern here is if this disables a FIBRE future; FIBRE apparently used unsolicited compact blocks

The fact that you're enabling FIBRE on a connection can just be taken as implicitly soliciting compact blocks on that connection, so I don't think this is a problem.

Copy link
Contributor

@hodlinator hodlinator left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concept ACK 1fc95e080040a9d038a8291eeecf645e0413a5c8

@@ -4435,6 +4435,11 @@ void PeerManagerImpl::ProcessMessage(CNode& pfrom, const std::string& msg_type,
range_flight.first++;
}

if (!requested_block_from_this_peer && !pfrom.m_bip152_highbandwidth_to) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed - even if we are in blocks-only mode we might have requested_block_from_this_peer, but we won't be able to reconstruct a block from our barren mempool unless the block is empty (or only contains transactions we originated).

One could special-case it to account for allowing empty block reconstruction on blocks-only nodes but it feels like dead weight in the code.


In case C of the diagram at https://github.com/bitcoin/bips/blob/master/bip-0152.mediawiki#intended-protocol-flow, we might have explicitly asked for a compact block from a low bandwidth peer. But we appear to not be doing so in blocks-only mode, which makes sense:
https://github.com/bitcoin/bitcoin/blob/6e1a49b66e77c2e6d158c51f51a19ec43cd74707/src/net_processing.cpp#L2780-L2788

@Crypt-iQ
Copy link
Contributor

The fact that you're enabling FIBRE on a connection can just be taken as implicitly soliciting compact blocks on that connection, so I don't think this is a problem.

Right, my earlier comment was wrong.

I think that dropping unsolicited cmpctblock in the non -blocksonly case is kind of ineffective as pointed out by others. But I also don't have a strong opinion about it given that it doesn't affect FIBRE.

self.generate(self.nodes[0], COINBASE_MATURITY + 1)
block = self.build_block_on_tip(self.nodes[0])

self.segwit_node.send_header_for_blocks([block])
self.segwit_node.wait_for_getdata([block.sha256], timeout=30)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

                               AttributeError: 'CBlock' object has no attribute 'sha256'

@davidgumberg
Copy link
Contributor Author

Force-pushed to address merge conflict and @hodlinator's feedback to log IP's.

@instagibbs
Copy link
Member

concept ACK

@ajtowns
Copy link
Contributor

ajtowns commented Oct 22, 2025

Concept ACK here too; haven't looked at the code yet

@instagibbs
Copy link
Member

one race condition we might hit is a compact block being sent to our node before that peer receives the message un-selecting them as a hb peer.

@davidgumberg
Copy link
Contributor Author

davidgumberg commented Oct 28, 2025

one race condition we might hit is a compact block being sent to our node before that peer receives the message un-selecting them as a hb peer.

In this case we probably are OK to drop, even though it wastes a little bandwidth, and for the time that our SENDCMPCT is on the wire to our new high-bandwidth peer, we are down to only 2 HB peers.

We'll only unselect a peer as high bandwidth when selecting another which only happens when connecting a new block. It takes ~half a round-trip for our peer to find out they've gotten the boot, so this condition only occurs when:

  1. The most recent block has resulted in us adding an HB peer and rotating one out.
  2. A second block is found in:
    A. <=~1/2 round-trip-time to the old HB peer that got unselected. The CB message that arrives is a waste of bandwidth.
    B. <=~1/2 RTT to the new HB-peer that got selected. We will be short-handed to 2 HB peers for this second block.

If 1/2 RTT = 50ms, on average 0.008% ($$1 - e^{\frac{-50\text{ms}}{600000\text{ms}}}$$) of blocks or ~4.3 blocks/yr are found that quickly. and only if those happen to be the same blocks where we are selecting a new HB peer will we hit this condition. I'm not sure if that's worth fixing.

Copy link
Contributor

@hodlinator hodlinator left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 5caaefd8d4174cfc27d5538e7f2d3a1c2bdb77d8

@@ -4412,6 +4412,11 @@ void PeerManagerImpl::ProcessMessage(CNode& pfrom, const std::string& msg_type,
range_flight.first++;
}

if (!requested_block_from_this_peer && !pfrom.m_bip152_highbandwidth_to) {
LogDebug(BCLog::NET, "Peer %d, not marked as high-bandwidth, sent us an unsolicited compact block!\n", pfrom.GetId());
return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remark: Wondered whether it would be fair to mark as Misbehaving() or disconnect them, but there's a tiny chance they may have recently been an HB peer (as remarked in #32606 (comment)).

Copy link
Contributor Author

@davidgumberg davidgumberg Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to the way @instagibbs pointed out for honest peers sending unsolicited CMPCTBLOCK's making this a bad idea. And if we did this, any way now or in the future that you could trick a node into sending an unsolicited CMPCTBLOCK becomes an attack vector. Given that there is little an attacker could accomplish by sending us an unsolicited compact block if we drop unsolicited compact blocks on the ground, I think we don't gain much by marking them as misbehaving or disconnecting them.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't tested. I think this will cause extra log messages if:

  • receive HEADERS from LB peer=0
  • send GETDATA to peer=0
  • receive CMPCTBLOCK from HB peer=1, it reconstructs, clearing download state for all peers for this block
  • receive CMPCTBLOCK from LB peer=0

Might confuse a user, but nothing is actually wrong here?


block, msg = build_compact_block()
unsolicited_peer.send_without_ping(msg_headers([block]))
unsolicited_peer.wait_for_getdata([block.hash_int], timeout=30)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe use a comment to point out that since we only send the header and not the whole block, we are not marked as an HB peer?

And/or use assert_highbandwidth_states(node, idx=-1, hb_to=False) another time at the end of this block to verify/clarify.

Copy link
Contributor

@Crypt-iQ Crypt-iQ Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unsolicited_peer also hasn't sent sendcmpct so its m_provides_cmpctblocks is false, and the node still processes the compact block! I am ok with this as-is since imo it is useful to have coverage for this case.

Copy link
Contributor

@Crypt-iQ Crypt-iQ left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @dergoegge that it's easy to become a HB peer if you're already the first to send unsolicited compact blocks to try and fingerprint a node's mempool. I think the benefit with this change is being more strict with what we'll accept and following the protocol closer (and I think there's even more improvements we can make). Left some test-related nits.


block, msg = build_compact_block()
unsolicited_peer.send_without_ping(msg_headers([block]))
unsolicited_peer.wait_for_getdata([block.hash_int], timeout=30)
Copy link
Contributor

@Crypt-iQ Crypt-iQ Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unsolicited_peer also hasn't sent sendcmpct so its m_provides_cmpctblocks is false, and the node still processes the compact block! I am ok with this as-is since imo it is useful to have coverage for this case.

@@ -4435,6 +4435,11 @@ void PeerManagerImpl::ProcessMessage(CNode& pfrom, const std::string& msg_type,
range_flight.first++;
}

if (!requested_block_from_this_peer && !pfrom.m_bip152_highbandwidth_to) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Future improvements to fully "lock down" this code path could be to:

  • check m_opts.ignore_incoming_txs
  • check m_provides_cmpctblocks is set
  • check that if the block was requested, we used MSG_CMPCT_BLOCK (requires state)

@ajtowns
Copy link
Contributor

ajtowns commented Oct 29, 2025

I'm not sure this makes sense, per #28272 (comment) . Perhaps it would be better to:

  • treat a CMPCTBLOCK announcement as being fine at any time (eg, so that miners who get a block via RPC can just send CMPCTBLOCK messages directly), and the "high-bandwidth" as just a way for nodes to avoid wasting their bandwidth sending those messages unnecessarily
  • change our behaviour when sending GETBLOCKTXN to also request any transactions that were in our mempool but had a sequence number higher than when we first saw the new block's header (If you completely reconstructed the block, but used newer txns, you would still not send a GETBLOCKTXN)

That would perhaps be slightly wasteful of bandwidth if a block arrives that includes a tx that was broadcast just before the block was found, but would avoid this being an additional fingerprinting vector independently of how high-bandwidth a potential attacker was, as far as I can see.

davidgumberg and others added 5 commits October 29, 2025 14:37
Processing unsolicited CMPCTBLOCK's from a peer that has not been marked
high bandwidth is not well-specified behavior in BIP-0152, in fact the
BIP seems to imply that it is not permitted:

"[...] method is not useful for compact blocks because `cmpctblock`
blocks can be sent unsolicitedly in high-bandwidth mode"

See https://github.com/bitcoin/bips/blob/master/bip-0152.mediawiki#separate-version-for-segregated-witness

This partially mitigates a mempool leak described in
[bitcoin#28272](bitcoin#28272), but that
particular issue will persist for peers that have been selected as high
bandwidth.

This also mitigates potential DoS / bandwidth-wasting / abusive
behavior that is discussed in the comments of bitcoin#28272.
This is move-only, and allows assert_highbandwidth_states to be used by
other tests.
Modifies the invalid_cmpctblock_message to check that both HB peers
sending unsolicited and non-HB peers sending unsolicited invalid
cmpctblock's get the boot from us. Also refactors the test to make it
less stateful.
Clear the getblocktxn message so we're not checking existing messages,
and check that the hash in the getblocktxn match the cmpctblock being
announced.

Co-authored-by: Hodlinator <172445034+hodlinator@users.noreply.github.com>
@davidgumberg davidgumberg force-pushed the 5-23-25-ignore-unsolicited branch from 5caaefd to 32bfd61 Compare October 29, 2025 21:37
@davidgumberg
Copy link
Contributor Author

davidgumberg commented Oct 30, 2025

Rebased to address reviewer feedback.

@ajtowns I'm thinking of this now less as a mitigation for the fingerprinting vector, and more just defense-in-depth / closing off attack surface for compact blocks. E.g. CVE-2024-35202 and CVE-2024-52921 are more trivial to exploit because you don't need an HB slot.

An HB slot might be a low bar for an attacker to overcome as @dergoegge points out above/ But, as I see it, there is very rarely a legitimate/useful reason today to process an unsolicited CMPCTBLOCK message, especially if it's the case that getting an HB slot is trivial, and it does raise the cost of an attack, and it mitigates any DoS that require sending malformed CMPCTBLOCK's persistently, any attacker is likely to get cycled out of their HB slot.

For example, once the first_in_flight slot is occupied, we won't send GETBLOCKTXN's to any non-HB peers, so if you have two connections to a node, you can have one occupy the first_in_flight slot for a real block with a fake cmpctblock that has nonsense shortid's, never respond to GETBLOCKTXN, and the second peer can endlessly send CMPCTBLOCK's with 2^16-1 shortid's and the victim node will just keep calling InitData and looping the entire mempool until it gets the block from an honest peer.

Edit: Actually that DoS might be more effective with a single fake shortid.

@Crypt-iQ
Copy link
Contributor

second peer can endlessly send CMPCTBLOCK's with 2^16-1 shortid's and the victim node will just keep calling InitData and looping the entire mempool until it gets the block from an honest peer.

Kind of a tangent, but do you have any idea how fast/slow this is if the mempool has ~100k txns?

@instagibbs
Copy link
Member

instagibbs commented Oct 30, 2025

@Crypt-iQ BlockEncoding.* benchmarks should cover this up to 50k. Quickly tested 100k, and it seems ~linear slowdown. (~4ms on my mid laptop)

@ajtowns
Copy link
Contributor

ajtowns commented Oct 30, 2025

the second peer can endlessly send CMPCTBLOCK's

If we're concerned about that, seems better to fix it directly?

--- a/src/net_processing.cpp
+++ b/src/net_processing.cpp
@@ -4412,6 +4412,11 @@ void PeerManagerImpl::ProcessMessage(CNode& pfrom, const std::string& msg_type,
         while (range_flight.first != range_flight.second) {
             if (range_flight.first->second.first == pfrom.GetId()) {
                 requested_block_from_this_peer = true;
+                const auto& qb_list_it = range_flight.first->second.second;
+                if (qb_list_it->partialBlock != nullptr) {
+                    LogDebug(BCLog::CMPCTBLOCK, "Ignoring duplicate CMPCTBLOCK message for block %s received from peer=%d%s", pindex->GetBlockHash().ToString(), pfrom.GetId(), pfrom.LogIP(fLogIPs));
+                    return;
+                }
                 break;
             }
             range_flight.first++;

But, as I see it, there is very #32606 (comment) a legitimate/useful reason today to process an unsolicited CMPCTBLOCK message

I could imagine someone restarting the FIBRE network, and having it interface to the p2p network with a custom node implementation that implements logic like "only relay txs with an internal node. drop connections to anyone with a ping time exceeding 50ms. for each block, did I get this block via FIBRE? if so, send a CMPCTBLOCK message to all peers because I'm probably the quickest source in this region. if not, behave normally."

@davidgumberg
Copy link
Contributor Author

davidgumberg commented Oct 30, 2025

If we're concerned about that, seems better to fix it directly?

--- a/src/net_processing.cpp
+++ b/src/net_processing.cpp
@@ -4412,6 +4412,11 @@ void PeerManagerImpl::ProcessMessage(CNode& pfrom, const std::string& msg_type,
  [...]

The suggested diff doesn't work because we remove the block request from mapBlocksInFlight if the txn's are missing, the firstInFlight slot is already taken, and the block is not from an HB Peer:

bitcoin/src/net_processing.cpp

Lines 4494 to 4497 in 24434c1

} else {
// Give up for this peer and wait for other peer(s)
RemoveBlockRequest(pindex->GetBlockHash(), pfrom.GetId());
}

To do something like what you're suggesting I think we would need another map that tracks whether or not a peer has already had the block in flight at one point and we gave up on it.

I could imagine someone restarting the FIBRE network, and having it interface to the p2p network with a custom node implementation that implements logic like "only relay txs with an internal node. drop connections to anyone with a ping time exceeding 50ms. for each block, did I get this block via FIBRE? if so, send a CMPCTBLOCK message to all peers because I'm probably the quickest source in this region. if not, behave normally."

If I'm not mistaken, at least the only effort I know of at restarting the FIBRE network does not want to reveal the identity of the FIBRE nodes to prevent targeted DoS, but maybe I am mistaken, pinging @w0xlt.

But, in general, I hear the broader stroke of your objection as: The "hole" in our implementation of compact block relay might actually be an opportunity we can leverage to improve block propagation, and we shouldn't close it so hastily. And that's fair enough, I kind of disagree about the FIBRE use case, but it is worth thinking about if, in an implementation like you suggested, where each node only processes one unsolicited block per peer per valid header, the price of InitData() * number of connections an attacker can reasonably get to a node is worth whatever the potential improvement we could get from nodes that definitely know they've got the block first like mining nodes, or are reasonably confident they're the fastest like FIBRE nodes announcing unsolicited. It seems interesting, and I'll try and think about it for a bit as well, but given the disproportionate number of CVE's that are in this part of the code, without something concrete I would prefer to close down our implementation of the protocol to be as conservative as possible about what CMPCTBLOCK messages we process, e.g. this PR and the other improvements suggested by @Crypt-iQ above: #32606 (comment)

Future improvements to fully "lock down" this code path could be to:

  • check m_opts.ignore_incoming_txs
  • check m_provides_cmpctblocks is set
  • check that if the block was requested, we used MSG_CMPCT_BLOCK (requires state)

@ajtowns
Copy link
Contributor

ajtowns commented Oct 30, 2025

The suggested diff doesn't work because we remove the block request from mapBlocksInFlight if the txn's are missing, the firstInFlight slot is already taken, and the block is not from an HB Peer:

Does that logic make sense? If we receive:

sequenceDiagram
   participant A
   Us ->> A: SENDCMPCT 0
   Us ->> B: SENDCMPCT 1 (high-bandwidth)
   A ->> Us: t=0 HEADERS
   Us ->> A: GETDATA(CMPCT)
   B ->> Us: t=25ms CMPCTBLOCK
   Us ->> B: GETBLOCKTXN
   A ->> Us: t=50ms CMPCTBLOCK
Loading

does it really make sense to not request the missing txns from the peer who first announced the header to us? If the RTT latency between us and B is 80ms (versus us and A at 50ms), then we'd get a reply from A at t=100ms and the reply from B at t=105ms, eg.

Not requesting GETBLOCKTXN in response to a CMPCTBLOCK from a non-hb peer when we didn't request the cb and we already have a request in flight still sounds sensible, though, so I don't think this resolves the objection.

Finding a way to refactor this code so that the logic for how we do block relay is mostly in one place rather than scattered amongst message various different message parsing logic might be valuable... I guess if you treated the logic as:

  1. Ooh, this peer is telling me about a possibly new block header!
  2. Ooh, it would be a new tip and I don't have the block yet! And I don't have it in-flight yet, I should reque...
  3. Ooh, it's already a CMPCTBLOCK and I don't need to request it! Great, let's analyse it, see if it reconstructs!
  4. Oh, it doesn't, better request the transactions

then if you received an unsolicited CMPCTBLOCK message that you already had things in flight for that block, you'd stop at (2) and just treat the message as an overly verbose headers update, and not even try to process it against the mempool (after all, if you have requests in flight, that was probably from an honest peer and you probably are actually missing transactions, so the reconstruction would probably fail anyway). OTOH, if you didn't have things in flight for it (or weren't being as parallel as you'd like to be), you would continue, and would request (and eventually process) the missing transactions.

@davidgumberg
Copy link
Contributor Author

davidgumberg commented Oct 30, 2025

We add peers to mapBlocksInFlight whenever we send GETDATA's in response to headers, so in the scenario you describe we would treat the peer that announced the header to us as first_in_flight().

The flow is ProcessHeadersMessage(), and if the header looks good, call HeadersDirectFetchBlocks(), and if everything looks good there and we decide to send a GETDATA for the block, we call BlockRequested() which adds the block to mapBlocksInFlight:

auto itInFlight = mapBlocksInFlight.insert(std::make_pair(hash, std::make_pair(nodeid, it)));

I think the first step of simplifying the logic would be to separate out "in-flight" as in I've GETDATA requested this block from this peer, and "in-flight" as in I'm waiting on a BLOCKTXN for this block from this peer.

The refactor you suggest sounds interesting, I don't know exactly how it would look, but if I'm understanding you right a centralized block processing function where some additional data (e.g. a CMPCTBLOCK) might or might not be available seems a reasonable approach, but the complexity would be in all the possible ways to "re-enter" the function, e.g. process the first message, it's a header we care about but no block or cmpctblock for us to process so send a GETDATA, get a cmpctblock message we're missing tx'es so send a getblocktxn, get a blocktxn, would this all be in the same function?

Copy link
Member

@polespinasa polespinasa left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code review ACK 32bfd61

I think it's worth resolving the odd case for -blocksonly in this pull request. But I would be happy too with it being done in a follow-up PR.

@@ -4435,6 +4435,11 @@ void PeerManagerImpl::ProcessMessage(CNode& pfrom, const std::string& msg_type,
range_flight.first++;
}

if (!requested_block_from_this_peer && !pfrom.m_bip152_highbandwidth_to) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if in the case of being in -blocksonly mode and receive a CMCTBLOCK message we instantly respond with a GETBLOCKTXN asking for all transactions (skipping all InitData logic)?

diff --git a/src/net_processing.cpp b/src/net_processing.cpp
index dad2c4ce6d..d46c9b003b 100644
--- a/src/net_processing.cpp
+++ b/src/net_processing.cpp
@@ -4413,6 +4413,15 @@ void PeerManagerImpl::ProcessMessage(CNode& pfrom, const std::string& msg_type,
         }
 
         if (!requested_block_from_this_peer && !pfrom.m_bip152_highbandwidth_to) {
+            if (m_opts.ignore_incoming_txs) {
+                BlockTransactionsRequest req;
+                for (size_t i = 0; i < cmpctblock.BlockTxCount(); i++) {
+                    req.indexes.push_back(i);
+                }
+                req.blockhash = pindex->GetBlockHash();
+                MakeAndPushMessage(pfrom, NetMsgType::GETBLOCKTXN, vInv);
+                return;
+            }
             LogDebug(BCLog::CMPCTBLOCK, "Peer %d%s, not marked as high-bandwidth, sent us an unsolicited compact block!", pfrom.GetId(), pfrom.LogIP(fLogIPs));
             return;
         }

Copy link
Contributor

@Crypt-iQ Crypt-iQ left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

treat a CMPCTBLOCK announcement as being fine at any time (eg, so that miners who get a block via RPC can just send CMPCTBLOCK messages directly), and the "high-bandwidth" as just a way for nodes to avoid wasting their bandwidth sending those messages unnecessarily

I think yes, except for -blocksonly nodes, where on receipt we wouldn't punish or anything, but we would ideally drop the message?

change our behaviour when sending GETBLOCKTXN to also request any transactions that were in our mempool but had a sequence number higher than when we first saw the new block's header (If you completely reconstructed the block, but used newer txns, you would still not send a GETBLOCKTXN)

I think it would be possible for an attacker to send HEADERS, receive GETDATA, wait for some time to allow new txns to accumulate in the node's mempool, then send a CMPCTBLOCK with suspected new txns that wouldn't have been announced yet. But, if the targeted node receives the valid block from any other peer, the download state gets wiped. So this is an edge-of-an-edge case. Since an attacker wants "tight timing information" as you pointed out on the linked issue and this is only able to be done ~10mins (and may not even work all the time if the attacker is not HB), it doesn't seem urgent.

without something concrete I would prefer to close down our implementation of the protocol to be as conservative as possible about what CMPCTBLOCK messages we process

Any restrictive change here introduces a kind of implicit version for compact block relay even if it's later lifted. I guess the unknown for me is whether this is something that miners (or others) want to do, are planning to do, or if this is a usecase we should support?

+1 to the refactor suggestion. I don't have any suggestions off-hand. Maybe a new file could be introduced specifically for compact block relay (similar to what was done for blockencodings.cpp)? It would make writing tests a bit easier. Also, checking whether partialBlock is nullptr (and having to reset it in some cases), is awkward.

@@ -4435,6 +4435,11 @@ void PeerManagerImpl::ProcessMessage(CNode& pfrom, const std::string& msg_type,
range_flight.first++;
}

if (!requested_block_from_this_peer && !pfrom.m_bip152_highbandwidth_to) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree that it doesn't make sense to process, we should drop it asap. Haven't tested either of the below things:

ProcessHeadersMessage will trigger a GETDATA and if the block turns out to be invalid, our peer who sent us the CMPCTBLOCK won't respond and will get disconnected for stalling. If our peer is modified to relay these in the first place to all peers, they could isolate themselves from -blocksonly nodes (assuming they aren't a mining node and don't validate what they send out). This is #13370.

A tangent, but I think a miner could trigger this statement in honest peer's connections if they mined and withheld enough blocks (~4-5) and if the last CMPCTBLOCK is for an invalid block. I'm leaving out a few steps and can write out what I think on the linked issue if it's worth addressing?

@@ -4412,6 +4412,11 @@ void PeerManagerImpl::ProcessMessage(CNode& pfrom, const std::string& msg_type,
range_flight.first++;
}

if (!requested_block_from_this_peer && !pfrom.m_bip152_highbandwidth_to) {
LogDebug(BCLog::NET, "Peer %d, not marked as high-bandwidth, sent us an unsolicited compact block!\n", pfrom.GetId());
return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't tested. I think this will cause extra log messages if:

  • receive HEADERS from LB peer=0
  • send GETDATA to peer=0
  • receive CMPCTBLOCK from HB peer=1, it reconstructs, clearing download state for all peers for this block
  • receive CMPCTBLOCK from LB peer=0

Might confuse a user, but nothing is actually wrong here?

Copy link
Contributor

@hodlinator hodlinator left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 32bfd61

unsolicited_peer.send_and_ping(msg)
with p2p_lock:
assert "getblocktxn" in unsolicited_peer.last_message
unsolicited_peer.clear_getblocktxn()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This clearing should arguably happen before the assert, along the lines of #32606 (comment) / 32bfd61

Full diff, including some previously missing clear_getblocktxn(), and deferring clearing to the last possible place.
--- a/test/functional/p2p_compactblocks.py
+++ b/test/functional/p2p_compactblocks.py
@@ -414,6 +414,7 @@ class CompactBlocksTest(BitcoinTestFramework):
             [k0, k1] = comp_block.get_siphash_keys()
             coinbase_hash = block.vtx[0].wtxid_int
             comp_block.shortids = [calculate_shortid(k0, k1, coinbase_hash)]
+            test_node.clear_getblocktxn()
             test_node.send_and_ping(msg_cmpctblock(comp_block.to_p2p()))
             assert_equal(int(node.getbestblockhash(), 16), block.hashPrevBlock)
             # Expect a getblocktxn message.
@@ -451,6 +452,7 @@ class CompactBlocksTest(BitcoinTestFramework):
         node = self.nodes[0]
 
         def test_getblocktxn_response(compact_block, peer, expected_result):
+            peer.clear_getblocktxn()
             msg = msg_cmpctblock(compact_block.to_p2p())
             peer.send_and_ping(msg)
             with p2p_lock:
@@ -517,8 +519,7 @@ class CompactBlocksTest(BitcoinTestFramework):
             assert tx.txid_hex in mempool
 
         # Clear out last request.
-        with p2p_lock:
-            test_node.last_message.pop("getblocktxn", None)
+        test_node.clear_getblocktxn()
 
         # Send compact block
         comp_block.initialize_from_block(block, prefill_list=[0], use_witness=True)
@@ -547,6 +548,7 @@ class CompactBlocksTest(BitcoinTestFramework):
         # Send compact block
         comp_block = HeaderAndShortIDs()
         comp_block.initialize_from_block(block, prefill_list=[0], use_witness=True)
+        test_node.clear_getblocktxn()
         test_node.send_and_ping(msg_cmpctblock(comp_block.to_p2p()))
         absolute_indexes = []
         with p2p_lock:
@@ -592,6 +594,7 @@ class CompactBlocksTest(BitcoinTestFramework):
         # Send compact block
         comp_block = HeaderAndShortIDs()
         comp_block.initialize_from_block(block, prefill_list=[0], use_witness=True)
+        test_node.clear_getblocktxn()
         test_node.send_and_ping(msg_cmpctblock(comp_block.to_p2p()))
         absolute_indexes = []
         with p2p_lock:
@@ -844,6 +847,7 @@ class CompactBlocksTest(BitcoinTestFramework):
 
             cmpct_block = HeaderAndShortIDs()
             cmpct_block.initialize_from_block(block)
+            peer.clear_getblocktxn()
             msg = msg_cmpctblock(cmpct_block.to_p2p())
             peer.send_and_ping(msg)
             with p2p_lock:
@@ -924,14 +928,13 @@ class CompactBlocksTest(BitcoinTestFramework):
         assert len(self.utxos)
 
         def announce_cmpct_block(node, peer, txn_count, expect_getblocktxn):
-            peer.clear_getblocktxn()
-
             utxo = self.utxos.pop(0)
             block = self.build_block_with_transactions(node, utxo, txn_count)
 
             cmpct_block = HeaderAndShortIDs()
             cmpct_block.initialize_from_block(block)
             msg = msg_cmpctblock(cmpct_block.to_p2p())
+            peer.clear_getblocktxn()
             peer.send_and_ping(msg)
             with p2p_lock:
                 if expect_getblocktxn:
@@ -1027,10 +1030,10 @@ class CompactBlocksTest(BitcoinTestFramework):
         block, msg = build_compact_block()
         unsolicited_peer.send_without_ping(msg_headers([block]))
         unsolicited_peer.wait_for_getdata([block.hash_int], timeout=30)
+        unsolicited_peer.clear_getblocktxn()
         unsolicited_peer.send_and_ping(msg)
         with p2p_lock:
             assert "getblocktxn" in unsolicited_peer.last_message
-        unsolicited_peer.clear_getblocktxn()
 
         # The node will ask for transactions from an unsolicited compact block
         # it receives from a high bandwidth peer, we need to use one set up earlier,

Comment on lines 286 to 287
# This index will be too high
prefilled_txn = PrefilledTransaction(1, block.vtx[0])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Named variable feels clearer. (No risk of confusing with the index used for block.vtx[...]).

Suggested change
# This index will be too high
prefilled_txn = PrefilledTransaction(1, block.vtx[0])
too_high_index = 1
prefilled_txn = PrefilledTransaction(too_high_index, block.vtx[0])

@DrahtBot DrahtBot requested a review from hodlinator November 3, 2025 14:03
Copy link
Contributor

@Crypt-iQ Crypt-iQ left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 32bfd61 and left some comments. I think if the use-case brought up by @ajtowns is not a concern, then I can ACK.

Separately, I've been thinking about how a refactor would look. I'm still at a loss because unifying the headers and the compact blocks processing logic seems difficult because sometimes the compact block can be processed as a headers message (i.e. calls ProcessHeadersMessage) and there are compact blocks-specific checks that a headers message wouldn't go through.

hb_peer.send_await_disconnect(msg_cmpctblock(cmpct_block))
assert_equal(int(self.nodes[0].getbestblockhash(), 16), block.hashPrevBlock)

# Test a solicited invalid cmpctblock from a non-HB peer.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit message of 42e281d can change to say "non-HB peers sending unsolicited invalid"

assert_equal([peer['bip152_hb_to'] for peer in node.getpeerinfo()], [False, True, True, True])

block, cmpct_block = announce_cmpct_block(node, stalling_peer, num_missing)
block, cmpct_block = announce_cmpct_block(node, stalling_peer, num_missing, expect_getblocktxn=False)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since stalling_peer is low-bandwidth, the compact block is dropped and the full block is requested (taking up the first slot) in SendMessages since self.nodes[0] has processed the header. The test still passes, but I think what this is testing has changed slightly from the original. The original behavior can be maintained if stalling_peer sends the header first and then sends over the compact block.

Copy link
Contributor

@w0xlt w0xlt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reACK 32bfd61

range_flight.first++;
}

if (!requested_block_from_this_peer && !pfrom.m_bip152_highbandwidth_to) {
Copy link
Contributor

@w0xlt w0xlt Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Maybe add a comment for clarification?

Suggested change
if (!requested_block_from_this_peer && !pfrom.m_bip152_highbandwidth_to) {
// Accept unsolicited cmpctblock *only* from peers we selected as high-bandwidth
// "to" (hb_to). Peers that merely requested HB *from* us (hb_from) don't qualify.
// This matches BIP152's "unsolicited in high-bandwidth mode" intent.
if (!requested_block_from_this_peer && !pfrom.m_bip152_highbandwidth_to) {

@DrahtBot DrahtBot requested a review from Crypt-iQ November 12, 2025 21:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.