Skip to content

Commit 5260466

Browse files
Merge #7416: fix(net): bound quorum data response vectors
a286e0e fix: bound quorum data response vectors (PastaClaw) Pull request description: Depends on #7439. Please review only the final command-specific commit here. ## Issue being fixed or feature implemented Quorum-data responses carry verification-vector and encrypted-contribution vectors whose expected sizes are known from the requested quorum. This hardens QDATA processing by checking the serialized vector count before allocating, deserializing, or processing those vectors. This path is MNAuth/request-gated and is intentionally handled separately from unauthenticated public vector intake. ## What was done? - Read QDATA verification vectors with the requested quorum threshold as the maximum. - Read encrypted contributions with the number of valid quorum members as the maximum. - Require exact semantic counts and penalize mismatches before decrypt/aggregate work. - Extend functional coverage for undersized, oversized, and allocation-amplifying CompactSize declarations. The reusable bounded-vector serialization primitives are introduced separately in #7439. ## How Has This Been Tested? - `test/functional/p2p_quorum_data.py` ## Breaking Changes None. ## Checklist: - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have added or updated relevant unit/integration/functional/e2e tests - [ ] I have made corresponding changes to the documentation - [ ] I have assigned this pull request to a milestone _(for repository code-owners and collaborators only) ACKs for top commit: kwvg: Code near identical to past utACK (see [diff](https://github.com/dashpay/dash/compare/ecf49885bcbb40fd09bd81de95edf54d3996e2fe..a286e0e7eb4fd63eb65f05f5deefd5a2c4c43f55)) --- utACK a286e0e Tree-SHA512: 1d217cff7096f0dbad736e98be91e6b4eac154ae7ca0512cb3200f448b3152485d89f3f13a5d36617d7e4122aac2ed521f79ccd6ca787c0e8fafb200cb3cf118
2 parents e002c28 + a286e0e commit 5260466

2 files changed

Lines changed: 104 additions & 18 deletions

File tree

src/llmq/net_quorum.cpp

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,16 @@ void NetQuorum::ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataS
203203

204204
// Check if request has QUORUM_VERIFICATION_VECTOR data
205205
if (request.GetDataMask() & CQuorumDataRequest::QUORUM_VERIFICATION_VECTOR) {
206+
// Reject the wire count before decoding any BLS G1 element so a bogus
207+
// count cannot spend arbitrary CPU on doomed decodes. A mismatch — over
208+
// or under — is a protocol violation worth a full ban.
209+
const size_t expected_vvec_size{static_cast<size_t>(pQuorum->params.threshold)};
206210
std::vector<CBLSPublicKey> verificationVector;
207-
vRecv >> verificationVector;
211+
if (!UnserializeVectorWithMaxSize(vRecv, verificationVector, expected_vvec_size) ||
212+
verificationVector.size() != expected_vvec_size) {
213+
m_peer_manager->PeerMisbehaving(pfrom.GetId(), 100, "invalid quorum verification vector size");
214+
return;
215+
}
208216

209217
if (pQuorum->SetVerificationVector(verificationVector)) {
210218
m_qman.QueueQuorumForWarming(pQuorum);
@@ -279,7 +287,12 @@ bool NetQuorum::ProcessContribQDATA(CNode& pfrom, CDataStream& vRecv,
279287
}
280288

281289
std::vector<CBLSIESEncryptedObject<CBLSSecretKey>> vecEncrypted;
282-
vRecv >> vecEncrypted;
290+
const size_t expected_contributions{static_cast<size_t>(std::ranges::count(quorum.qc->validMembers, true))};
291+
if (!UnserializeVectorWithMaxSize(vRecv, vecEncrypted, expected_contributions) ||
292+
vecEncrypted.size() != expected_contributions) {
293+
m_peer_manager->PeerMisbehaving(pfrom.GetId(), 100, "invalid encrypted contribution vector size");
294+
return false;
295+
}
283296

284297
std::vector<CBLSSecretKey> vecSecretKeys;
285298
vecSecretKeys.resize(vecEncrypted.size());

test/functional/p2p_quorum_data.py

Lines changed: 89 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,20 @@
33
# Distributed under the MIT software license, see the accompanying
44
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
55

6+
import copy
7+
import struct
68
import time
79

8-
from test_framework.messages import CSigSharesInv, msg_qgetdata, msg_qsigsinv, msg_qwatch
10+
from test_framework.messages import (
11+
CSigSharesInv,
12+
msg_qdata,
13+
msg_qgetdata,
14+
msg_qsigsinv,
15+
msg_qwatch,
16+
ser_compact_size,
17+
ser_uint256,
18+
ser_vector,
19+
)
920
from test_framework.p2p import (
1021
p2p_lock,
1122
P2PInterface,
@@ -54,6 +65,46 @@
5465
oversized_inv_count = 65536 # ~512 KiB wire -> ~256 GiB declared allocation
5566

5667

68+
class _QDataWithRawPayload(msg_qdata):
69+
"""A msg_qdata that ships arbitrary bytes as its wire body.
70+
71+
The p2p framework calls serialize() to build the message payload; overriding
72+
it lets us craft a QDATA whose declared vvec/contribs CompactSize does not
73+
match the wire body — the exact shape a malicious peer would send.
74+
"""
75+
__slots__ = ("_raw_payload",)
76+
77+
def __init__(self, raw_payload):
78+
super().__init__()
79+
self._raw_payload = raw_payload
80+
81+
def serialize(self):
82+
return self._raw_payload
83+
84+
85+
def craft_qdata_with_bad_section(qdata_valid, *, bad_vvec=None, bad_contribs=None):
86+
"""Build a QDATA payload from qdata_valid with a single malformed vector section.
87+
88+
Both `bad_vvec` and `bad_contribs`, if provided, are raw wire bytes that
89+
fully replace that vector's section (compact-size prefix + any element
90+
bytes the attacker wants to include, typically none). Sections not
91+
overridden are copied verbatim from qdata_valid, so ProcessMessage reaches
92+
the intended check point (a valid vvec is required to reach the
93+
contributions check).
94+
"""
95+
payload = b""
96+
payload += struct.pack("<B", qdata_valid.quorum_type)
97+
payload += ser_uint256(qdata_valid.quorum_hash)
98+
payload += struct.pack("<H", qdata_valid.data_mask)
99+
payload += ser_uint256(qdata_valid.protx_hash)
100+
payload += struct.pack("<B", qdata_valid.error)
101+
if qdata_valid.data_mask & 0x01:
102+
payload += bad_vvec if bad_vvec is not None else ser_vector(qdata_valid.quorum_vvec)
103+
if qdata_valid.data_mask & 0x02:
104+
payload += bad_contribs if bad_contribs is not None else ser_vector(qdata_valid.enc_contributions)
105+
return _QDataWithRawPayload(payload)
106+
107+
57108
def assert_qdata(qdata, qgetdata, error, len_vvec=0, len_contributions=0):
58109
assert qdata is not None and qgetdata is not None
59110
assert_equal(qdata.quorum_type, qgetdata.quorum_type)
@@ -222,26 +273,48 @@ def test_basics():
222273
force_request_expire()
223274
assert mn1.get_node(self).quorum("getdata", id_p2p_mn1, 100, quorum_hash, 0x03, mn1.proTxHash)
224275
p2p_mn1.wait_for_qmessage("qgetdata")
225-
qdata_invalid_request = qdata_valid
276+
qdata_invalid_request = copy.deepcopy(qdata_valid)
226277
qdata_invalid_request.data_mask = 2
227278
p2p_mn1.send_message(qdata_invalid_request)
228279
wait_for_banscore(mn1.get_node(self), id_p2p_mn1, 30)
229-
# - Invalid verification vector
230-
force_request_expire()
231-
assert mn1.get_node(self).quorum("getdata", id_p2p_mn1, 100, quorum_hash, 0x03, mn1.proTxHash)
232-
p2p_mn1.wait_for_qmessage("qgetdata")
233-
qdata_invalid_vvec = qdata_valid
280+
# Invalid vector sizes now bump ban score to 100 and disconnect the peer,
281+
# so each of the following cases uses a fresh authenticated connection.
282+
283+
def send_bad_qdata_expect_disconnect(bad_qdata):
284+
mn1.get_node(self).disconnect_p2ps()
285+
fresh_p2p = p2p_connection(mn1.get_node(self))
286+
fresh_id = get_p2p_id(mn1.get_node(self))
287+
mnauth(mn1.get_node(self), fresh_id, fake_mnauth_1[0], fake_mnauth_1[1])
288+
force_request_expire()
289+
assert mn1.get_node(self).quorum("getdata", fresh_id, 100, quorum_hash, 0x03, mn1.proTxHash)
290+
fresh_p2p.wait_for_qmessage("qgetdata")
291+
fresh_p2p.send_message(bad_qdata)
292+
self.wait_until(lambda: not fresh_p2p.is_connected, timeout=10)
293+
294+
# - Invalid verification vector size
295+
qdata_invalid_vvec = copy.deepcopy(qdata_valid)
234296
qdata_invalid_vvec.quorum_vvec.pop()
235-
p2p_mn1.send_message(qdata_invalid_vvec)
236-
wait_for_banscore(mn1.get_node(self), id_p2p_mn1, 40)
237-
# - Invalid contributions
238-
force_request_expire()
239-
assert mn1.get_node(self).quorum("getdata", id_p2p_mn1, 100, quorum_hash, 0x03, mn1.proTxHash)
240-
p2p_mn1.wait_for_qmessage("qgetdata")
241-
qdata_invalid_contribution = qdata_valid
297+
send_bad_qdata_expect_disconnect(qdata_invalid_vvec)
298+
# - Undersized contributions
299+
qdata_invalid_contribution = copy.deepcopy(qdata_valid)
242300
qdata_invalid_contribution.enc_contributions.pop()
243-
p2p_mn1.send_message(qdata_invalid_contribution)
244-
wait_for_banscore(mn1.get_node(self), id_p2p_mn1, 50)
301+
send_bad_qdata_expect_disconnect(qdata_invalid_contribution)
302+
# - Oversized contributions
303+
qdata_oversized_contribution = copy.deepcopy(qdata_valid)
304+
qdata_oversized_contribution.enc_contributions.append(qdata_oversized_contribution.enc_contributions[0])
305+
send_bad_qdata_expect_disconnect(qdata_oversized_contribution)
306+
# - Raw vectors with a declared count above the protocol limit but within
307+
# ReadCompactSize's supported range, and no element bytes following. A correctly
308+
# bounded node rejects the count before attempting a BLS element decode and
309+
# disconnects the peer; a post-decode size check would instead hit stream
310+
# underrun and leave the authenticated responder connected.
311+
# Cover both vector fields: the vvec check is first, so an oversized-vvec case
312+
# exercises the ProcessMessage path; the oversized-contribs case must include a
313+
# valid vvec so ProcessMessage reaches ProcessContribQDATA.
314+
send_bad_qdata_expect_disconnect(craft_qdata_with_bad_section(
315+
qdata_valid, bad_vvec=ser_compact_size(1000)))
316+
send_bad_qdata_expect_disconnect(craft_qdata_with_bad_section(
317+
qdata_valid, bad_contribs=ser_compact_size(1000)))
245318
mn1.get_node(self).disconnect_p2ps()
246319
mn2.get_node(self).disconnect_p2ps()
247320

0 commit comments

Comments
 (0)