Skip to content

Commit b988249

Browse files
fix: bound DKG pending message queue across NodeId reconnects
CDKGPendingMessages bounded retention only per NodeId, an ephemeral per-connection counter, and never erased the entry. A peer that disconnects and reconnects therefore got a fresh quota while its previously queued payloads and seen-hashes stayed resident, so the queue grew linearly in the number of connections with no global ceiling. For llmq_400_85 that is roughly 56 MB per connection cycle, repeatable indefinitely. Victims are not limited to masternodes: in observer mode the handler still enqueues, never pops and never clears, so retention there is permanent. Add a global cap across all NodeIds and release a disconnecting peer's payloads and seen-hashes via a finalize hook. Filed as a draft: the cap is still message-count-based rather than byte-based, and EvictGreediestNode runs before the duplicate check, so a malicious masternode can replay seen hashes to evict honest peers' queued messages without occupying a slot itself. Both are unresolved.
1 parent f1dde51 commit b988249

8 files changed

Lines changed: 432 additions & 8 deletions

‎src/Makefile.test.include‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ BITCOIN_TESTS =\
137137
test/limitedmap_tests.cpp \
138138
test/llmq_blockprocessor_tests.cpp \
139139
test/llmq_dkg_tests.cpp \
140+
test/llmq_dkg_pending_tests.cpp \
140141
test/llmq_chainlock_tests.cpp \
141142
test/llmq_commitment_tests.cpp \
142143
test/llmq_hash_tests.cpp \

‎src/llmq/dkgsessionhandler.cpp‎

Lines changed: 116 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
#include <llmq/dkgsessionhandler.h>
66

77
#include <logging.h>
8+
#include <streams.h>
89
#include <uint256.h>
910

11+
#include <optional>
1012
#include <stdexcept>
1113

1214
namespace llmq {
@@ -25,23 +27,82 @@ CDKGSessionHandler::CDKGSessionHandler(const Consensus::LLMQParams& _params) :
2527

2628
CDKGSessionHandler::~CDKGSessionHandler() = default;
2729

30+
std::list<CDKGPendingMessages::PendingMessage>::iterator CDKGPendingMessages::EraseEntry(
31+
std::list<PendingMessage>::iterator it)
32+
{
33+
seenMessages.erase(it->hash);
34+
if (auto qit = queuedPerNode.find(it->from); qit != queuedPerNode.end() && --qit->second == 0) {
35+
queuedPerNode.erase(qit);
36+
}
37+
return pendingMessages.erase(it);
38+
}
39+
40+
bool CDKGPendingMessages::EvictGreediestNode()
41+
{
42+
// Pick the peer holding the most slots, so a flooder is always its own
43+
// victim: a peer that queued one message can only be evicted once every
44+
// other peer is down to one as well.
45+
std::optional<NodeId> victim;
46+
size_t victim_count{0};
47+
for (const auto& [node, count] : queuedPerNode) {
48+
if (node < 0) continue; // never evict our own messages
49+
if (count > victim_count) {
50+
victim = node;
51+
victim_count = count;
52+
}
53+
}
54+
if (!victim.has_value()) return false;
55+
56+
// Drop that peer's oldest message: it is the least likely to still be
57+
// relevant to the current phase.
58+
for (auto it = pendingMessages.begin(); it != pendingMessages.end(); ++it) {
59+
if (it->from == *victim) {
60+
LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- queue full (%d), evicting oldest of peer=%d (%d queued)\n",
61+
__func__, maxMessagesTotal, *victim, victim_count);
62+
EraseEntry(it);
63+
return true;
64+
}
65+
}
66+
return false;
67+
}
68+
2869
void CDKGPendingMessages::PushPendingMessage(NodeId from, std::shared_ptr<CDataStream> pm, const uint256& hash)
2970
{
3071
LOCK(cs_messages);
3172

32-
if (messagesPerNode[from] >= maxMessagesPerNode) {
73+
// Our own messages (from < 0) are produced by our phase handler, are a
74+
// handful per round, and are the only way our contribution reaches the
75+
// quorum. They must never be dropped by a peer-driven bound.
76+
const bool is_own = from < 0;
77+
78+
if (!is_own && messagesPerNode[from] >= maxMessagesPerNode) {
3379
// TODO ban?
3480
LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- too many messages, peer=%d\n", __func__, from);
3581
return;
3682
}
37-
messagesPerNode[from]++;
83+
84+
// Global bound: NodeId is ephemeral (reconnect => fresh id => fresh per-node
85+
// quota), so a per-node-only cap cannot bound total queue memory. Enforce it
86+
// by evicting the greediest peer rather than rejecting the newest message,
87+
// so one flooding peer cannot starve the DKG for honest members.
88+
while (pendingMessages.size() >= maxMessagesTotal) {
89+
if (!EvictGreediestNode()) {
90+
// Only own messages left; they are bounded by our own phase handler.
91+
break;
92+
}
93+
}
94+
95+
if (!is_own) {
96+
messagesPerNode[from]++;
97+
}
3898

3999
if (!seenMessages.emplace(hash).second) {
40100
LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- already seen %s, peer=%d\n", __func__, hash.ToString(), from);
41101
return;
42102
}
43103

44-
pendingMessages.emplace_back(std::make_pair(from, std::move(pm)));
104+
pendingMessages.emplace_back(PendingMessage{from, std::move(pm), hash});
105+
queuedPerNode[from]++;
45106
}
46107

47108
std::list<CDKGPendingMessages::BinaryMessage> CDKGPendingMessages::PopPendingMessages(size_t maxCount)
@@ -50,7 +111,13 @@ std::list<CDKGPendingMessages::BinaryMessage> CDKGPendingMessages::PopPendingMes
50111

51112
std::list<BinaryMessage> ret;
52113
while (!pendingMessages.empty() && ret.size() < maxCount) {
53-
ret.emplace_back(std::move(pendingMessages.front()));
114+
auto& front = pendingMessages.front();
115+
ret.emplace_back(front.from, std::move(front.msg));
116+
// Popped messages are handed to the DKG session; their content hash stays
117+
// in seenMessages so AlreadyHave() keeps suppressing re-requests.
118+
if (auto qit = queuedPerNode.find(front.from); qit != queuedPerNode.end() && --qit->second == 0) {
119+
queuedPerNode.erase(qit);
120+
}
54121
pendingMessages.pop_front();
55122
}
56123

@@ -68,14 +135,59 @@ void CDKGPendingMessages::Clear()
68135
LOCK(cs_messages);
69136
pendingMessages.clear();
70137
messagesPerNode.clear();
138+
queuedPerNode.clear();
71139
seenMessages.clear();
72140
}
73141

142+
void CDKGPendingMessages::RemoveNode(NodeId nodeId)
143+
{
144+
// Own/local enqueues use from=-1 and are not tied to a peer disconnect.
145+
if (nodeId < 0) {
146+
return;
147+
}
148+
149+
LOCK(cs_messages);
150+
messagesPerNode.erase(nodeId);
151+
152+
// Runs under ::cs_main (via PeerManagerImpl::FinalizeNode), so skip the list
153+
// scan entirely for the overwhelmingly common case of a peer that never
154+
// queued a DKG message.
155+
if (queuedPerNode.find(nodeId) == queuedPerNode.end()) {
156+
return;
157+
}
158+
159+
for (auto it = pendingMessages.begin(); it != pendingMessages.end();) {
160+
if (it->from == nodeId) {
161+
// Free the content-hash slot too; otherwise a reconnecting attacker
162+
// can grow seenMessages without bound even after payloads are dropped
163+
// (especially in observer mode where Clear() never runs). The hash is
164+
// stored alongside the payload, so no re-hashing happens here.
165+
it = EraseEntry(it);
166+
} else {
167+
++it;
168+
}
169+
}
170+
}
171+
172+
size_t CDKGPendingMessages::Size() const
173+
{
174+
LOCK(cs_messages);
175+
return pendingMessages.size();
176+
}
177+
74178
void CDKGSessionHandler::ClearPendingMessages()
75179
{
76180
pendingContributions.Clear();
77181
pendingComplaints.Clear();
78182
pendingJustifications.Clear();
79183
pendingPrematureCommitments.Clear();
80184
}
185+
186+
void CDKGSessionHandler::RemoveNode(NodeId nodeId)
187+
{
188+
pendingContributions.RemoveNode(nodeId);
189+
pendingComplaints.RemoveNode(nodeId);
190+
pendingJustifications.RemoveNode(nodeId);
191+
pendingPrematureCommitments.RemoveNode(nodeId);
192+
}
81193
} // namespace llmq

‎src/llmq/dkgsessionhandler.h‎

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
#include <net.h> // for NodeId
99
#include <sync.h>
10+
#include <uint256.h> // PendingMessage stores a uint256 by value
1011

1112
#include <list>
1213
#include <map>
@@ -17,7 +18,6 @@
1718

1819
class CDataStream;
1920
class CBlockIndex;
20-
class uint256;
2121

2222
namespace Consensus {
2323
struct LLMQParams;
@@ -48,28 +48,79 @@ enum class QuorumPhase {
4848
* handler thread.
4949
*
5050
* Each message type has it's own instance of this class.
51+
*
52+
* Retention is bounded both per NodeId (@ref maxMessagesPerNode) and globally
53+
* (@ref maxMessagesTotal = maxMessagesPerNode * MAX_PENDING_NODES_FACTOR). The
54+
* global bound is required because NodeId is ephemeral: a reconnecting peer
55+
* receives a fresh id and would otherwise obtain a fresh full per-node quota
56+
* forever. @ref RemoveNode drops a disconnected peer's counter and still-queued
57+
* payloads so live peers can use the capacity.
58+
*
59+
* The global bound must not become a way for one peer to stall the DKG for
60+
* everyone, so it is enforced by evicting the peer that currently occupies the
61+
* most slots rather than by rejecting whoever pushes next: a flooder is always
62+
* its own victim and honest members keep their place in the queue. Locally
63+
* generated messages (@c from < 0) bypass the bound entirely — they are produced
64+
* by our own phase handler, are a handful per round, and are the only path by
65+
* which our own contribution reaches the quorum.
5166
*/
5267
class CDKGPendingMessages
5368
{
5469
public:
5570
using BinaryMessage = std::pair<NodeId, std::shared_ptr<CDataStream>>;
5671

72+
//! Cap total retained messages at this multiple of the per-node limit. A
73+
//! legitimate round needs at most maxMessagesPerNode messages in total (one
74+
//! per member, doubled to catch double-sends), so this leaves generous
75+
//! headroom for honest traffic while still bounding what peers can pin.
76+
static constexpr size_t MAX_PENDING_NODES_FACTOR = 8;
77+
5778
private:
79+
struct PendingMessage {
80+
NodeId from;
81+
std::shared_ptr<CDataStream> msg;
82+
//! Content hash as inserted into @ref seenMessages. Stored so eviction
83+
//! and disconnect cleanup never have to re-hash the payload (which would
84+
//! put unbounded SHA256 work under ::cs_main via FinalizeNode).
85+
uint256 hash;
86+
};
87+
5888
const size_t maxMessagesPerNode;
89+
const size_t maxMessagesTotal;
5990
mutable Mutex cs_messages;
60-
std::list<BinaryMessage> pendingMessages GUARDED_BY(cs_messages);
91+
std::list<PendingMessage> pendingMessages GUARDED_BY(cs_messages);
92+
//! Per-connection lifetime quota counter. Deliberately not decremented when a
93+
//! message is popped: the quota is per connection, not per queue slot, so a
94+
//! peer cannot regain capacity by getting its messages processed. Released
95+
//! only by @ref RemoveNode and @ref Clear.
6196
std::map<NodeId, size_t> messagesPerNode GUARDED_BY(cs_messages);
97+
//! Live queue occupancy per NodeId, kept in sync with @ref pendingMessages.
98+
//! Used to pick the greediest peer on overflow and to skip the list scan in
99+
//! @ref RemoveNode when the disconnecting peer has nothing queued.
100+
std::map<NodeId, size_t> queuedPerNode GUARDED_BY(cs_messages);
62101
Uint256HashSet seenMessages GUARDED_BY(cs_messages);
63102

103+
//! Erase one queue entry, keeping queuedPerNode and seenMessages in sync.
104+
//! Returns the iterator following the erased entry.
105+
std::list<PendingMessage>::iterator EraseEntry(std::list<PendingMessage>::iterator it)
106+
EXCLUSIVE_LOCKS_REQUIRED(cs_messages);
107+
//! Drop the oldest queued message of the peer occupying the most slots.
108+
//! Never evicts our own (from < 0) messages. Returns false if nothing could
109+
//! be evicted (i.e. the queue holds only own messages).
110+
bool EvictGreediestNode() EXCLUSIVE_LOCKS_REQUIRED(cs_messages);
111+
64112
public:
65113
explicit CDKGPendingMessages(size_t _maxMessagesPerNode) :
66-
maxMessagesPerNode(_maxMessagesPerNode) {};
114+
maxMessagesPerNode(_maxMessagesPerNode),
115+
maxMessagesTotal(_maxMessagesPerNode * MAX_PENDING_NODES_FACTOR) {};
67116

68117
/**
69118
* Enqueue a serialized DKG message under @p from with content hash @p hash.
70119
* Caller is responsible for hashing the payload and (for real peers)
71120
* routing the erase-request to PeerManager. Drops the message silently on
72-
* per-node capacity overflow or duplicate hash.
121+
* per-node capacity overflow or duplicate hash. On global capacity overflow
122+
* the greediest *other* peer is evicted instead (see the class comment), so
123+
* a flooder cannot deny the queue to honest members.
73124
*/
74125
void PushPendingMessage(NodeId from, std::shared_ptr<CDataStream> pm, const uint256& hash)
75126
EXCLUSIVE_LOCKS_REQUIRED(!cs_messages);
@@ -78,6 +129,22 @@ class CDKGPendingMessages
78129
bool HasSeen(const uint256& hash) const EXCLUSIVE_LOCKS_REQUIRED(!cs_messages);
79130
void Clear() EXCLUSIVE_LOCKS_REQUIRED(!cs_messages);
80131

132+
/**
133+
* Drop @p nodeId's per-node counter, any still-queued payloads from that
134+
* peer, and the corresponding content hashes in @ref seenMessages. Called
135+
* on disconnect so a reconnecting attacker cannot pin memory (or grow
136+
* seenMessages) under abandoned NodeIds.
137+
*
138+
* Only *un-processed* messages are dropped. Anything already popped by the
139+
* phase handler has been accepted into the DKG session state and is
140+
* unaffected, so disconnecting a peer never invalidates a contribution the
141+
* session already took — the DKG completes as before.
142+
*/
143+
void RemoveNode(NodeId nodeId) EXCLUSIVE_LOCKS_REQUIRED(!cs_messages);
144+
145+
//! Number of still-queued binary messages (for tests / diagnostics).
146+
size_t Size() const EXCLUSIVE_LOCKS_REQUIRED(!cs_messages);
147+
81148
// Might return nullptr messages, which indicates that deserialization failed for some reason
82149
template <typename Message>
83150
std::vector<std::pair<NodeId, std::shared_ptr<Message>>> PopAndDeserializeMessages(size_t maxCount)
@@ -123,6 +190,7 @@ class CDKGSessionHandler
123190
virtual ~CDKGSessionHandler();
124191

125192
void ClearPendingMessages();
193+
void RemoveNode(NodeId nodeId);
126194

127195
public:
128196
virtual bool GetContribution(const uint256& hash, CDKGContribution& ret) const { return false; }

‎src/llmq/net_dkg.cpp‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,16 @@ bool NetDKG::ProcessGetData(CNode& pfrom, const CInv& inv, CConnman& connman, co
595595
return false;
596596
}
597597

598+
void NetDKG::FinalizeNode(NodeId nodeid)
599+
{
600+
// Release per-NodeId DKG queue state on disconnect. Required for both
601+
// active and observer mode: observer mode never runs ClearPendingMessages,
602+
// and active mode only clears at the start of the next DKG round.
603+
m_qdkgsman.ForEachHandler([nodeid](CDKGSessionHandler& handler) {
604+
handler.RemoveNode(nodeid);
605+
});
606+
}
607+
598608
void NetDKG::Start()
599609
{
600610
if (m_active == nullptr) return;

‎src/llmq/net_dkg.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ class NetDKG final : public NetHandler
7070
EXCLUSIVE_LOCKS_REQUIRED(!cs_indexed_quorums_cache);
7171
bool AlreadyHave(const CInv& inv) override;
7272
bool ProcessGetData(CNode& pfrom, const CInv& inv, CConnman& connman, const CNetMsgMaker& msgMaker) override;
73+
void FinalizeNode(NodeId nodeid) override;
7374
/**
7475
* Drives one phase-handler thread per ActiveDKGSessionHandler in active mode;
7576
* no-op in observer mode (no curSession to drive).

‎src/net_processing.cpp‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1795,6 +1795,13 @@ void PeerManagerImpl::FinalizeNode(const CNode& node) {
17951795
m_addrman.Connected(node.addr);
17961796
}
17971797

1798+
// Let protocol handlers free any state keyed on the ephemeral NodeId.
1799+
// Still under cs_main (LOCK scope is the whole function); handlers must only
1800+
// take their own locks and must not re-enter PeerManager.
1801+
for (const auto& handler : m_handlers) {
1802+
handler->FinalizeNode(nodeid);
1803+
}
1804+
17981805
LogPrint(BCLog::NET, "Cleared nodestate for peer=%d\n", nodeid);
17991806
}
18001807

‎src/net_processing.h‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ class NetHandler
130130
virtual void Interrupt() {}
131131
virtual void Schedule(CScheduler& scheduler) {}
132132

133+
//! Called when a peer is fully disconnected. Handlers may free any
134+
//! per-NodeId state keyed on the ephemeral connection id.
135+
virtual void FinalizeNode(NodeId /*nodeid*/) {}
136+
133137
virtual void ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataStream& vRecv) {}
134138

135139
// It returns true, if NetHandler has a responsibility about having this type of inventory and has corresponding data.

0 commit comments

Comments
 (0)