rpc/net: gate and harden stratummine, keep regtest off the live network, init nSPV filter

stratummine (rpc/mining.cpp), a test-only reference miner, was registered
unconditionally behind a bare #ifndef WIN32 with okSafeMode=true, so it shipped
as a live RPC on every non-Windows release build. Four fixes:

  - Gated behind an explicit, default-off -stratummine, with regtest exempt so
    qa/rpc-tests can still drive it. Deliberately NOT gated on fExperimentalMode:
    that defaults to TRUE (init.cpp:1195), so such a gate is a no-op -- an error
    made and caught while testing this change.
  - mining.notify's three hash fields went straight into uint256(ParseHex(...)).
    uint256's vector ctor asserts on a wrong size (uint256.cpp:30) and NDEBUG is
    defined nowhere in this build, so that assert is live in release; ParseHex
    also truncates silently at the first non-hex character. A short or garbled
    field therefore ABORTED THE DAEMON. Added StratumHex256(), which requires 64
    hex chars and a 32-byte result, and all three fields are validated before any
    is committed so a bad job is rejected rather than half-applied.
  - processLine ran inside the window where the RandomX cache, the VM and the
    socket are live, all released on the normal path only, and every get_str()
    throws on a type mismatch -- so a malformed message leaked 256 MB and the fd.
    Body wrapped in try/catch: ignore the line, keep mining.
  - Caller-supplied timeout clamped to [1, 3600]; it was unbounded, pinning an
    RPC worker and the cache indefinitely. okSafeMode -> false.

Verified against a hostile stratum server on regtest:
  - 4 malformed mining.notify payloads (short hex, non-hex, wrong JSON type,
    31 bytes) -> all rejected, daemon alive, 0 assertions. The 31-byte case is
    the one that previously hit the uint256 assert.
  - valid job first (so RandomX actually allocates) then garbage mid-mine ->
    RSS delta +2.2 MB, i.e. cache and VM released, not the ~256 MB a leak leaves.
  - regtest exemption confirmed: stratummine runs past the gate there.
  The non-regtest refusal path is by code reading only -- a testnet node on this
  host collides with the production daemon's RPC port, so it was not exercised.

hush_utils.h: stop injecting the mainnet node1-node10.dragonx.is addnode seeds
when -regtest or -testnet is set. regtest reuses mainnet's network magic, so a
supposedly isolated node was handshaking production peers and pulling their
headers into its own index. Gated at the injection site only -- isdragonx itself
must stay true, because it also selects ac_private, ac_algo, blocktime and the
reward/halving schedule (an earlier version of this patch gated isdragonx itself
and silently turned ac_private off on regtest). hush_args() runs between
ParseParameters() and ReadConfigFile() (bitcoind.cpp:115/144/158), so this sees a
command-line -regtest, as qa/rpc-tests uses, but not a config-file regtest=1.

hush_nSPV_fullnode.h: initialize `filter` at both sites. It was assigned only on
the len-11 request form; the other two passed uninitialized stack memory to
NSPV_getaddressutxos/NSPV_getaddresstxids, remotely reachable since
HUSH_NSPV_FULLNODE is on by default.

getblocktemplate_proposals.py still passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FU87LdsJZiZkfq1eXubpeo
This commit is contained in:
2026-08-31 02:38:38 -05:00
parent 6d282db216
commit db42091ce3
3 changed files with 70 additions and 10 deletions

View File

@@ -660,7 +660,7 @@ void hush_nSPVreq(CNode *pfrom,std::vector<uint8_t> request) // received a reque
struct NSPV_utxosresp U; struct NSPV_utxosresp U;
if ( len >= 2 && len < 64+5 && request[1] < 64 && (request[1] == len-3 || request[1] == len-7 || request[1] == len-11) ) if ( len >= 2 && len < 64+5 && request[1] < 64 && (request[1] == len-3 || request[1] == len-7 || request[1] == len-11) )
{ {
int32_t skipcount = 0; char coinaddr[64]; uint32_t filter; uint8_t isCC = 0; int32_t skipcount = 0; char coinaddr[64]; uint32_t filter = 0; uint8_t isCC = 0; // only assigned on the len-11 form; the other two passed it on uninitialized
memcpy(coinaddr,&request[2],request[1]); // request[1] < 64 bounds the copy + the terminator write memcpy(coinaddr,&request[2],request[1]); // request[1] < 64 bounds the copy + the terminator write
coinaddr[request[1]] = 0; coinaddr[request[1]] = 0;
if ( request[1] == len-3 ) if ( request[1] == len-3 )
@@ -698,7 +698,7 @@ void hush_nSPVreq(CNode *pfrom,std::vector<uint8_t> request) // received a reque
struct NSPV_txidsresp T; struct NSPV_txidsresp T;
if ( len >= 2 && len < 64+5 && request[1] < 64 && (request[1] == len-3 || request[1] == len-7 || request[1] == len-11) ) if ( len >= 2 && len < 64+5 && request[1] < 64 && (request[1] == len-3 || request[1] == len-7 || request[1] == len-11) )
{ {
int32_t skipcount = 0; char coinaddr[64]; uint32_t filter; uint8_t isCC = 0; int32_t skipcount = 0; char coinaddr[64]; uint32_t filter = 0; uint8_t isCC = 0; // only assigned on the len-11 form; the other two passed it on uninitialized
memcpy(coinaddr,&request[2],request[1]); // request[1] < 64 bounds the copy + the terminator write memcpy(coinaddr,&request[2],request[1]); // request[1] < 64 bounds the copy + the terminator write
coinaddr[request[1]] = 0; coinaddr[request[1]] = 0;
if ( request[1] == len-3 ) if ( request[1] == len-3 )

View File

@@ -1783,11 +1783,21 @@ void hush_args(char *argv0)
fprintf(stderr,".oO Starting %s Full Node (Extreme Privacy!) with genproc=%d notary=%d\n",name.c_str(),HUSH_MININGTHREADS, IS_HUSH_NOTARY); fprintf(stderr,".oO Starting %s Full Node (Extreme Privacy!) with genproc=%d notary=%d\n",name.c_str(),HUSH_MININGTHREADS, IS_HUSH_NOTARY);
vector<string> DRAGONX_nodes = {}; vector<string> DRAGONX_nodes = {};
// Only DRAGONX connects to these by default, other chains must opt-in via -connect/-addnode // Only DRAGONX connects to these by default, other chains must opt-in via -connect/-addnode.
// Never on regtest or testnet: regtest reuses mainnet's network magic, so injecting the mainnet
// seeds here makes a supposedly isolated node handshake production peers and pull their headers
// into its own index -- which is exactly what it did, and why multi-node rpc-tests were talking
// to the live chain. NOTE: hush_args() runs between ParseParameters() and ReadConfigFile()
// (bitcoind.cpp:115/144/158), so this sees a command-line -regtest (how qa/rpc-tests starts
// nodes) but NOT a bare "regtest=1" in the config file.
const bool isdragonx = strncmp(name.c_str(), "DRAGONX",7) == 0 ? true : false; const bool isdragonx = strncmp(name.c_str(), "DRAGONX",7) == 0 ? true : false;
LogPrint("net", "%s: isdragonx=%d\n", __func__, isdragonx); // Seed injection only -- isdragonx itself must stay true here, because it also selects
if (isdragonx) { // ac_private, ac_algo, blocktime and the reward/halving schedule below.
const bool isnotmainnet = GetBoolArg("-regtest", false) || GetBoolArg("-testnet", false);
LogPrint("net", "%s: isdragonx=%d isnotmainnet=%d\n", __func__, isdragonx, isnotmainnet);
if (isdragonx && !isnotmainnet) {
// node8-node10 are PLACEHOLDERS with no DNS records yet. A hostname that // node8-node10 are PLACEHOLDERS with no DNS records yet. A hostname that
// does not resolve is harmless here: ThreadOpenAddedConnections just fails // does not resolve is harmless here: ThreadOpenAddedConnections just fails
// to open the connection and retries on its 2-minute cycle. Reserving the // to open the connection and retries on its 2-minute cycle. Reserving the

View File

@@ -1112,6 +1112,27 @@ static bool StratumMinerRecvLines(int fd, std::string& buf, int timeout_ms, std:
// authorize, receive work + the per-height RandomX key, then vary the block nNonce, hash with // authorize, receive work + the per-height RandomX key, then vary the block nNonce, hash with
// RandomX (byte-identical to CheckRandomXSolution via GetRandomXInput), and submit a 32-byte // RandomX (byte-identical to CheckRandomXSolution via GetRandomXInput), and submit a 32-byte
// solution when the block hash meets target. Exists to validate the -stratum RandomX pool path. // solution when the block hash meets target. Exists to validate the -stratum RandomX pool path.
//! Upper bound on how long stratummine will hold an RPC worker thread (and a 256 MB RandomX
//! cache). An unbounded caller-supplied deadline pins both indefinitely.
static const int64_t MAX_STRATUMMINE_TIMEOUT = 3600;
//! Parse a 64-char hex field from mining.notify into a uint256.
//! These fields come from whatever host the operator pointed us at, and both primitives below are
//! unforgiving: uint256's vector constructor asserts on a wrong-size input (uint256.cpp:30, and
//! NDEBUG is never defined for this build so the assert is live in release), while ParseHex
//! silently truncates at the first non-hex character. A short or garbled field would therefore
//! abort the daemon rather than be rejected. Returns false instead.
static bool StratumHex256(const std::string& hex, uint256& out)
{
if (hex.size() != 64 || !IsHex(hex))
return false;
std::vector<unsigned char> v = ParseHex(hex);
if (v.size() != 32)
return false;
out = uint256(v);
return true;
}
UniValue stratummine(const UniValue& params, bool fHelp, const CPubKey& mypk) UniValue stratummine(const UniValue& params, bool fHelp, const CPubKey& mypk)
{ {
if (fHelp || params.size() < 2 || params.size() > 4) if (fHelp || params.size() < 2 || params.size() > 4)
@@ -1129,10 +1150,24 @@ UniValue stratummine(const UniValue& params, bool fHelp, const CPubKey& mypk)
if (ASSETCHAINS_ALGO != ASSETCHAINS_RANDOMX) if (ASSETCHAINS_ALGO != ASSETCHAINS_RANDOMX)
throw JSONRPCError(RPC_MISC_ERROR, "stratummine only supports RandomX chains"); throw JSONRPCError(RPC_MISC_ERROR, "stratummine only supports RandomX chains");
// This is a reference miner for exercising -stratum, not a production facility: it dials an
// operator-supplied host, blocks an RPC worker for the whole run, and parses whatever that host
// chooses to send back. Require an explicit opt-in, and exempt regtest so the test suite can
// still drive it. NOTE: do NOT gate this on fExperimentalMode -- that defaults to TRUE
// (init.cpp:1195), so it would leave the RPC exposed on every node and the gate would be a no-op.
if (!GetBoolArg("-stratummine", false) && Params().NetworkIDString() != "regtest")
throw JSONRPCError(RPC_MISC_ERROR,
"stratummine is a test-only reference miner and is disabled by default; "
"restart with -stratummine to enable it");
const std::string host = params[0].get_str(); const std::string host = params[0].get_str();
const int port = params[1].get_int(); const int port = params[1].get_int();
const std::string addr = params.size() > 2 ? params[2].get_str() : "x"; const std::string addr = params.size() > 2 ? params[2].get_str() : "x";
const int64_t timeout = params.size() > 3 ? params[3].get_int64() : 120; int64_t timeout = params.size() > 3 ? params[3].get_int64() : 120;
if (timeout < 1)
timeout = 1;
if (timeout > MAX_STRATUMMINE_TIMEOUT)
timeout = MAX_STRATUMMINE_TIMEOUT;
const int64_t deadline = GetTime() + timeout; const int64_t deadline = GetTime() + timeout;
// connect (blocking TCP) // connect (blocking TCP)
@@ -1166,6 +1201,11 @@ UniValue stratummine(const UniValue& params, bool fHelp, const CPubKey& mypk)
uint256 hashPrevBlock, hashMerkleRoot, hashReserved; uint256 hashPrevBlock, hashMerkleRoot, hashReserved;
auto processLine = [&](const std::string& line) { auto processLine = [&](const std::string& line) {
// Every get_str()/get_int() below throws on a type mismatch, and this lambda runs inside the
// window where the RandomX cache and VM are allocated and the socket is open -- all of which
// are released only on the normal path. A malformed server message must therefore never
// escape from here, or it leaks 256 MB and the fd on its way out.
try {
UniValue v; UniValue v;
if (!v.read(line)) return; if (!v.read(line)) return;
const UniValue& id = find_value(v, "id"); const UniValue& id = find_value(v, "id");
@@ -1185,16 +1225,26 @@ UniValue stratummine(const UniValue& params, bool fHelp, const CPubKey& mypk)
poolTarget = UintToArith256(uint256S(p[0].get_str())); poolTarget = UintToArith256(uint256S(p[0].get_str()));
haveTarget = true; haveTarget = true;
} else if (m == "mining.notify" && p.size() >= 7) { } else if (m == "mining.notify" && p.size() >= 7) {
// Validate every fixed-width field before committing any of it, so a malformed job is
// ignored outright rather than half-applied over the previous one.
uint256 prev, merkle, reserved;
if (!StratumHex256(p[2].get_str(), prev) ||
!StratumHex256(p[3].get_str(), merkle) ||
!StratumHex256(p[4].get_str(), reserved))
return;
jobId = p[0].get_str(); jobId = p[0].get_str();
nVersion = bswap_32((uint32_t)strtoul(p[1].get_str().c_str(), NULL, 16)); nVersion = bswap_32((uint32_t)strtoul(p[1].get_str().c_str(), NULL, 16));
hashPrevBlock = uint256(ParseHex(p[2].get_str())); hashPrevBlock = prev;
hashMerkleRoot = uint256(ParseHex(p[3].get_str())); hashMerkleRoot = merkle;
hashReserved = uint256(ParseHex(p[4].get_str())); hashReserved = reserved;
timeHex = p[5].get_str(); timeHex = p[5].get_str();
nTime = bswap_32((uint32_t)strtoul(timeHex.c_str(), NULL, 16)); nTime = bswap_32((uint32_t)strtoul(timeHex.c_str(), NULL, 16));
nBits = bswap_32((uint32_t)strtoul(p[6].get_str().c_str(), NULL, 16)); nBits = bswap_32((uint32_t)strtoul(p[6].get_str().c_str(), NULL, 16));
haveJob = true; haveJob = true;
} }
} catch (const std::exception&) {
// Malformed message from the server: ignore the line and keep mining.
}
}; };
std::string buf; std::string buf;
@@ -1302,7 +1352,7 @@ static const CRPCCommand commands[] =
{ // category name actor (function) okSafeMode { // category name actor (function) okSafeMode
// --------------------- ------------------------ ----------------------- ---------- // --------------------- ------------------------ ----------------------- ----------
#ifndef WIN32 #ifndef WIN32
{ "mining", "stratummine", &stratummine, true }, { "mining", "stratummine", &stratummine, false },
#endif #endif
{ "mining", "getlocalsolps", &getlocalsolps, true }, { "mining", "getlocalsolps", &getlocalsolps, true },
{ "mining", "getnetworksolps", &getnetworksolps, true }, { "mining", "getnetworksolps", &getnetworksolps, true },