fix(net): bound and stop re-sorting getqrinfo base block hashes - #7629
fix(net): bound and stop re-sorting getqrinfo base block hashes#7629PastaPastaPasta wants to merge 2 commits into
Conversation
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.
|
✅ Final review complete — no blockers (commit 282a149) |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
Included review availability: Your plan provides up to 4 included reviews per hour; 1 remains after this review. WalkthroughThe change limits Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to 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: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
thepastaclaw
left a comment
There was a problem hiding this comment.
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)
Issue being fixed or feature implemented
CGetQuorumRotationInfo::baseBlockHashesis deserialized with no size limit, andGETQUORUMROTATIONINFOis served to any peer, with no rate limiting, while holdingcs_main. Two things compound:MAX_PROTOCOL_MESSAGE_LENGTH / sizeof(uint256)= 98304 base block hashes in a single 3 MiB message.GetLastBaseBlockHash()sorts the entire base list on every call, andBuildQuorumRotationInfo()calls it once per constructedCSimplifiedMNListDiff. Forllmq_60_75that is up to3 * signingActiveQuorumCount(96) snapshot bases plus the target cycles and the tip, so roughly 10-30 full sorts per request. Every comparison dereferences aCBlockIndex*, so this is cache-hostile work that scales ask * n log nwithnchosen 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.
baseBlockHashesnow deserializes throughLIMITED_VECTOR(..., MAX_BASE_BLOCK_HASHES)withMAX_BASE_BLOCK_HASHES = 4096.LimitedVectorFormatteremits 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 forllmq_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 takesSpan<const CBlockIndex* const>so it cannot mutate the caller's list. The two callers that appended to the list mid-construction now use a newInsertBaseBlockSorted()helper that inserts atstd::upper_boundinstead ofpush_back, preserving the ordering invariant atO(n)pointer moves rather than re-establishing it atO(n log n)index dereferences. The initialstd::sortinBuildQuorumRotationInfo()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:
baseBlockHashesstill lands in the genericProcessMessages()exception handler, which logs and drops without a misbehaviour score. Several sibling handlers innet_processing.cppcatch 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 rotationinfobuilds aCGetQuorumRotationInfoin 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-hookson aarch64-apple-darwin.Unit tests,
src/test/llmq_snapshot_tests.cpp:get_quorum_rotation_info_base_block_hashes_limit_testis new: a request at exactlyMAX_BASE_BLOCK_HASHESround-trips, and one past it throwsstd::ios_base::failurebefore any element is decoded.get_last_base_block_hash_repeated_base_blocks_testwas 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 existingquorum rotationinfoblock 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 inmnListDiffAtHMinus3C— 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
getqrinfocarrying 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: