Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Makefile.test.include
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ BITCOIN_TESTS =\
test/llmq_hash_tests.cpp \
test/llmq_invalid_type_tests.cpp \
test/llmq_params_tests.cpp \
test/llmq_qgetdata_tests.cpp \
test/llmq_snapshot_tests.cpp \
test/llmq_utils_tests.cpp \
test/logging_tests.cpp \
Expand Down
48 changes: 39 additions & 9 deletions src/llmq/net_quorum.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ void NetQuorum::ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataS
CQuorumDataRequest request;
vRecv >> request;

// nError is a response-only field on QDATA. Honest QGETDATA senders never
// emit it (serialization skips UNDEFINED), so a request carrying one is not a
// request we could ever have produced -- score it in full rather than tolerating
// a run of them. Accepting a requester-supplied value let an attacker pick
// QUORUM_VERIFICATION_VECTOR_MISSING / ENCRYPTED_CONTRIBUTIONS_MISSING and
// suppress the rate-limit ban while still forcing the expensive response
// construction path.
if (request.GetError() != CQuorumDataRequest::Errors::UNDEFINED) {
m_peer_manager->PeerMisbehaving(pfrom.GetId(), 100, "qgetdata with error field");
return;
}
Comment on lines +89 to +92

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

hmm; why only 10...?


auto sendQDATA = [&](CQuorumDataRequest::Errors nError,
bool request_limit_exceeded,
const CDataStream& body = CDataStream(SER_NETWORK, PROTOCOL_VERSION)) -> bool {
Expand All @@ -104,25 +116,42 @@ void NetQuorum::ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataS
return misbehave;
};

const CQuorumDataRequestKey key(pfrom.GetVerifiedProRegTxHash(), false, request.GetQuorumHash(), request.GetLLMQType());
const bool request_limit_exceeded = !m_qman.RegisterDataRequest(key, request, /*add_expiry_bias=*/false);

// Validate cheap, attacker-controlled fields before registering a tracking entry so
// garbage llmqType / unknown quorumHash values cannot grow mapQuorumDataRequests
// (and so rate-limit keys only cover requests that can reach the expensive path).
// Neither reply below reports a rate-limit state: they are reached before any
// tracking entry exists, so there is none to report. Cost is a ~93-byte reply to a
// ~93-byte request, so there is no amplification to gate either.
if (!Params().GetLLMQ(request.GetLLMQType()).has_value()) {
// Unlike the misses below, this one cannot be explained by the peer being ahead of
// Unlike the miss below, this one cannot be explained by the peer being ahead of
// us: no quorum of an unregistered type can exist on this chain, so there is
// nothing to ask about. Answer with the error anyway, then score in full.
sendQDATA(CQuorumDataRequest::Errors::QUORUM_TYPE_INVALID, request_limit_exceeded);
sendQDATA(CQuorumDataRequest::Errors::QUORUM_TYPE_INVALID, /*request_limit_exceeded=*/false);
m_peer_manager->PeerMisbehaving(pfrom.GetId(), 100, "invalid llmqType in QGETDATA");
return;
}

const CBlockIndex* pQuorumBaseBlockIndex = WITH_LOCK(::cs_main, return m_chainman.m_blockman.LookupBlockIndex(request.GetQuorumHash()));
if (pQuorumBaseBlockIndex == nullptr) {
if (sendQDATA(CQuorumDataRequest::Errors::QUORUM_BLOCK_NOT_FOUND, request_limit_exceeded)) {
m_peer_manager->PeerMisbehaving(pfrom.GetId(), 25, "request limit exceeded");
}
// A block we have not synced yet is legitimate for an honest peer, so this one is
// not scored on its own.
sendQDATA(CQuorumDataRequest::Errors::QUORUM_BLOCK_NOT_FOUND, /*request_limit_exceeded=*/false);
return;
}

const CQuorumDataRequestKey key(pfrom.GetVerifiedProRegTxHash(), false, request.GetQuorumHash(), request.GetLLMQType());
const auto registered = m_qman.RegisterDataRequest(key, request, /*add_expiry_bias=*/false);
if (!registered.has_value()) {
// Per-identity tracking budget exhausted: this peer has too many live requests
// outstanding. Score it and drop without doing any of the response work below --
// an honest peer never reaches this, and the rate limit alone cannot bound the
// map because a fresh quorumHash is always a fresh key.
LogPrint(BCLog::LLMQ, "NetQuorum::%s -- %s: inbound request budget exhausted, from peer=%d\n",
__func__, msg_type, pfrom.GetId());
m_peer_manager->PeerMisbehaving(pfrom.GetId(), 25, "too many quorum data requests");
return;
}
const bool request_limit_exceeded = !*registered;

const auto pQuorum = m_qman.GetQuorum(request.GetLLMQType(), request.GetQuorumHash());
if (pQuorum == nullptr) {
Expand Down Expand Up @@ -321,7 +350,8 @@ DataRequestStatus NetQuorum::RequestQuorumData(CNode& peer, const CQuorum& quoru
quorum.m_quorum_base_block_index->GetBlockHash(), quorum.qc->llmqType);
const CQuorumDataRequest request(quorum.qc->llmqType, quorum.m_quorum_base_block_index->GetBlockHash(),
nDataMask, proTxHash);
if (!m_qman.RegisterDataRequest(key, request)) {
// We initiated this request, so the inbound budget cannot apply and the result is engaged.
if (m_qman.RegisterDataRequest(key, request) != std::optional<bool>{true}) {
return m_qman.GetDataRequestStatus(peer.GetVerifiedProRegTxHash(), /*we_requested=*/true,
quorum.m_quorum_base_block_index->GetBlockHash(), quorum.qc->llmqType);
}
Expand Down
36 changes: 28 additions & 8 deletions src/llmq/quorumsman.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,13 @@ void CQuorumManager::CleanupExpiredDataRequests() const
auto it = mapQuorumDataRequests.begin();
while (it != mapQuorumDataRequests.end()) {
if (it->second.IsExpired(/*add_bias=*/true)) {
if (!it->first.m_we_requested) {
// Release the entry's per-identity budget, else a peer stays locked out forever.
if (auto count_it = m_inbound_request_counts.find(it->first.proRegTx);
count_it != m_inbound_request_counts.end() && --count_it->second == 0) {
m_inbound_request_counts.erase(count_it);
}
}
it = mapQuorumDataRequests.erase(it);
} else {
++it;
Expand Down Expand Up @@ -370,19 +377,32 @@ CQuorumCPtr CQuorumManager::GetQuorum(Consensus::LLMQType llmqType, gsl::not_nul
return BuildQuorumFromCommitment(llmqType, pQuorumBaseBlockIndex, populate_cache);
}

bool CQuorumManager::RegisterDataRequest(const CQuorumDataRequestKey& key, const CQuorumDataRequest& request,
bool add_expiry_bias) const
std::optional<bool> CQuorumManager::RegisterDataRequest(const CQuorumDataRequestKey& key,
const CQuorumDataRequest& request,
bool add_expiry_bias) const
{
LOCK(cs_data_requests);
// A peer-initiated request for an unseen key consumes tracking budget. The rate limit cannot
// bound the map on its own: a fresh quorumHash is always a fresh key, so it is never "already
// pending". Re-requests of an existing key fall through to the rate limit below instead.
if (!key.m_we_requested && !mapQuorumDataRequests.count(key)) {
if (auto it = m_inbound_request_counts.find(key.proRegTx);
it != m_inbound_request_counts.end() && it->second >= MAX_INBOUND_DATA_REQUESTS) {
return std::nullopt;
}
}
auto [old_pair, inserted] = mapQuorumDataRequests.emplace(key, request);
if (!inserted) {
if (old_pair->second.IsExpired(add_expiry_bias)) {
old_pair->second = request;
return true;
if (inserted) {
if (!key.m_we_requested) {
++m_inbound_request_counts[key.proRegTx];
}
return false;
return true;
}
return true;
if (old_pair->second.IsExpired(add_expiry_bias)) {
old_pair->second = request;
return true;
}
return false;
}

CQuorumManager::DataResponseValidation CQuorumManager::ValidateDataResponse(
Expand Down
26 changes: 23 additions & 3 deletions src/llmq/quorumsman.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#include <deque>
#include <map>
#include <memory>
#include <optional>
#include <thread>

class CBLSSignature;
Expand Down Expand Up @@ -48,6 +49,18 @@ class CDKGSessionManager;
class CQuorumBlockProcessor;
class CQuorumSnapshotManager;

//! Per-identity budget for live peer-initiated QGETDATA tracking entries.
//!
//! Entries are keyed on the attacker-chosen quorumHash, so without a cap a peer that never
//! repeats a hash is never rate-limited and grows mapQuorumDataRequests unboundedly for the
//! 300s+bias expiry window (cleanup only runs per-block, and not at all during IBD).
//! The budget is per requesting identity rather than global so one peer cannot evict or
//! starve another; all qwatch peers share the null proRegTx identity and therefore one budget,
//! matching the existing rate-limit behaviour for that class of peer.
//! An honest peer requests at most vvec+contributions for a handful of quorums it is recovering,
//! so this is orders of magnitude above legitimate use.
static constexpr size_t MAX_INBOUND_DATA_REQUESTS{64};

/**
* The quorum manager maintains quorums which were mined on chain. When a quorum is requested from the manager,
* it will lookup the commitment (through CQuorumBlockProcessor) and build a CQuorum object from it.
Expand All @@ -70,6 +83,10 @@ class CQuorumManager final
mutable Mutex cs_data_requests;
mutable std::unordered_map<CQuorumDataRequestKey, CQuorumDataRequest, StaticSaltedHasher> mapQuorumDataRequests
GUARDED_BY(cs_data_requests);
//! Live peer-initiated entries in mapQuorumDataRequests, counted per requesting identity so
//! the per-identity budget can be enforced without scanning the whole map.
mutable std::unordered_map<uint256, size_t, StaticSaltedHasher> m_inbound_request_counts
GUARDED_BY(cs_data_requests);

mutable Mutex m_cs_maps;
mutable std::map<Consensus::LLMQType, Uint256LruHashMap<CQuorumPtr>> mapQuorumsCache
Expand Down Expand Up @@ -131,9 +148,12 @@ class CQuorumManager final
bool IsMasternode() const;
bool IsWatching() const;

//! Request tracking for QGETDATA/QDATA — used by NetQuorum and RPC
bool RegisterDataRequest(const CQuorumDataRequestKey& key, const CQuorumDataRequest& request,
bool add_expiry_bias = true) const
//! Request tracking for QGETDATA/QDATA — used by NetQuorum and RPC.
//! Returns nullopt when a peer-initiated request would exceed that identity's tracking
//! budget, true when the entry was created or refreshed, and false when an unexpired entry
//! already exists (the rate limit applies).
std::optional<bool> RegisterDataRequest(const CQuorumDataRequestKey& key, const CQuorumDataRequest& request,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve duplicate suppression in the RPC caller

When quorum getdata is invoked again for the same peer and quorum before the request expires, this method now returns std::optional<bool>{false}. The unchanged caller in src/rpc/quorums.cpp:935 applies ! to the optional itself, which tests whether it is engaged rather than its contained value, so it proceeds to send the duplicate QGETDATA and reports success. The responder then treats that duplicate as rate-limit abuse and scores this node; repeated RPC calls can ultimately disconnect it. Update that caller to inspect the contained boolean just as RequestQuorumData now does.

Useful? React with 👍 / 👎.

bool add_expiry_bias = true) const
EXCLUSIVE_LOCKS_REQUIRED(!cs_data_requests);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
enum class DataResponseValidation : uint8_t { OK, NotRequested, AlreadyReceived, Mismatch };
DataResponseValidation ValidateDataResponse(const CQuorumDataRequestKey& key,
Expand Down
Loading
Loading