Skip to content

Commit 2204e00

Browse files
fix: reject commitments with no quorum members and cover genesis end-to-end
GetAllQuorumMembers() can legitimately return an empty member set (disabled LLMQ type, out-of-range quorumIndex, and now a parentless quorum base). CFinalCommitment::VerifySignatureAsync indexed members[0] unconditionally on the is_single_member() branch, so an empty set is an out-of-bounds read. No built-in LLMQ type has size == 1, but regtest -llmqtestparams and devnet -llmqdevnetparams both allow it. Reject an empty member set explicitly instead. Add llmq_commitment_tests/commitment_genesis_quorum_hash_rejected_test, which drives the real consensus entry point CheckLLMQCommitment with a mined commitment naming the genesis block. Verified that removing the IsQuorumTypeEnabled null guard makes this test abort the binary (EXIT=134), which the previous direct-call test did not. Also correct the stale reproduction comment in evo_utils_tests: the nullptr literal there was a compile error pre-fix, not the abort site.
1 parent 9178d6d commit 2204e00

3 files changed

Lines changed: 62 additions & 11 deletions

File tree

src/llmq/commitment.cpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ bool CFinalCommitment::VerifySignatureAsync(const llmq::UtilParameters& util_par
5454
LogPrint(BCLog::LLMQ, "CFinalCommitment::%s members[%s] quorumPublicKey[%s] commitmentHash[%s]\n", __func__,
5555
ss3.str(), quorumPublicKey.ToString(), commitmentHash.ToString());
5656
}
57+
// GetAllQuorumMembers() legitimately returns an empty set (disabled LLMQ type, out-of-range
58+
// quorumIndex, parentless quorum base). A commitment can never be valid without members, and
59+
// the single-member branch below indexes members[0] unconditionally, so reject here.
60+
if (members.empty()) {
61+
LogPrint(BCLog::LLMQ, "CFinalCommitment -- q[%s] no quorum members\n", quorumHash.ToString());
62+
return false;
63+
}
64+
5765
if (llmq_params.is_single_member()) {
5866
LogPrintf("pubkey operator: %s\n", members[0]->pdmnState->pubKeyOperator.Get().ToString());
5967
if (!membersSig.VerifyInsecure(members[0]->pdmnState->pubKeyOperator.Get(), commitmentHash)) {

src/test/evo_utils_tests.cpp

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,18 +71,17 @@ BOOST_FIXTURE_TEST_CASE(utils_IsQuorumTypeEnabled_tests_mainnet, TestingSetup)
7171
}
7272

7373
// Regression: a genesis quorum base has pprev == nullptr.
74-
// Pre-fix that pointer was fed to IsQuorumTypeEnabled's gsl::not_null
75-
// parameter and aborted the process (not catchable by std::exception handlers).
74+
// Pre-fix that pointer was fed to IsQuorumTypeEnabled's gsl::not_null parameter,
75+
// whose converting constructor calls Expects() and so std::terminate()s the
76+
// process. That is [[noreturn]] noexcept, not an exception, so no try/catch in
77+
// the validation or net-processing stack could contain it.
7678
//
77-
// Verified pre-fix failure (unpatched code, debug build, aarch64-apple-darwin):
78-
// ./src/test/test_dash --run_test=evo_utils_tests/genesis_quorum_base_null_pprev_safe
79-
// ERROR: error detected null not_null detected at test/evo_utils_tests.cpp:100:81:test_method
80-
// libc++abi: terminating
81-
// fatal error: ... signal: SIGABRT (application abort requested)
82-
// EXIT=134
83-
// Post-fix: IsQuorumTypeEnabled returns false and GetAllQuorumMembers returns
84-
// empty members instead of aborting. Covers the V019 GetAllQuorumMembers path
85-
// and the V016/U034 sink pattern used by NetDKG.
79+
// Note the two guards below are not equivalent. Passing a literal nullptr was a
80+
// *compile* error pre-fix (not_null(std::nullptr_t) is deleted), so that line
81+
// only pins the relaxed signature. The GetAllQuorumMembers() call is the one
82+
// that reproduced the abort, since the null arrives through a runtime pointer.
83+
// The end-to-end consensus path is covered by
84+
// llmq_commitment_tests/commitment_genesis_quorum_hash_rejected_test.
8685
BOOST_FIXTURE_TEST_CASE(genesis_quorum_base_null_pprev_safe, RegTestingSetup)
8786
{
8887
const CBlockIndex* genesis = WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip());

src/test/llmq_commitment_tests.cpp

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#include <streams.h>
1818
#include <util/check.h>
1919
#include <util/strencodings.h>
20+
#include <validation.h>
2021

2122
#include <boost/test/unit_test.hpp>
2223

@@ -192,6 +193,49 @@ BOOST_FIXTURE_TEST_CASE(commitment_check_undersized_bitset_debug_log_test, RegTe
192193
"unexpected v[0] in clamped log line: " + *it);
193194
}
194195

196+
BOOST_FIXTURE_TEST_CASE(commitment_genesis_quorum_hash_rejected_test, RegTestingSetup)
197+
{
198+
// End-to-end regression: a mined TRANSACTION_QUORUM_COMMITMENT whose
199+
// quorumHash names the genesis block. Genesis has pprev == nullptr, and
200+
// CFinalCommitment::Verify -> GetAllQuorumMembers used to hand that null to
201+
// ChainstateManager::IsQuorumTypeEnabled's gsl::not_null parameter, which
202+
// std::terminate()s the node. This drives the real consensus entry point
203+
// (CheckLLMQCommitment), so the whole chain of guards is exercised: without
204+
// them this test aborts the test binary (SIGABRT) rather than failing.
205+
const CBlockIndex* genesis = WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip());
206+
BOOST_REQUIRE(genesis != nullptr);
207+
BOOST_REQUIRE_EQUAL(genesis->nHeight, 0);
208+
BOOST_REQUIRE(genesis->pprev == nullptr);
209+
210+
CFinalCommitmentTxPayload payload;
211+
payload.nVersion = CFinalCommitmentTxPayload::CURRENT_VERSION;
212+
// CheckLLMQCommitment requires nHeight == m_base_index->nHeight + 1.
213+
payload.nHeight = 1;
214+
payload.commitment = CreateValidCommitment(TEST_PARAMS, genesis->GetBlockHash());
215+
// Genesis is past regtest's V19Height (1) and TEST_PARAMS does not rotate.
216+
payload.commitment.nVersion = CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION;
217+
BOOST_REQUIRE(!payload.commitment.IsNull());
218+
219+
CMutableTransaction mtx;
220+
mtx.nVersion = CTransaction::SPECIAL_VERSION;
221+
mtx.nType = TRANSACTION_QUORUM_COMMITMENT;
222+
SetTxPayload(mtx, payload);
223+
const CTransaction tx{mtx};
224+
225+
const llmq::UtilParameters util_params{*Assert(m_node.dmnman),
226+
*Assert(m_node.llmq_ctx)->qsnapman,
227+
*Assert(m_node.chainman),
228+
genesis};
229+
230+
TxValidationState state;
231+
BOOST_CHECK(!llmq::CheckLLMQCommitment(util_params, tx, state));
232+
BOOST_CHECK(state.IsInvalid());
233+
// Reached Verify(), where the empty member set makes the validMembers bitset
234+
// check reject. Any earlier reject reason would mean the test stopped short
235+
// of the vulnerable code path.
236+
BOOST_CHECK_EQUAL(state.GetRejectReason(), "bad-qc-invalid");
237+
}
238+
195239
BOOST_AUTO_TEST_CASE(commitment_serialization_test)
196240
{
197241
// Test with valid commitment

0 commit comments

Comments
 (0)