diff --git a/configure.ac b/configure.ac index ef133f5f8895..0320abaa605d 100644 --- a/configure.ac +++ b/configure.ac @@ -2,7 +2,7 @@ AC_PREREQ([2.69]) dnl Don't forget to push a corresponding tag when updating any of _CLIENT_VERSION_* numbers define(_CLIENT_VERSION_MAJOR, 23) define(_CLIENT_VERSION_MINOR, 1) -define(_CLIENT_VERSION_BUILD, 5) +define(_CLIENT_VERSION_BUILD, 7) define(_CLIENT_VERSION_IS_RELEASE, false) define(_COPYRIGHT_YEAR, 2026) define(_COPYRIGHT_HOLDERS,[The %s developers]) diff --git a/contrib/flatpak/org.dash.dash-core.metainfo.xml b/contrib/flatpak/org.dash.dash-core.metainfo.xml index 486118c24fd2..30227f6b4771 100644 --- a/contrib/flatpak/org.dash.dash-core.metainfo.xml +++ b/contrib/flatpak/org.dash.dash-core.metainfo.xml @@ -21,6 +21,7 @@ + diff --git a/doc/man/dash-cli.1 b/doc/man/dash-cli.1 index 751623230e27..2d5739f408f7 100644 --- a/doc/man/dash-cli.1 +++ b/doc/man/dash-cli.1 @@ -1,7 +1,7 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH DASH-CLI "1" "June 2026" "dash-cli v23.1.5" "User Commands" +.TH DASH-CLI "1" "June 2026" "dash-cli v23.1.7" "User Commands" .SH NAME -dash-cli \- manual page for dash-cli v23.1.5 +dash-cli \- manual page for dash-cli v23.1.7 .SH SYNOPSIS .B dash-cli [\fI\,options\/\fR] \fI\, \/\fR[\fI\,params\/\fR] \fI\,Send command to Dash Core\/\fR @@ -15,7 +15,7 @@ dash-cli \- manual page for dash-cli v23.1.5 .B dash-cli [\fI\,options\/\fR] \fI\,help Get help for a command\/\fR .SH DESCRIPTION -Dash Core RPC client version v23.1.5 +Dash Core RPC client version v23.1.7 .SH OPTIONS .HP \-? diff --git a/doc/man/dash-qt.1 b/doc/man/dash-qt.1 index 5e1531f3af39..30cb48525d9c 100644 --- a/doc/man/dash-qt.1 +++ b/doc/man/dash-qt.1 @@ -1,12 +1,12 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH DASH-QT "1" "June 2026" "dash-qt v23.1.5" "User Commands" +.TH DASH-QT "1" "June 2026" "dash-qt v23.1.7" "User Commands" .SH NAME -dash-qt \- manual page for dash-qt v23.1.5 +dash-qt \- manual page for dash-qt v23.1.7 .SH SYNOPSIS .B dash-qt [\fI\,command-line options\/\fR] [\fI\,URI\/\fR] .SH DESCRIPTION -Dash Core version v23.1.5 +Dash Core version v23.1.7 .PP Optional URI is a Dash address in BIP21 URI format. .SH OPTIONS @@ -1172,7 +1172,7 @@ Set the font weight for bold texts. Possible range 0 to 8 (default: 4) .HP \fB\-font\-weight\-normal\fR .IP -Set the font weight for normal texts. Possible range 0 to 8 (default: 2) +Set the font weight for normal texts. Possible range 0 to 8 (default: 1) .HP \fB\-lang=\fR .IP diff --git a/doc/man/dash-tx.1 b/doc/man/dash-tx.1 index 9e7b735370ad..fa7fc530064b 100644 --- a/doc/man/dash-tx.1 +++ b/doc/man/dash-tx.1 @@ -1,7 +1,7 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH DASH-TX "1" "June 2026" "dash-tx v23.1.5" "User Commands" +.TH DASH-TX "1" "June 2026" "dash-tx v23.1.7" "User Commands" .SH NAME -dash-tx \- manual page for dash-tx v23.1.5 +dash-tx \- manual page for dash-tx v23.1.7 .SH SYNOPSIS .B dash-tx [\fI\,options\/\fR] \fI\, \/\fR[\fI\,commands\/\fR] \fI\,Update hex-encoded dash transaction\/\fR @@ -9,7 +9,7 @@ dash-tx \- manual page for dash-tx v23.1.5 .B dash-tx [\fI\,options\/\fR] \fI\,-create \/\fR[\fI\,commands\/\fR] \fI\,Create hex-encoded dash transaction\/\fR .SH DESCRIPTION -Dash Core dash\-tx utility version v23.1.5 +Dash Core dash\-tx utility version v23.1.7 .SH OPTIONS .HP \-? diff --git a/doc/man/dash-util.1 b/doc/man/dash-util.1 index b65ba3b541bc..f1a1e4ccf654 100644 --- a/doc/man/dash-util.1 +++ b/doc/man/dash-util.1 @@ -1,12 +1,12 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH DASH-UTIL "1" "June 2026" "dash-util v23.1.5" "User Commands" +.TH DASH-UTIL "1" "June 2026" "dash-util v23.1.7" "User Commands" .SH NAME -dash-util \- manual page for dash-util v23.1.5 +dash-util \- manual page for dash-util v23.1.7 .SH SYNOPSIS .B dash-util [\fI\,options\/\fR] [\fI\,commands\/\fR] \fI\,Do stuff\/\fR .SH DESCRIPTION -Dash Core dash\-util utility version v23.1.5 +Dash Core dash\-util utility version v23.1.7 .SH OPTIONS .HP \-? diff --git a/doc/man/dash-wallet.1 b/doc/man/dash-wallet.1 index 73aa81e1701d..b942f301f180 100644 --- a/doc/man/dash-wallet.1 +++ b/doc/man/dash-wallet.1 @@ -1,9 +1,9 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH DASH-WALLET "1" "June 2026" "dash-wallet v23.1.5" "User Commands" +.TH DASH-WALLET "1" "June 2026" "dash-wallet v23.1.7" "User Commands" .SH NAME -dash-wallet \- manual page for dash-wallet v23.1.5 +dash-wallet \- manual page for dash-wallet v23.1.7 .SH DESCRIPTION -Dash Core dash\-wallet version v23.1.5 +Dash Core dash\-wallet version v23.1.7 .PP dash\-wallet is an offline tool for creating and interacting with Dash Core wallet files. By default dash\-wallet will act on wallets in the default mainnet wallet directory in the datadir. diff --git a/doc/man/dashd.1 b/doc/man/dashd.1 index b4903f2bf74f..8312159ffd62 100644 --- a/doc/man/dashd.1 +++ b/doc/man/dashd.1 @@ -1,12 +1,12 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH DASHD "1" "June 2026" "dashd v23.1.5" "User Commands" +.TH DASHD "1" "June 2026" "dashd v23.1.7" "User Commands" .SH NAME -dashd \- manual page for dashd v23.1.5 +dashd \- manual page for dashd v23.1.7 .SH SYNOPSIS .B dashd [\fI\,options\/\fR] \fI\,Start Dash Core\/\fR .SH DESCRIPTION -Dash Core version v23.1.5 +Dash Core version v23.1.7 .SH OPTIONS .HP \-? diff --git a/doc/release-notes.md b/doc/release-notes.md index aeda56d9d27d..5e4e5e7628b3 100644 --- a/doc/release-notes.md +++ b/doc/release-notes.md @@ -1,7 +1,8 @@ -# Dash Core version v23.1.5 +# Dash Core version v23.1.7 -This is a new patch version release, bringing bug fixes. -This release is **optional** for all nodes, although recommended. +This is a new patch version release, bringing security hardening and build fixes +for newer compiler toolchains. +This release is **recommended** for all nodes, and especially for masternodes. Please report bugs using the issue tracker at GitHub: @@ -25,15 +26,34 @@ require a reindex. # Release Notes -## Bug Fixes +## Security -- Corrected the checkpoint hash for height 2487500 (dash#7368). +This release hardens several peer-to-peer message handlers against +denial-of-service from remote peers. These issues do not affect consensus and do +not put funds at risk, but they could be used to crash or degrade nodes - +masternodes in particular - so upgrading is recommended. -## Documentation +- Networking: a peer whose receive buffer filled up could keep the socket-handler + thread spinning at 100% CPU for the duration of the backpressure. The thread now + falls back to its normal poll wait while such peers are paused. +- LLMQ / DKG: pushed DKG messages are now accepted only from verified masternodes, + are bounded in size, and are structurally validated before being retained; + malformed signatures can no longer trigger an assertion failure during batch + signature verification. +- BLS: verifying a DKG contribution share whose verification vector was never + received no longer dereferences a null pointer. +- InstantSend: locks with an oversized input set are now rejected before any + expensive processing, and the queues holding not-yet-verified and + awaiting-transaction locks are bounded to prevent unbounded memory growth. +- Governance: vote-sync requests carrying a bloom filter outside the permitted size + are rejected, preventing a CPU-amplification stall of P2P message processing. -- Updated the v23.1.4 release notes intro and wording (dash#7369). +## Build -# v23.1.5 Change log +- Fixed GCC 16 build failures in warning-enabled builds by tightening header + includes and initializing LevelDB compaction output size. + +# v23.1.7 Change log See detailed [set of changes][set-of-changes]. @@ -41,7 +61,8 @@ See detailed [set of changes][set-of-changes]. Thanks to everyone who directly contributed to this release: -- PastaClaw +- knst +- PastaPastaPasta As well as everyone that submitted issues, reviewed pull requests and helped debug the release candidates. @@ -50,6 +71,7 @@ debug the release candidates. These releases are considered obsolete. Old release notes can be found here: +- [v23.1.5](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.5.md) released Jun/19/2026 - [v23.1.4](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.4.md) released Jun/18/2026 - [v23.1.3](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.3.md) released May/28/2026 - [v23.1.2](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.2.md) released Mar/12/2026 @@ -67,4 +89,4 @@ These releases are considered obsolete. Old release notes can be found here: - [v21.0.0](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-21.0.0.md) released Jul/25/2024 - [v20.1.1](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-20.1.1.md) released April/3/2024 -[set-of-changes]: https://github.com/dashpay/dash/compare/9adc0b16f93d15fe065692cbe77f3950419db0cb...dashpay:v23.1.5 +[set-of-changes]: https://github.com/dashpay/dash/compare/v23.1.5...dashpay:v23.1.7 diff --git a/doc/release-notes/dash/release-notes-23.1.5.md b/doc/release-notes/dash/release-notes-23.1.5.md new file mode 100644 index 000000000000..aeda56d9d27d --- /dev/null +++ b/doc/release-notes/dash/release-notes-23.1.5.md @@ -0,0 +1,70 @@ +# Dash Core version v23.1.5 + +This is a new patch version release, bringing bug fixes. +This release is **optional** for all nodes, although recommended. + +Please report bugs using the issue tracker at GitHub: + + + +# Upgrading and downgrading + +## How to Upgrade + +If you are running an older version, shut it down. Wait until it has completely +shut down (which might take a few minutes for older versions), then run the +installer (on Windows) or just copy over /Applications/Dash-Qt (on Mac) or +dashd/dash-qt (on Linux). + +## Downgrade warning + +### Downgrade to a version < v23.0.0 + +Downgrading to a version older than v23.0.0 is not supported, and will +require a reindex. + +# Release Notes + +## Bug Fixes + +- Corrected the checkpoint hash for height 2487500 (dash#7368). + +## Documentation + +- Updated the v23.1.4 release notes intro and wording (dash#7369). + +# v23.1.5 Change log + +See detailed [set of changes][set-of-changes]. + +# Credits + +Thanks to everyone who directly contributed to this release: + +- PastaClaw + +As well as everyone that submitted issues, reviewed pull requests and helped +debug the release candidates. + +# Older releases + +These releases are considered obsolete. Old release notes can be found here: + +- [v23.1.4](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.4.md) released Jun/18/2026 +- [v23.1.3](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.3.md) released May/28/2026 +- [v23.1.2](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.2.md) released Mar/12/2026 +- [v23.1.0](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.0.md) released Feb/15/2026 +- [v23.0.2](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.0.2.md) released Dec/4/2025 +- [v23.0.0](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.0.0.md) released Nov/10/2025 +- [v22.1.3](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-22.1.3.md) released Jul/15/2025 +- [v22.1.2](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-22.1.2.md) released Apr/15/2025 +- [v22.1.1](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-22.1.1.md) released Feb/17/2025 +- [v22.1.0](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-22.1.0.md) released Feb/10/2025 +- [v22.0.0](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-22.0.0.md) released Dec/12/2024 +- [v21.1.1](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-21.1.1.md) released Oct/22/2024 +- [v21.1.0](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-21.1.0.md) released Aug/8/2024 +- [v21.0.2](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-21.0.2.md) released Aug/1/2024 +- [v21.0.0](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-21.0.0.md) released Jul/25/2024 +- [v20.1.1](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-20.1.1.md) released April/3/2024 + +[set-of-changes]: https://github.com/dashpay/dash/compare/9adc0b16f93d15fe065692cbe77f3950419db0cb...dashpay:v23.1.5 diff --git a/src/Makefile.am b/src/Makefile.am index 1c906aa1e03e..bc7890892383 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -285,6 +285,7 @@ BITCOIN_CORE_H = \ llmq/commitment.h \ llmq/context.h \ llmq/debug.h \ + llmq/dkgmessages.h \ llmq/dkgsession.h \ llmq/dkgsessionhandler.h \ llmq/dkgsessionmgr.h \ diff --git a/src/bls/bls_worker.cpp b/src/bls/bls_worker.cpp index 70e778c62df4..d58c18200c9f 100644 --- a/src/bls/bls_worker.cpp +++ b/src/bls/bls_worker.cpp @@ -708,7 +708,11 @@ std::future CBLSWorker::AsyncVerifyContributionShare(const CBLSId& forId, const BLSVerificationVectorPtr& vvec, const CBLSSecretKey& skContribution) { - if (!forId.IsValid() || !VerifyVerificationVector(*vvec)) { + // vvec may be null when the verification vector for that member was never + // received (e.g. a non-member observer that did not get the member's QCONTRIB). + // Dereferencing it here is a remote-triggerable crash; treat a missing vvec as a + // failed verification, mirroring the null check in VerifyVerificationVectors(). + if (!forId.IsValid() || vvec == nullptr || !VerifyVerificationVector(*vvec)) { auto p = BuildFutureDoneCallback(); p.first(false); return std::move(p.second); diff --git a/src/consensus/consensus.h b/src/consensus/consensus.h index adc95252cf8b..625d67e4621d 100644 --- a/src/consensus/consensus.h +++ b/src/consensus/consensus.h @@ -9,7 +9,7 @@ /** The maximum allowed size for a serialized block, in bytes (network rule) */ static const unsigned int MAX_LEGACY_BLOCK_SIZE = 1000000; static const unsigned int MAX_DIP0001_BLOCK_SIZE = 2000000; -inline unsigned int MaxBlockSize(bool fDIP0001Active = true) +constexpr unsigned int MaxBlockSize(bool fDIP0001Active = true) { return fDIP0001Active ? MAX_DIP0001_BLOCK_SIZE : MAX_LEGACY_BLOCK_SIZE; } diff --git a/src/governance/net_governance.cpp b/src/governance/net_governance.cpp index da164a0d06a5..97e21b9025a8 100644 --- a/src/governance/net_governance.cpp +++ b/src/governance/net_governance.cpp @@ -76,6 +76,15 @@ void NetGovernance::ProcessMessage(CNode& peer, const std::string& msg_type, CDa vRecv >> nProp; vRecv >> filter; + // The per-object vote-sync path tests this peer-supplied filter against every + // cached vote (CBloomFilter::contains() loops nHashFuncs times). An unbounded + // nHashFuncs would force billions of MurmurHash3 evaluations per vote while the + // message-processing mutex is held. Enforce the same bound filterload uses. + if (!filter.IsWithinSizeConstraints()) { + m_peer_manager->PeerMisbehaving(peer.GetId(), 100); + return; + } + LogPrint(BCLog::GOBJECT, "MNGOVERNANCESYNC -- syncing governance objects to our peer %s\n", peer.GetLogString()); if (nProp == uint256()) { // Full sync of all governance objects diff --git a/src/instantsend/instantsend.cpp b/src/instantsend/instantsend.cpp index c743e128c3c0..a7f929c294d2 100644 --- a/src/instantsend/instantsend.cpp +++ b/src/instantsend/instantsend.cpp @@ -45,6 +45,13 @@ void CInstantSendManager::EnqueueInstantSendLock(NodeId from, const uint256& has } LOCK(cs_pendingLocks); + if (pendingInstantSendLocks.size() >= MAX_PENDING_INSTANTSEND_LOCKS) { + // Drop instead of growing the queue without bound. A peer cannot pin + // unbounded memory with unverified islocks; honest islocks are re-relayed. + LogPrint(BCLog::INSTANTSEND, "CInstantSendManager::%s -- pending islock queue full (%d), dropping islock=%s peer=%d\n", + __func__, pendingInstantSendLocks.size(), hash.ToString(), from); + return; + } pendingInstantSendLocks.emplace(hash, instantsend::PendingISLockFromPeer{from, std::move(islock)}); } @@ -110,6 +117,14 @@ void CInstantSendManager::AddPendingISLock(const uint256& hash, const instantsen { // put it in a separate pending map and try again later LOCK(cs_pendingLocks); + if (pendingNoTxInstantSendLocks.size() >= MAX_PENDING_INSTANTSEND_LOCKS) { + // Bound this queue too: a malicious quorum could otherwise mint valid islocks + // for transactions that never arrive, growing it without limit. + // Honest islocks are re-relayed, so dropping under flood is not fatal. + LogPrint(BCLog::INSTANTSEND, "CInstantSendManager::%s -- no-tx pending islock queue full (%d), dropping islock=%s peer=%d\n", + __func__, pendingNoTxInstantSendLocks.size(), hash.ToString(), from); + return; + } pendingNoTxInstantSendLocks.try_emplace(hash, instantsend::PendingISLockFromPeer{from, islock}); } diff --git a/src/instantsend/instantsend.h b/src/instantsend/instantsend.h index fe144f986218..58b669d3a5ec 100644 --- a/src/instantsend/instantsend.h +++ b/src/instantsend/instantsend.h @@ -54,6 +54,14 @@ class CInstantSendManager instantsend::CInstantSendDb db; CSporkManager& spork_manager; + // Hard ceiling on the number of peer-supplied InstantSend locks retained in each of + // the pending queues -- pendingInstantSendLocks (received, not yet BLS-verified) and + // pendingNoTxInstantSendLocks (verified, awaiting the locked transaction). The work + // thread drains continuously, so normal operation keeps only a handful pending; this + // never triggers legitimately, but it bounds memory if a peer floods locks faster than + // they can be processed. + static constexpr size_t MAX_PENDING_INSTANTSEND_LOCKS{1024}; + mutable Mutex cs_pendingLocks; // Incoming and not verified yet Uint256HashMap pendingInstantSendLocks GUARDED_BY(cs_pendingLocks); diff --git a/src/instantsend/lock.cpp b/src/instantsend/lock.cpp index f0f2c1d1508d..f768c033708f 100644 --- a/src/instantsend/lock.cpp +++ b/src/instantsend/lock.cpp @@ -29,7 +29,7 @@ uint256 InstantSendLock::GetRequestId() const */ bool InstantSendLock::TriviallyValid() const { - if (txid.IsNull() || inputs.empty()) { + if (txid.IsNull() || inputs.empty() || inputs.size() > MAX_INPUTS) { return false; } diff --git a/src/instantsend/lock.h b/src/instantsend/lock.h index b0536e8421bc..852fc67bebb5 100644 --- a/src/instantsend/lock.h +++ b/src/instantsend/lock.h @@ -6,6 +6,7 @@ #define BITCOIN_INSTANTSEND_LOCK_H #include +#include #include #include @@ -18,6 +19,15 @@ class COutPoint; namespace instantsend { struct InstantSendLock { static constexpr uint8_t CURRENT_VERSION{1}; + // An islock pins the same outpoints as the locked transaction's inputs, so it can + // never carry more inputs than a consensus-valid transaction. Such a transaction must + // fit in a block (MaxBlockSize()) and each transaction input (CTxIn) serializes to at + // least 41 bytes (COutPoint 36 + scriptSig length 1 + nSequence 4), bounding it at + // MaxBlockSize() / 41 inputs; the islock stores those inputs as 36-byte COutPoints. + // Deriving from MaxBlockSize() keeps this cap in lockstep with any future block-size + // change. The ceiling can never reject a valid islock, but it lets us drop oversized + // locks before the O(n) hashing/dedup work and bounds each retained pending entry. + static constexpr size_t MAX_INPUTS{MaxBlockSize() / 41}; uint8_t nVersion{CURRENT_VERSION}; std::vector inputs; diff --git a/src/instantsend/net_instantsend.cpp b/src/instantsend/net_instantsend.cpp index c25d771a5833..97c383bc7221 100644 --- a/src/instantsend/net_instantsend.cpp +++ b/src/instantsend/net_instantsend.cpp @@ -252,6 +252,15 @@ void NetInstantSend::ProcessMessage(CNode& pfrom, const std::string& msg_type, C vRecv >> *islock; const NodeId from = pfrom.GetId(); + + // Reject oversized locks before any O(n) work (hashing, dedup). A consensus-valid + // transaction -- and therefore a valid islock -- can never exceed MAX_INPUTS inputs, + // so this cannot drop a legitimate lock. + if (islock->inputs.size() > instantsend::InstantSendLock::MAX_INPUTS) { + m_peer_manager->PeerMisbehaving(from, INVALID_ISLOCK_MISBEHAVIOR_SCORE); + return; + } + uint256 hash = ::SerializeHash(*islock); WITH_LOCK(::cs_main, m_peer_manager->PeerEraseObjectRequest(from, CInv{MSG_ISDLOCK, hash})); diff --git a/src/llmq/dkgmessages.h b/src/llmq/dkgmessages.h new file mode 100644 index 000000000000..177b68e5d4d4 --- /dev/null +++ b/src/llmq/dkgmessages.h @@ -0,0 +1,188 @@ +// Copyright (c) 2018-2025 The Dash Core developers +// Distributed under the MIT/X11 software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_LLMQ_DKGMESSAGES_H +#define BITCOIN_LLMQ_DKGMESSAGES_H + +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace llmq { +class CDKGContribution +{ +public: + Consensus::LLMQType llmqType; + uint256 quorumHash; + uint256 proTxHash; + BLSVerificationVectorPtr vvec; + std::shared_ptr> contributions; + CBLSSignature sig; + +public: + template + inline void SerializeWithoutSig(Stream& s) const + { + s << std23::to_underlying(llmqType); + s << quorumHash; + s << proTxHash; + s << *vvec; + s << *contributions; + } + template + inline void Serialize(Stream& s) const + { + SerializeWithoutSig(s); + s << sig; + } + template + inline void Unserialize(Stream& s) + { + std::vector tmp1; + CBLSIESMultiRecipientObjects tmp2; + + s >> llmqType; + s >> quorumHash; + s >> proTxHash; + s >> tmp1; + s >> tmp2; + s >> sig; + + vvec = std::make_shared>(std::move(tmp1)); + contributions = std::make_shared>(std::move(tmp2)); + } + + [[nodiscard]] uint256 GetSignHash() const + { + CHashWriter hw(SER_GETHASH, 0); + SerializeWithoutSig(hw); + hw << CBLSSignature(); + return hw.GetHash(); + } +}; + +class CDKGComplaint +{ +public: + Consensus::LLMQType llmqType{Consensus::LLMQType::LLMQ_NONE}; + uint256 quorumHash; + uint256 proTxHash; + std::vector badMembers; + std::vector complainForMembers; + CBLSSignature sig; + +public: + CDKGComplaint() = default; + explicit CDKGComplaint(const Consensus::LLMQParams& params) : + badMembers((size_t)params.size), complainForMembers((size_t)params.size) {}; + + SERIALIZE_METHODS(CDKGComplaint, obj) + { + READWRITE( + obj.llmqType, + obj.quorumHash, + obj.proTxHash, + DYNBITSET(obj.badMembers), + DYNBITSET(obj.complainForMembers), + obj.sig + ); + } + + [[nodiscard]] uint256 GetSignHash() const + { + CDKGComplaint tmp(*this); + tmp.sig = CBLSSignature(); + return ::SerializeHash(tmp); + } +}; + +class CDKGJustification +{ +public: + Consensus::LLMQType llmqType; + uint256 quorumHash; + uint256 proTxHash; + struct Contribution { + uint32_t index; + CBLSSecretKey key; + SERIALIZE_METHODS(Contribution, obj) + { + READWRITE(obj.index, obj.key); + } + }; + std::vector contributions; + CBLSSignature sig; + +public: + SERIALIZE_METHODS(CDKGJustification, obj) + { + READWRITE(obj.llmqType, obj.quorumHash, obj.proTxHash, obj.contributions, obj.sig); + } + + [[nodiscard]] uint256 GetSignHash() const + { + CDKGJustification tmp(*this); + tmp.sig = CBLSSignature(); + return ::SerializeHash(tmp); + } +}; + +// each member commits to a single set of valid members with this message +// then each node aggregate all received premature commitments +// into a single CFinalCommitment, which is only valid if +// enough (>=minSize) premature commitments were aggregated +class CDKGPrematureCommitment +{ +public: + Consensus::LLMQType llmqType{Consensus::LLMQType::LLMQ_NONE}; + uint256 quorumHash; + uint256 proTxHash; + std::vector validMembers; + + CBLSPublicKey quorumPublicKey; + uint256 quorumVvecHash; + + CBLSSignature quorumSig; // threshold sig share of quorumHash+validMembers+pubKeyHash+vvecHash + CBLSSignature sig; // single member sig of quorumHash+validMembers+pubKeyHash+vvecHash + +public: + CDKGPrematureCommitment() = default; + explicit CDKGPrematureCommitment(const Consensus::LLMQParams& params) : + validMembers((size_t)params.size) {}; + + [[nodiscard]] int CountValidMembers() const + { + return int(std::count(validMembers.begin(), validMembers.end(), true)); + } + +public: + SERIALIZE_METHODS(CDKGPrematureCommitment, obj) + { + READWRITE( + obj.llmqType, + obj.quorumHash, + obj.proTxHash, + DYNBITSET(obj.validMembers), + obj.quorumPublicKey, + obj.quorumVvecHash, + obj.quorumSig, + obj.sig + ); + } + + [[nodiscard]] uint256 GetSignHash() const + { + return BuildCommitmentHash(llmqType, quorumHash, validMembers, quorumPublicKey, quorumVvecHash); + } +}; +} // namespace llmq + +#endif // BITCOIN_LLMQ_DKGMESSAGES_H diff --git a/src/llmq/dkgsession.cpp b/src/llmq/dkgsession.cpp index 8e050afac5d8..1d39f2752ea0 100644 --- a/src/llmq/dkgsession.cpp +++ b/src/llmq/dkgsession.cpp @@ -69,11 +69,6 @@ CDKGMember::CDKGMember(const CDeterministicMNCPtr& _dmn, size_t _idx) : { } -uint256 CDKGPrematureCommitment::GetSignHash() const -{ - return BuildCommitmentHash(llmqType, quorumHash, validMembers, quorumPublicKey, quorumVvecHash); -} - CDKGSession::CDKGSession(CBLSWorker& _blsWorker, CDeterministicMNManager& dmnman, CDKGDebugManager& _dkgDebugManager, CDKGSessionManager& _dkgManager, CQuorumSnapshotManager& qsnapman, const ChainstateManager& chainman, const CBlockIndex* pQuorumBaseBlockIndex, @@ -178,6 +173,15 @@ bool CDKGSession::PreVerifyMessage(const CDKGContribution& qc, bool& retBan) con return false; } + // Reject a structurally-invalid (e.g. all-zero) signature here. This is a cheap + // validity check, not the batched signature verification; it ensures the message + // never reaches CBLSSignature::AggregateInsecure(), which asserts validity. + if (!qc.sig.IsValid()) { + logger.Batch("invalid contribution signature"); + retBan = true; + return false; + } + if (qc.contributions->blobs.size() != members.size()) { logger.Batch("invalid contributions count"); retBan = true; @@ -279,6 +283,14 @@ bool CDKGSession::PreVerifyMessage(const CDKGComplaint& qc, bool& retBan) const return false; } + // Cheap validity check (not the batched signature verification): reject a + // structurally-invalid signature before it can reach AggregateInsecure(). + if (!qc.sig.IsValid()) { + logger.Batch("invalid complaint signature"); + retBan = true; + return false; + } + if (qc.badMembers.size() != (size_t)params.size) { logger.Batch("invalid badMembers bitset size"); retBan = true; @@ -360,6 +372,14 @@ bool CDKGSession::PreVerifyMessage(const CDKGJustification& qj, bool& retBan) co return false; } + // Cheap validity check (not the batched signature verification): reject a + // structurally-invalid signature before it can reach AggregateInsecure(). + if (!qj.sig.IsValid()) { + logger.Batch("invalid justification signature"); + retBan = true; + return false; + } + if (qj.contributions.empty()) { logger.Batch("justification with no contributions"); retBan = true; diff --git a/src/llmq/dkgsession.h b/src/llmq/dkgsession.h index ae151abb07d1..91b77abf1cf2 100644 --- a/src/llmq/dkgsession.h +++ b/src/llmq/dkgsession.h @@ -5,7 +5,7 @@ #ifndef BITCOIN_LLMQ_DKGSESSION_H #define BITCOIN_LLMQ_DKGSESSION_H -#include +#include #include #include @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -35,170 +36,6 @@ class CQuorumSnapshotManager; } // namespace llmq namespace llmq { -class CDKGContribution -{ -public: - Consensus::LLMQType llmqType; - uint256 quorumHash; - uint256 proTxHash; - BLSVerificationVectorPtr vvec; - std::shared_ptr> contributions; - CBLSSignature sig; - -public: - template - inline void SerializeWithoutSig(Stream& s) const - { - s << std23::to_underlying(llmqType); - s << quorumHash; - s << proTxHash; - s << *vvec; - s << *contributions; - } - template - inline void Serialize(Stream& s) const - { - SerializeWithoutSig(s); - s << sig; - } - template - inline void Unserialize(Stream& s) - { - std::vector tmp1; - CBLSIESMultiRecipientObjects tmp2; - - s >> llmqType; - s >> quorumHash; - s >> proTxHash; - s >> tmp1; - s >> tmp2; - s >> sig; - - vvec = std::make_shared>(std::move(tmp1)); - contributions = std::make_shared>(std::move(tmp2)); - } - - [[nodiscard]] uint256 GetSignHash() const - { - CHashWriter hw(SER_GETHASH, 0); - SerializeWithoutSig(hw); - hw << CBLSSignature(); - return hw.GetHash(); - } -}; - -class CDKGComplaint -{ -public: - Consensus::LLMQType llmqType{Consensus::LLMQType::LLMQ_NONE}; - uint256 quorumHash; - uint256 proTxHash; - std::vector badMembers; - std::vector complainForMembers; - CBLSSignature sig; - -public: - CDKGComplaint() = default; - explicit CDKGComplaint(const Consensus::LLMQParams& params) : - badMembers((size_t)params.size), complainForMembers((size_t)params.size) {}; - - SERIALIZE_METHODS(CDKGComplaint, obj) - { - READWRITE( - obj.llmqType, - obj.quorumHash, - obj.proTxHash, - DYNBITSET(obj.badMembers), - DYNBITSET(obj.complainForMembers), - obj.sig - ); - } - - [[nodiscard]] uint256 GetSignHash() const - { - CDKGComplaint tmp(*this); - tmp.sig = CBLSSignature(); - return ::SerializeHash(tmp); - } -}; - -class CDKGJustification -{ -public: - Consensus::LLMQType llmqType; - uint256 quorumHash; - uint256 proTxHash; - struct Contribution { - uint32_t index; - CBLSSecretKey key; - SERIALIZE_METHODS(Contribution, obj) - { - READWRITE(obj.index, obj.key); - } - }; - std::vector contributions; - CBLSSignature sig; - -public: - SERIALIZE_METHODS(CDKGJustification, obj) - { - READWRITE(obj.llmqType, obj.quorumHash, obj.proTxHash, obj.contributions, obj.sig); - } - - [[nodiscard]] uint256 GetSignHash() const - { - CDKGJustification tmp(*this); - tmp.sig = CBLSSignature(); - return ::SerializeHash(tmp); - } -}; - -// each member commits to a single set of valid members with this message -// then each node aggregate all received premature commitments -// into a single CFinalCommitment, which is only valid if -// enough (>=minSize) premature commitments were aggregated -class CDKGPrematureCommitment -{ -public: - Consensus::LLMQType llmqType{Consensus::LLMQType::LLMQ_NONE}; - uint256 quorumHash; - uint256 proTxHash; - std::vector validMembers; - - CBLSPublicKey quorumPublicKey; - uint256 quorumVvecHash; - - CBLSSignature quorumSig; // threshold sig share of quorumHash+validMembers+pubKeyHash+vvecHash - CBLSSignature sig; // single member sig of quorumHash+validMembers+pubKeyHash+vvecHash - -public: - CDKGPrematureCommitment() = default; - explicit CDKGPrematureCommitment(const Consensus::LLMQParams& params) : - validMembers((size_t)params.size) {}; - - [[nodiscard]] int CountValidMembers() const - { - return int(std::count(validMembers.begin(), validMembers.end(), true)); - } - -public: - SERIALIZE_METHODS(CDKGPrematureCommitment, obj) - { - READWRITE( - obj.llmqType, - obj.quorumHash, - obj.proTxHash, - DYNBITSET(obj.validMembers), - obj.quorumPublicKey, - obj.quorumVvecHash, - obj.quorumSig, - obj.sig - ); - } - - [[nodiscard]] uint256 GetSignHash() const; -}; - class CDKGMember { public: diff --git a/src/llmq/dkgsessionmgr.cpp b/src/llmq/dkgsessionmgr.cpp index 400854a8a0ad..6cb65165e795 100644 --- a/src/llmq/dkgsessionmgr.cpp +++ b/src/llmq/dkgsessionmgr.cpp @@ -9,7 +9,6 @@ #include #include #include -#include #include #include #include diff --git a/src/llmq/net_dkg.cpp b/src/llmq/net_dkg.cpp index b1f8ae91775d..627761b454bb 100644 --- a/src/llmq/net_dkg.cpp +++ b/src/llmq/net_dkg.cpp @@ -30,6 +30,86 @@ namespace llmq { namespace { +// Upper bound on the serialized size of a well-formed DKG message of the given +// type for the given quorum params. Used to reject oversized payloads at intake +// before any deserialization or retention, which closes the low-cost memory +// amplification window (a legitimate message is bounded by quorum params, far +// below the 3 MiB transport cap). Generous slack is added and the result is +// clamped to a hard ceiling so a future params change can never silently re-open +// the full transport window. +size_t MaxDKGMessageSize(std::string_view msg_type, const Consensus::LLMQParams& params) +{ + constexpr size_t COMPACT = 5; // max CompactSize for any realistic count + constexpr size_t PREFIX = 1 + 32 + 32; // llmqType + quorumHash + proTxHash + constexpr size_t PUBKEY = BLS_CURVE_PUBKEY_SIZE; // 48 + constexpr size_t SIG = BLS_CURVE_SIG_SIZE; // 96 + constexpr size_t SECKEY = BLS_CURVE_SECKEY_SIZE; // 32 + constexpr size_t BLOB = COMPACT + 128; // encrypted seckey blob, generous + constexpr size_t SLACK = 1024; + constexpr size_t HARD_CEILING = size_t{1} << 20; // 1 MiB + + const size_t size = params.size > 0 ? static_cast(params.size) : 0; + const size_t threshold = params.threshold > 0 ? static_cast(params.threshold) : 0; + + size_t cap = 0; + if (msg_type == NetMsgType::QCONTRIB) { + // llmqType/quorumHash/proTxHash + vvec + contributions(IES) + sig + cap = PREFIX + (COMPACT + threshold * PUBKEY) + (PUBKEY + 32 + COMPACT + size * BLOB) + SIG; + } else if (msg_type == NetMsgType::QJUSTIFICATION) { + // ... + contributions(index u32 + seckey) + sig + cap = PREFIX + (COMPACT + size * (4 + SECKEY)) + SIG; + } else if (msg_type == NetMsgType::QCOMPLAINT) { + // ... + 2 dynamic bitsets (badMembers, complainForMembers) + sig + cap = PREFIX + 2 * (COMPACT + (size + 7) / 8) + SIG; + } else if (msg_type == NetMsgType::QPCOMMITMENT) { + // ... + validMembers bitset + quorumPublicKey + quorumVvecHash + quorumSig + sig + cap = PREFIX + (COMPACT + (size + 7) / 8) + PUBKEY + 32 + 2 * SIG; + } else { + return HARD_CEILING; + } + cap += SLACK; + return cap < HARD_CEILING ? cap : HARD_CEILING; +} + +// Cheap, param-only structural validation of a pushed DKG message, run at intake +// before retention. Deserializes a COPY of the payload (leaving the caller's bytes +// intact for the pending queue and its inventory hash) and checks only safe upper +// bounds derived from quorum params: no member-list lookup and no signature +// verification, which remain on the DKG worker thread. Deserializing the copy does +// decompress the BLS points carried in the payload, but that work is bounded by +// the size cap applied just before this check. Rejects malformed or clearly +// oversized payloads before retention. +bool CheckDKGMessageStructure(std::string_view msg_type, const CDataStream& vRecv, const Consensus::LLMQParams& params) +{ + const size_t size = params.size > 0 ? static_cast(params.size) : 0; + const size_t threshold = params.threshold > 0 ? static_cast(params.threshold) : 0; + try { + CDataStream s(vRecv); // copy; deserialization does not advance the caller's stream + if (msg_type == NetMsgType::QCONTRIB) { + CDKGContribution qc; + s >> qc; + return qc.vvec != nullptr && qc.vvec->size() == threshold && + qc.contributions != nullptr && qc.contributions->blobs.size() <= size; + } else if (msg_type == NetMsgType::QCOMPLAINT) { + CDKGComplaint qc; + s >> qc; + return qc.badMembers.size() == qc.complainForMembers.size() && + qc.badMembers.size() <= size; + } else if (msg_type == NetMsgType::QJUSTIFICATION) { + CDKGJustification qj; + s >> qj; + return qj.contributions.size() <= size; + } else if (msg_type == NetMsgType::QPCOMMITMENT) { + CDKGPrematureCommitment qc; + s >> qc; + return qc.validMembers.size() <= size; + } + return false; + } catch (const std::exception&) { + return false; + } +} + // returns a set of NodeIds which sent invalid messages template std::unordered_set BatchVerifyMessageSigs(CDKGSession& session, @@ -57,6 +137,18 @@ std::unordered_set BatchVerifyMessageSigs(CDKGSession& session, continue; } + // An invalid signature must never reach AggregateInsecure(), which asserts + // that both operands are valid. Mark the sender bad and skip it instead of + // aggregating it. This guard is mandatory: it covers every + // message type the batch verifier is instantiated for, including those whose + // per-message PreVerifyMessage does not (yet) reject invalid signatures. + // Note: 'first' below tracks the first *accumulated* (valid) signature, not + // the first *examined* message, so skipping leading invalid sigs is safe. + if (!msg->sig.IsValid()) { + ret.emplace(nodeId); + continue; + } + if (first) { aggSig = msg->sig; } else { @@ -80,6 +172,12 @@ std::unordered_set BatchVerifyMessageSigs(CDKGSession& session, messageHashes.emplace_back(msgHash); } if (!revertToSingleVerification) { + if (pubKeys.empty()) { + // Every message had an unknown member or invalid signature; all such + // senders are already in ret. VerifyInsecureAggregated() asserts that + // the pubkey/hash spans are non-empty, so bail out here. + return ret; + } if (aggSig.VerifyInsecureAggregated(pubKeys, messageHashes)) { // all good return ret; @@ -105,6 +203,13 @@ std::unordered_set BatchVerifyMessageSigs(CDKGSession& session, } auto member = session.GetMember(msg->proTxHash); + if (member == nullptr || !msg->sig.IsValid()) { + // Examined messages with these properties are already in ret, but the + // early break on a duplicate hash above can leave some unexamined. + // Stay defensive: never dereference a null member or verify an invalid sig. + ret.emplace(nodeId); + continue; + } bool valid = msg->sig.VerifyInsecure(member->dmn->pdmnState->pubKeyOperator.Get(), msg->GetSignHash()); if (!valid) { ret.emplace(nodeId); @@ -279,6 +384,15 @@ void NetDKG::ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataStre return; } + // Pushed DKG messages (QCONTRIB/QCOMPLAINT/QJUSTIFICATION/QPCOMMITMENT) retain + // attacker-controlled payloads, so they must originate from an MNAuth-verified + // masternode. qwatch is unauthenticated (any peer can set it via QWATCH) and is + // only meaningful for pull/observation paths; it must not bypass this gate. + if (pfrom.GetVerifiedProRegTxHash().IsNull()) { + m_peer_manager->PeerMisbehaving(pfrom.GetId(), 10, "DKG message from non-verified peer"); + return; + } + if (vRecv.empty()) { m_peer_manager->PeerMisbehaving(pfrom.GetId(), 100); return; @@ -333,6 +447,21 @@ void NetDKG::ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataStre } } + // Reject oversized payloads before any deserialization or retention. A + // well-formed DKG message is bounded by quorum params; anything larger is an + // amplification attempt against the per-peer pending queue. + if (vRecv.size() > MaxDKGMessageSize(msg_type, llmq_params)) { + m_peer_manager->PeerMisbehaving(pfrom.GetId(), 100, "oversized DKG message"); + return; + } + + // Cheap structural pre-validation before retention. Validates a copy so the + // original bytes (and their inventory hash) are preserved for the worker. + if (!CheckDKGMessageStructure(msg_type, vRecv, llmq_params)) { + m_peer_manager->PeerMisbehaving(pfrom.GetId(), 100, "malformed DKG message"); + return; + } + int inv_type = 0; if (msg_type == NetMsgType::QCONTRIB) inv_type = MSG_QUORUM_CONTRIB; diff --git a/src/net.cpp b/src/net.cpp index d63da2308e31..4b8d3420a94d 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -2373,6 +2373,16 @@ Sock::EventsPerSock CConnman::GenerateWaitSockets(Span nodes) return events_per_sock; } +bool HasUnpausedReceivableNode(const std::unordered_map& receivable_nodes) +{ + for (const auto& entry : receivable_nodes) { + if (!entry.second->fPauseRecv) { + return true; + } + } + return false; +} + void CConnman::SocketHandler(CMasternodeSync& mn_sync) { AssertLockNotHeld(m_total_bytes_sent_mutex); @@ -2383,7 +2393,11 @@ void CConnman::SocketHandler(CMasternodeSync& mn_sync) // Check if we have work to do and thus should avoid waiting for events READ_LOCK(m_nodes_mutex); // We acquire this to avoid the pointers stored in mapSendableNodes and mapReceivableNodes being invalidated by ThreadSocketHandler LOCK(cs_sendable_receivable_nodes); - if (!mapReceivableNodes.empty()) { + // A receive-paused node lingers here with its readable flag set but is never drained, so + // it must not count as work; otherwise this polls with a zero timeout every iteration and + // ThreadSocketHandler busy-loops at 100% CPU for the whole (remotely triggerable) pause + // window. See HasUnpausedReceivableNode(). + if (HasUnpausedReceivableNode(mapReceivableNodes)) { return true; } for (const auto& p : mapSendableNodes) { diff --git a/src/net.h b/src/net.h index 21119f8675bb..0cf55621919c 100644 --- a/src/net.h +++ b/src/net.h @@ -49,6 +49,7 @@ #include #include #include +#include #include #include @@ -1180,6 +1181,16 @@ class NetEventsInterface ~NetEventsInterface() = default; }; +/** + * Returns true if any node in `receivable_nodes` can currently be drained: it has buffered data + * to read (so it is in the receivable set) and is not receive-paused. A receive-paused node + * lingers in the set with its readable flag set but is skipped by the receive path, so it must + * NOT be treated as actionable work -- otherwise CConnman::SocketHandler() polls with a zero + * timeout on every iteration and ThreadSocketHandler busy-loops at 100% CPU for the whole, + * remotely triggerable, pause window. + */ +bool HasUnpausedReceivableNode(const std::unordered_map& receivable_nodes); + class CConnman { friend class CNode; diff --git a/src/test/bls_tests.cpp b/src/test/bls_tests.cpp index d810c01979f5..1efe18644729 100644 --- a/src/test/bls_tests.cpp +++ b/src/test/bls_tests.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -480,6 +481,41 @@ BOOST_AUTO_TEST_CASE(bls_threshold_signature_tests) FuncThresholdSignature(false); } +// Regression test: a DKG justification path can reach +// CBLSWorker::AsyncVerifyContributionShare() with a null verification vector when +// the corresponding member's contribution was never received (e.g. on a non-member +// observer). The singular overload dereferenced *vvec with no null check and aborted +// the process; it must instead treat a missing vvec as a failed verification, like +// the plural VerifyVerificationVectors() path. A *valid* id is required so the guard +// does not short-circuit before reaching the (previously) unguarded dereference. +void FuncVerifyContributionShareNullVvec(const bool legacy_scheme) +{ + bls::bls_legacy_scheme.store(legacy_scheme); + + CBLSWorker worker; + worker.Start(1); + + const CBLSId id{uint256::ONE}; + BOOST_REQUIRE(id.IsValid()); + + CBLSSecretKey sk; + sk.MakeNewKey(); + + const BLSVerificationVectorPtr null_vvec; // default-constructed null shared_ptr + BOOST_REQUIRE(null_vvec == nullptr); + + // Must return false (verification failed) instead of dereferencing the null vvec. + BOOST_CHECK(worker.AsyncVerifyContributionShare(id, null_vvec, sk).get() == false); + + worker.Stop(); +} + +BOOST_AUTO_TEST_CASE(bls_verify_contribution_share_null_vvec_tests) +{ + FuncVerifyContributionShareNullVvec(true); + FuncVerifyContributionShareNullVvec(false); +} + // A dummy BLS object that satisfies the minimal interface expected by CBLSLazyWrapper. class DummyBLS { diff --git a/src/test/evo_islock_tests.cpp b/src/test/evo_islock_tests.cpp index 4d40b139af58..2e681b906236 100644 --- a/src/test/evo_islock_tests.cpp +++ b/src/test/evo_islock_tests.cpp @@ -208,4 +208,28 @@ BOOST_AUTO_TEST_CASE(geninputlockrequestid_edge_cases) BOOST_CHECK(nullRequestId != maxIndexRequestId); } +// Regression test for the islock input cap: an oversized input vector must be +// rejected by TriviallyValid(), while the cap is derived so it can never reject a valid lock. +BOOST_AUTO_TEST_CASE(trivially_valid_input_cap) +{ + // A lock with a non-null txid and a few unique inputs is trivially valid. + instantsend::InstantSendLock islock; + islock.txid = uint256::ONE; + islock.inputs = {COutPoint(uint256::ONE, 0), COutPoint(uint256::ONE, 1)}; + BOOST_CHECK(islock.TriviallyValid()); + + // MAX_INPUTS is derived from MaxBlockSize(): a consensus-valid transaction must fit in a + // block and each input is >=41 bytes on the wire, so it can never carry more than + // MaxBlockSize() / 41 inputs. The cap thus cannot reject a legitimate islock, and it + // tracks any future block-size change. + BOOST_CHECK_EQUAL(instantsend::InstantSendLock::MAX_INPUTS, MaxBlockSize() / 41); + + // A lock carrying more than MAX_INPUTS inputs is rejected up front, before any O(n) + // hashing/dedup work, so a peer cannot pin an oversized input vector. + instantsend::InstantSendLock oversized; + oversized.txid = uint256::ONE; + oversized.inputs.assign(instantsend::InstantSendLock::MAX_INPUTS + 1, COutPoint(uint256::ONE, 0)); + BOOST_CHECK(!oversized.TriviallyValid()); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/src/test/net_tests.cpp b/src/test/net_tests.cpp index 7633d4cab66c..d0ecb8639d51 100644 --- a/src/test/net_tests.cpp +++ b/src/test/net_tests.cpp @@ -1665,4 +1665,41 @@ BOOST_AUTO_TEST_CASE(v2_short_id_version_negotiation) } } +//! Regression test for the ThreadSocketHandler busy-loop on receive-paused peers. +//! +//! CConnman::SocketHandler() polls sockets with a zero timeout (its "only_poll" fast path) +//! whenever a node has actionable receive work, so that sockets with buffered data keep getting +//! drained. A receive-paused peer (fPauseRecv) is skipped by the receive path yet lingers in the +//! receivable set with its readable flag set, so it must not be counted as actionable work -- +//! otherwise the loop would spin at 100% CPU for the whole pause window. HasUnpausedReceivableNode() +//! encodes that rule; verify it ignores paused nodes but still reports genuinely drainable ones. +BOOST_AUTO_TEST_CASE(socket_handler_skips_receive_paused_nodes) +{ + auto make_node = [](NodeId id) { + return std::make_unique( + id, /*sock=*/nullptr, + CAddress{CService{}, NODE_NONE}, /*nKeyedNetGroupIn=*/0, /*nLocalHostNonceIn=*/0, + CAddress{}, /*addrNameIn=*/std::string{}, + ConnectionType::INBOUND, /*inbound_onion=*/false); + }; + auto paused = make_node(1); + auto active = make_node(2); + paused->fPauseRecv = true; + active->fPauseRecv = false; + + std::unordered_map receivable; + // No receivable nodes -> nothing to drain. + BOOST_CHECK(!HasUnpausedReceivableNode(receivable)); + // A lone receive-paused node must NOT count as work (this is the bug being guarded against). + receivable.emplace(paused->GetId(), paused.get()); + BOOST_CHECK(!HasUnpausedReceivableNode(receivable)); + // A node that can actually be drained does count as work. + receivable.emplace(active->GetId(), active.get()); + BOOST_CHECK(HasUnpausedReceivableNode(receivable)); + // Once the paused node is unpaused (drained by the message handler), it counts again. + receivable.erase(active->GetId()); + paused->fPauseRecv = false; + BOOST_CHECK(HasUnpausedReceivableNode(receivable)); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/test/functional/feature_llmq_dkg_intake.py b/test/functional/feature_llmq_dkg_intake.py new file mode 100755 index 000000000000..168c94eead38 --- /dev/null +++ b/test/functional/feature_llmq_dkg_intake.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +""" +feature_llmq_dkg_intake.py + +Adversarial P2P tests for DKG message-intake hardening: + - pushed DKG messages (qcontrib/qcomplaint/qjustify/qpcommit) from a peer that is + not MNAuth-verified are rejected before retention. + - oversized DKG payloads are rejected (before deserialization / retention) even + from a verified peer. + - structural pre-validation: malformed DKG payloads (valid quorum prefix, garbage + body) are rejected before retention even from a verified peer. + +The node must not crash; the sending peer must be scored (Misbehaving). +""" + +from test_framework.messages import ser_uint256 +from test_framework.p2p import P2PInterface +from test_framework.test_framework import DashTestFramework +from test_framework.util import wait_until_helper + +LLMQ_TEST = 100 + +# A masternode protx/operator-pubkey pair accepted by the regtest-only `mnauth` +# debug RPC, used to mark a P2P connection as MNAuth-verified without BLS signing. +FAKE_PROTX = "cecf37bf0ec05d2d22cb8227f88074bb882b94cd2081ba318a5a444b1b15b9fd" +FAKE_PUBKEY = "8e7afdb849e5e2a085b035b62e21c0940c753f2d4501325743894c37162f287bccaffbedd60c36581dabbf127a22e43f" + +DKG_PUSH_TYPES = [b"qcontrib", b"qcomplaint", b"qjustify", b"qpcommit"] + + +class msg_dkg_raw: + """A DKG push message carrying an arbitrary raw payload (for adversarial intake tests).""" + __slots__ = ("msgtype", "payload") + + def __init__(self, msgtype, payload=b""): + self.msgtype = msgtype + self.payload = payload + + def serialize(self): + return self.payload + + def __repr__(self): + return "msg_dkg_raw(type=%s, len=%d)" % (self.msgtype, len(self.payload)) + + +def get_p2p_id(node): + def get_id(): + for p in node.getpeerinfo(): + for p2p in node.p2ps: + if p["subver"] == p2p.strSubVer: + return p["id"] + return None + wait_until_helper(lambda: get_id() is not None, timeout=10) + return get_id() + + +def wait_for_banscore(node, peer_id, expected_score): + def get_score(): + for peer in node.getpeerinfo(): + if peer["id"] == peer_id: + return peer["banscore"] + return None + wait_until_helper(lambda: get_score() == expected_score, timeout=10) + + +class DkgIntakeTest(DashTestFramework): + def add_options(self, parser): + self.add_wallet_options(parser) + + def set_test_params(self): + # -whitelist keeps the adversarial peer connected even after it crosses the + # discouragement threshold, so banscore stays observable for the score==100 cases. + # -debug=net surfaces the Misbehaving reason strings in debug.log. + extra_args = [["-whitelist=127.0.0.1", "-debug=net", "-deprecatedrpc=banscore"]] * 4 + self.set_dash_test_params(4, 3, extra_args=extra_args) + + def quorum_hash_prefix(self): + # llmqType (1 byte) + quorumHash (32 bytes, little-endian) -- the on-wire prefix + # shared by every DKG message, used so oversized/malformed payloads resolve to a + # real in-progress quorum and reach the size/structural checks. + return bytes([LLMQ_TEST]) + ser_uint256(int(self.quorum_hash, 16)) + + def add_verified_peer(self, node): + peer = node.add_p2p_connection(P2PInterface()) + peer_id = get_p2p_id(node) + assert node.mnauth(peer_id, FAKE_PROTX, FAKE_PUBKEY) + return peer, peer_id + + def run_test(self): + node0 = self.nodes[0] + node0.sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 0) + self.wait_for_sporks_same() + + # Mine a quorum so we have a quorumHash that resolves to a valid DKG base block. + self.quorum_hash = self.mine_quorum() + + # Target an active masternode -- the realistic victim of these messages. + mn_node = self.mninfo[0].get_node(self) + + self.test_unverified_sender_rejected(mn_node) + self.test_oversized_rejected(mn_node) + self.test_malformed_rejected(mn_node) + + def test_unverified_sender_rejected(self, node): + self.log.info("Pushed DKG messages from a non-verified peer are rejected (Misbehaving 10 each)") + peer = node.add_p2p_connection(P2PInterface()) + peer_id = get_p2p_id(node) + wait_for_banscore(node, peer_id, 0) + score = 0 + for msgtype in DKG_PUSH_TYPES: + with node.assert_debug_log(["DKG message from non-verified peer"]): + peer.send_message(msg_dkg_raw(msgtype, self.quorum_hash_prefix())) + peer.sync_with_ping() + score += 10 + wait_for_banscore(node, peer_id, score) + node.disconnect_p2ps() + + def test_oversized_rejected(self, node): + self.log.info("Oversized DKG payloads are rejected even from a verified peer (Misbehaving 100)") + peer, peer_id = self.add_verified_peer(node) + wait_for_banscore(node, peer_id, 0) + # >1 MiB clears the hard ceiling regardless of quorum params, and stays under the + # 3 MiB transport cap so the message is delivered to the handler. + payload = self.quorum_hash_prefix() + b"\x00" * (1024 * 1024 + 4096) + with node.assert_debug_log(["oversized DKG message"]): + peer.send_message(msg_dkg_raw(b"qcontrib", payload)) + peer.sync_with_ping() + wait_for_banscore(node, peer_id, 100) + node.disconnect_p2ps() + + def test_malformed_rejected(self, node): + self.log.info("Malformed DKG payloads are rejected even from a verified peer (Misbehaving 100)") + peer, peer_id = self.add_verified_peer(node) + wait_for_banscore(node, peer_id, 0) + # Valid llmqType + quorumHash prefix, then too few bytes to deserialize a + # CDKGContribution -> structural pre-validation rejects it before retention. + payload = self.quorum_hash_prefix() + b"\x00\x00\x00\x00" + with node.assert_debug_log(["malformed DKG message"]): + peer.send_message(msg_dkg_raw(b"qcontrib", payload)) + peer.sync_with_ping() + wait_for_banscore(node, peer_id, 100) + node.disconnect_p2ps() + + +if __name__ == '__main__': + DkgIntakeTest().main() diff --git a/test/functional/p2p_govsync_bloom.py b/test/functional/p2p_govsync_bloom.py new file mode 100755 index 000000000000..9ade5f5ba64a --- /dev/null +++ b/test/functional/p2p_govsync_bloom.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test that an oversized govsync bloom filter is rejected. + +A MNGOVERNANCESYNC ("govsync") request carries a peer-supplied CBloomFilter. For a +per-object request the node tests that filter against every cached vote, and +CBloomFilter::contains() loops nHashFuncs times. nHashFuncs is deserialized without +bounds, so an unbounded value would force an enormous amount of work while the message +processing mutex is held. The handler must reject any filter that is not within the +standard size constraints (vData <= 36000 bytes, nHashFuncs <= 50), matching filterload. +""" +import struct + +from test_framework.messages import ser_string, ser_uint256 +from test_framework.p2p import P2PInterface +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import force_finish_mnsync + +# CBloomFilter size constraints (src/common/bloom.h). +MAX_HASH_FUNCS = 50 + + +class msg_govsync: + """MNGOVERNANCESYNC: a governance-object hash followed by a CBloomFilter.""" + msgtype = b"govsync" + + def __init__(self, nprop=0, data=b"", n_hash_funcs=0, n_tweak=0, n_flags=0): + self.nprop = nprop + self.data = data + self.n_hash_funcs = n_hash_funcs + self.n_tweak = n_tweak + self.n_flags = n_flags + + def serialize(self): + r = ser_uint256(self.nprop) + r += ser_string(self.data) # CBloomFilter.vData + r += struct.pack(" 50) is rejected and the peer disconnected") + bad_peer = node.add_p2p_connection(P2PInterface()) + bad_peer.send_message(msg_govsync(n_hash_funcs=0xFFFFFFFF)) + bad_peer.wait_for_disconnect() + + +if __name__ == '__main__': + GovsyncBloomCapTest().main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index f97e9261a223..b398e6e55e47 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -139,6 +139,7 @@ 'feature_llmq_evo.py', # NOTE: needs dash_hash to pass 'feature_llmq_is_cl_conflicts.py', # NOTE: needs dash_hash to pass 'feature_llmq_dkgerrors.py', # NOTE: needs dash_hash to pass + 'feature_llmq_dkg_intake.py', # NOTE: needs dash_hash to pass 'feature_llmq_singlenode.py', # NOTE: needs dash_hash to pass 'feature_dip4_coinbasemerkleroots.py', # NOTE: needs dash_hash to pass 'feature_mnehf.py', # NOTE: needs dash_hash to pass @@ -344,6 +345,7 @@ 'feature_new_quorum_type_activation.py', 'feature_governance_objects.py', 'p2p_governance_invs.py', + 'p2p_govsync_bloom.py', 'rpc_uptime.py', 'feature_discover.py', 'wallet_resendwallettransactions.py --legacy-wallet',