Summary
PrivateBroadcast::HavePendingTransactions() returns !m_transactions.empty(), which is true even when all transactions have already been confirmed by NUM_PRIVATE_BROADCAST_PER_TX (3) peers.
Transactions are not immediately removed from m_transactions after full confirmation — they remain until GetStale() + ReattemptPrivateBroadcast cleans them up, which can take 1–3 minutes (governed by STALE_DURATION = 1min).
Impact
In FinalizeNode, when a private-broadcast connection closes without sending PONG:
if (node.IsPrivateBroadcastConn() &&
!m_tx_for_private_broadcast.DidNodeConfirmReception(nodeid) &&
m_tx_for_private_broadcast.HavePendingTransactions()) { // ← always true during stale window
m_connman.m_private_broadcast.NumToOpenAdd(1);
}
This opens a replacement connection even when all 3 target peers have confirmed. The replacement calls PickTxForSend, which picks the already-fully-confirmed transaction and sends it to a 4th (or 5th, 6th…) peer — directly defeating the privacy guarantee of private broadcast.
Exploit Scenario
An adversary running several Bitcoin nodes can:
- Accept a private-broadcast connection from a victim
- Receive the INV (transaction)
- Disconnect without sending PONG (no confirmation)
- Observe the victim immediately open another connection to another adversary-controlled node
- Repeat, causing unbounded cascade
Each extra connection the victim opens to an adversary-controlled node confirms that this specific IP originated the transaction, deanonymizing the sender. This requires no special exploit — just not responding to PING.
Root Cause
// src/private_broadcast.cpp (before fix)
bool PrivateBroadcast::HavePendingTransactions()
{
LOCK(m_mutex);
return !m_transactions.empty(); // true even for fully-confirmed txs
}
Fix
Accept a sufficient_confirmations threshold and only return true if at least one transaction has fewer confirmations than the threshold. The FinalizeNode call site passes NUM_PRIVATE_BROADCAST_PER_TX:
// src/private_broadcast.cpp (after fix)
bool PrivateBroadcast::HavePendingTransactions(size_t sufficient_confirmations)
{
LOCK(m_mutex);
for (const auto& [tx, state] : m_transactions) {
const Priority p{DerivePriority(state.send_statuses)};
if (p.num_confirmed < sufficient_confirmations) return true;
}
return false;
}
A PR with the fix and a regression test is attached.