Skip to content

fix: reject unvalidated LLMQType in QSIGSHARE before quorum lookup - #7516

Open
PastaPastaPasta wants to merge 2 commits into
dashpay:developfrom
PastaPastaPasta:sec/v005
Open

fix: reject unvalidated LLMQType in QSIGSHARE before quorum lookup#7516
PastaPastaPasta wants to merge 2 commits into
dashpay:developfrom
PastaPastaPasta:sec/v005

Conversation

@PastaPastaPasta

@PastaPastaPasta PastaPastaPasta commented Aug 2, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

Consensus::LLMQType is a uint8_t enum serialized verbatim in CSigBase/CSigShare with no range check. Before this change, llmq::NetSigning::ProcessMessage fed each CSigShare from a QSIGSHARE message straight into CSigSharesManager::ProcessMessageSigShare -> CQuorumManager::GetQuorum -> CQuorumBlockProcessor::HasMinedCommitment, which indexed mapHasMinedCommitmentCache[llmqType].

That map is seeded only with the LLMQ types registered for the active chain. operator[] on an unregistered key default-constructs Uint256LruHashMap<bool> with MaxSize = 0, and unordered_lru_cache's constructor runs assert(_maxSize != 0). Since src/util/check.h makes compiling with NDEBUG a hard #error, this assert is live in release builds.

The result is a deterministic abort from a single ~100-byte P2P message. It is reachable by any inbound peer past the version handshake with no masternode authentication and no quorum membership. The victim must be running as a masternode and have SPORK_21_QUORUM_ALL_CONNECTED active, so the practical effect is degraded quorum availability, ChainLocks and InstantSend.

Every sibling handler (QSIGREC, QSIGSESANN, QGETDATA, QFCOMMITMENT) already gated on Params().GetLLMQ(...).has_value(). QSIGSHARE was the gap. QBSIGSHARES and QSIGSHARESINV/QGETSIGSHARES need no gate of their own: they carry no type on the wire and inherit one from a session that QSIGSESANN already validated.

What was done?

  • Reject an unregistered LLMQType in the QSIGSHARE handler before any quorum lookup, and score the sender. This closes the reported vector.
  • Remove the hazard structurally rather than gating each lookup. The LLMQ caches were std::map<LLMQType, unordered_lru_cache<...>> seeded by InitQuorumsCache, so every operator[] on them was an abort waiting for a caller that had not validated the type -- five uses outside the reported path had the same shape. A new PerLlmqTypeCache<Value, Key> (src/llmq/cache.h) owns the map, holds one LRU per registered type, and answers for any other type as a miss with writes dropped. All eight caches are converted (mapHasMinedCommitmentCache, m_qc_hashes_lru, mapQuorumsCache, scanQuorumsCache, mapQuorumMembers, mapIndexedQuorumMembers, indexed_quorums_cache, cleanupQuorumsCache) and InitQuorumsCache is deleted.

Per-type cache capacities, lock scopes, GUARDED_BY annotations and the lazy if (empty()) Init(...) seeding are preserved at every converted site. One deliberate behaviour change: HasMinedCommitment on an unregistered type now falls through to the EvoDB probe and returns false, rather than returning early. That is the same probe any cache miss performs, and the handlers reject unregistered types before reaching it.

How Has This Been Tested?

Two commits: the handler gate on its own (small enough to backport), then the cache refactor with the tests that guard it.

Locally, on macOS/arm64:

  • Full build clean, and the first commit builds standalone on top of develop.
  • src/test/test_dash: 795 test cases, no errors. The new llmq_invalid_type_tests suite covers the PerLlmqTypeCache contract plus HasMinedCommitment, GetQuorum and GetCachedMutableQuorum with unregistered types.
  • feature_llmq_signing.py, feature_llmq_rotation.py, feature_llmq_connections.py, feature_llmq_data_recovery.py, p2p_quorum_data.py: all pass.
  • test/lint/all-lint.py: clean apart from pre-existing cppcheck warnings in files this PR does not touch.

Breaking Changes

None. Only messages carrying an LLMQ type that is not registered for the active chain are affected, and those were never valid.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • 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

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@PastaPastaPasta, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c538eff3-2170-4cf6-a244-0c1d6dfa3801

📥 Commits

Reviewing files that changed from the base of the PR and between dc2d5cf and 45c7343.

📒 Files selected for processing (16)
  • src/Makefile.am
  • src/Makefile.test.include
  • src/llmq/blockprocessor.cpp
  • src/llmq/blockprocessor.h
  • src/llmq/cache.h
  • src/llmq/net_dkg.cpp
  • src/llmq/net_dkg.h
  • src/llmq/net_quorum.cpp
  • src/llmq/net_quorum.h
  • src/llmq/net_signing.cpp
  • src/llmq/quorumsman.cpp
  • src/llmq/quorumsman.h
  • src/llmq/utils.cpp
  • src/llmq/utils.h
  • src/test/llmq_invalid_type_tests.cpp
  • src/test/llmq_utils_tests.cpp

Walkthrough

The change adds PerLlmqTypeCache and migrates LLMQ cache users to its type-aware API. Unregistered types produce safe cache misses and do not create entries. Network processing now rejects invalid types, logs them, and applies peer penalties. New unit and functional tests cover cache, commitment, quorum lookup, and disconnect behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: udjinm6

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.59% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the primary fix: rejecting unvalidated LLMQType values in QSIGSHARE before quorum lookup.
Description check ✅ Passed The description directly explains the vulnerability, cache refactor, behavior changes, and tests included in the pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

thepastaclaw commented Aug 2, 2026

Copy link
Copy Markdown

🔍 Review in progress — actively reviewing now (commit 45c7343)
Stage: Codex precheck starting
ETA: complete ~03:24 UTC (median 19m across 30 recent reviews)
Running 4m · Last checked: 2026-08-03 03:10 UTC

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

This pull request has conflicts, please rebase.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

This pull request has conflicts, please rebase.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Potential PR merge conflicts

This is advisory only. It does not block CI, but it marks PRs that will likely need a rebase depending on merge order.

If this PR merges first

These open PRs will likely need a rebase:

If these PRs merge first

This PR will likely need a rebase:

Consensus::LLMQType is a uint8_t enum serialized verbatim in CSigBase/CSigShare with no range check. ProcessMessage fed each CSigShare from a QSIGSHARE message straight into ProcessMessageSigShare -> GetQuorum -> HasMinedCommitment, which indexed mapHasMinedCommitmentCache[llmqType]. That map holds only the types registered for the active chain, and operator[] on any other key default-constructs an LRU with MaxSize 0, whose constructor asserts -- a deterministic abort from a single ~100-byte message, reachable by any inbound peer past the version handshake.

Every sibling handler (QSIGREC, QSIGSESANN, QGETDATA, QFCOMMITMENT) already gated on Params().GetLLMQ(...).has_value(); QSIGSHARE was the gap. QBSIGSHARES and QSIGSHARESINV/QGETSIGSHARES carry no type on the wire and inherit a session type that QSIGSESANN validated, so they need no gate of their own.
The LLMQ caches were std::map<LLMQType, unordered_lru_cache<...>> seeded by InitQuorumsCache with the registered types only. operator[] on any other key default-constructs an LRU whose MaxSize template argument defaults to 0, and unordered_lru_cache's constructor asserts on that, so every unguarded lookup was an abort waiting for a caller that had not validated the type. The previous commit closes the reported path; six more operator[] uses had the same shape and relied on their callers gating first.

PerLlmqTypeCache owns the map instead: it holds a cache per registered type and answers for every other type as a miss, dropping the writes, so the hazard is unreachable rather than merely unreached. InitQuorumsCache is deleted. Converted: mapHasMinedCommitmentCache, m_qc_hashes_lru, mapQuorumsCache, scanQuorumsCache, mapQuorumMembers, mapIndexedQuorumMembers, indexed_quorums_cache, cleanupQuorumsCache. Per-type capacities, lock scopes, GUARDED_BY annotations and the lazy seeding are preserved at every site.

llmq_invalid_type_tests covers the cache contract and the two lookup paths a wire-supplied type reaches: HasMinedCommitment directly, and GetQuorum via HasQuorum. One behaviour change: HasMinedCommitment on an unregistered type now falls through to the EvoDB probe and returns false rather than short-circuiting; that is the same probe any cache miss performs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants