Skip to content

fix(net): bound and stop re-sorting getqrinfo base block hashes - #7629

Open
PastaPastaPasta wants to merge 2 commits into
dashpay:developfrom
PastaPastaPasta:claude/qrinfo-bound-base-block-hashes
Open

fix(net): bound and stop re-sorting getqrinfo base block hashes#7629
PastaPastaPasta wants to merge 2 commits into
dashpay:developfrom
PastaPastaPasta:claude/qrinfo-bound-base-block-hashes

Conversation

@PastaPastaPasta

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

CGetQuorumRotationInfo::baseBlockHashes is deserialized with no size limit, and GETQUORUMROTATIONINFO is served to any peer, with no rate limiting, while holding cs_main. Two things compound:

  1. The wire format accepts MAX_PROTOCOL_MESSAGE_LENGTH / sizeof(uint256) = 98304 base block hashes in a single 3 MiB message.
  2. On the non-legacy construction path GetLastBaseBlockHash() sorts the entire base list on every call, and BuildQuorumRotationInfo() calls it once per constructed CSimplifiedMNListDiff. For llmq_60_75 that is up to 3 * signingActiveQuorumCount (96) snapshot bases plus the target cycles and the tip, so roughly 10-30 full sorts per request. Every comparison dereferences a CBlockIndex*, so this is cache-hostile work that scales as k * n log n with n chosen by the requesting peer.

The requesting peer does have to supply genuine hashes — the population loop returns early on the first hash that is not found or is not in the active chain — but on a chain with millions of blocks that costs an attacker nothing, and the peer picks the construction path by advertising its protocol version.

Found while re-verifying an audit finding against develop; there is no open issue.

What was done

Two independent changes, one commit each.

Bound the request. baseBlockHashes now deserializes through LIMITED_VECTOR(..., MAX_BASE_BLOCK_HASHES) with MAX_BASE_BLOCK_HASHES = 4096. LimitedVectorFormatter emits the ordinary vector wire format, so nothing changes for senders — this is a deserialization safety property only. The limit is deliberately generous: a client can only usefully hold the bases a response hands it, and the server appends at most ~101 per response for llmq_60_75, so 4096 is ~40x real usage while cutting the worst case 24x. The number only has to be small enough that walking the list is free.

Stop re-sorting. GetLastBaseBlockHash() no longer sorts; it now documents that its input must already be ordered by height, and takes Span<const CBlockIndex* const> so it cannot mutate the caller's list. The two callers that appended to the list mid-construction now use a new InsertBaseBlockSorted() helper that inserts at std::upper_bound instead of push_back, preserving the ordering invariant at O(n) pointer moves rather than re-establishing it at O(n log n) index dereferences. The initial std::sort in BuildQuorumRotationInfo() is unchanged and still runs for both construction paths.

Output is unchanged. The legacy path never sorted inside GetLastBaseBlockHash() and never appends, so it is untouched. On the non-legacy path the list was already sorted and deduplicated before the first call, and every subsequent append was followed by a re-sort — inserting in position produces the same sequence. Equal heights can only mean the same active-chain block, so ties resolve to the same hash either way.

Not changed, deliberately, since both are judgement calls a reviewer may want to make differently:

  • An oversized baseBlockHashes still lands in the generic ProcessMessages() exception handler, which logs and drops without a misbehaviour score. Several sibling handlers in net_processing.cpp catch locally and attribute the failure to the peer instead. Dropping is the more conservative failure mode if some client turns out to exceed the limit, and the DoS is already neutralised by the bound plus the sort removal.
  • quorum rotationinfo builds a CGetQuorumRotationInfo in memory without a serialization round-trip, so the RPC is not subject to the new limit. It is authenticated, and the sort removal makes a large list cheap there too.

How Has This Been Tested?

Built with --enable-debug --enable-crash-hooks on aarch64-apple-darwin.

Unit tests, src/test/llmq_snapshot_tests.cpp:

  • get_quorum_rotation_info_base_block_hashes_limit_test is new: a request at exactly MAX_BASE_BLOCK_HASHES round-trips, and one past it throws std::ios_base::failure before any element is decoded.
  • get_last_base_block_hash_repeated_base_blocks_test was updated for the new contract — the case that fed deliberately unsorted input to exercise the internal sort is gone, and a case covering the genesis fallback when no base is at or below the target was added.

Functional test, test/functional/feature_llmq_rotation.py: the existing quorum rotationinfo block now also asserts that two base blocks at different heights produce identical output regardless of the order they are requested in, which is exactly the property the removed sort was providing. It additionally asserts that the second base is not inert — it displaces the genesis fallback in mnListDiffAtHMinus3C — so the ordering check cannot quietly become vacuous.

I also model-checked the claim that inserting in position is equivalent to re-sorting after each append, over 20000 random append/query sequences; the two agree at every step.

Breaking Changes

None for any current client. A getqrinfo carrying more than 4096 base block hashes is now dropped instead of served; no honest client has a reason to send one, and Dash Core itself never sends this message. Serialization is byte-identical, so senders are unaffected.

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

CGetQuorumRotationInfo::baseBlockHashes had no size limit, so the wire format accepted MAX_PROTOCOL_MESSAGE_LENGTH / sizeof(uint256) = 98304 entries from an unauthenticated peer in a single 3 MiB message. BuildQuorumRotationInfo() then walks that list once per constructed CSimplifiedMNListDiff while holding cs_main.

Route it through LIMITED_VECTOR with a 4096 cap. A client can only usefully hold the bases a response hands it, and the server appends at most 3 * signingActiveQuorumCount snapshot bases plus the target cycles and the tip (~101 for llmq_60_75) per response, so the limit is roughly 40x real usage. LimitedVectorFormatter emits the ordinary vector wire format, so senders are unaffected.
On the non-legacy construction path GetLastBaseBlockHash() sorted the whole base list on each call, and BuildQuorumRotationInfo() calls it once per constructed CSimplifiedMNListDiff - roughly 10-30 times per request for llmq_60_75. Each comparison dereferences a CBlockIndex*, so the cost was k * n log n cache-hostile work under cs_main with n chosen by the requesting peer.

Make the ordering an input precondition instead: the two mid-construction append sites now insert at std::upper_bound via InsertBaseBlockSorted() rather than push_back, so the list stays sorted at O(n) pointer moves. GetLastBaseBlockHash() drops both the sort and the now-unused use_legacy_construction parameter, and takes Span<const CBlockIndex* const> so it can no longer mutate the caller's list.

Output is unchanged. The legacy path never sorted inside the getter and never appends. On the non-legacy path the list was already sorted and deduplicated before the first call and every append was followed by a re-sort, so inserting in position yields the same sequence; equal heights can only be the same active-chain block, so ties resolve identically.
@thepastaclaw

thepastaclaw commented Aug 20, 2026

Copy link
Copy Markdown

✅ Final review complete — no blockers (commit 282a149)

@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 11ef687b-26e8-4d44-8aca-fea171aa227e

📥 Commits

Reviewing files that changed from the base of the PR and between 5651a05 and 282a149.

📒 Files selected for processing (4)
  • src/llmq/snapshot.cpp
  • src/llmq/snapshot.h
  • src/test/llmq_snapshot_tests.cpp
  • test/functional/feature_llmq_rotation.py

Included review availability: Your plan provides up to 4 included reviews per hour; 1 remains after this review.


Walkthrough

The change limits CGetQuorumRotationInfo::baseBlockHashes serialization to 4096 entries. GetLastBaseBlockHash now accepts sorted constant block-index pointers and uses genesis fallback when needed. Non-legacy snapshot diff construction inserts generated work blocks in height order. Tests cover duplicate handling, fallback behavior, serialization boundaries, and order-independent RPC results.

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

Merge Risk: ⚪ Minimal · up to 282a1

The change bounds request size and removes repeated sorting while preserving the documented output behavior; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers: knst, thepastaclaw

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes both main changes: bounding base-block hashes and removing repeated sorting.
Description check ✅ Passed The description directly explains the security and performance fixes, implementation, testing, and compatibility impact.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ 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 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Final validation — Codex/Sol only (Phase 2 disabled)

The exact-head diff correctly bounds unauthenticated getqrinfo deserialization while retaining the existing wire encoding, and it preserves the sorted base-block invariant without repeated full-list sorts. Call-site and test inspection found no actionable correctness, compatibility, performance, or coverage issues.
Source: reviewers codex-general and codex-dash-core-commit-history (exact backend model IDs were not included in the supplied evidence); final verifier Claude (exact runtime model ID was not exposed). openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and is not reviewer evidence.

Validated zero-blocker Codex/Sol precheck evidence was promoted to final because Phase 2 (Sonnet/Opus) is temporarily disabled. This is Codex/Sol-only final validation, not Codex + Sonnet/Opus coverage.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — dash-core-commit-history (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet/Opus: not run (Phase 2 disabled — temporary Codex/Sol-only final)
  • Secondary pass: disabled (temporary_phase2_sonnet_disable)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants