stratum: reject low-diff shares before spending a RandomX hash on them

SubmitBlock validated in the wrong order. After two O(1) length checks it
went straight to CheckRandomXSolution() -- a full ~65ms
randomx_calculate_hash -- and only afterwards tested the share target. So
32 arbitrary bytes from any peer bought a RandomX hash, and the cost was
paid in three bad places at once: on the shared HTTP/RPC libevent thread
(stratum uses EventBase(), the same base ThreadHTTP dispatches, and RPC
replies are posted back onto it), inside the read loop that holds
cs_stratum so BlockWatcher cannot push new work to real miners, and
holding the global cs_randomx_validator that block validation also takes.

One connection writing submit lines pins that thread indefinitely.

Move the share-target test above the RandomX verify. CBlockHeader::
GetHash() is SerializeHash over the header including nSolution, so
meeting the target still requires real SHA256d grinding -- an attacker
now pays for the hash instead of the node. Semantics are unchanged,
including that an empty local_diff parses to zero and still rejects; only
the position moved, and the diagnostics are recomputed locally since the
old message used variables declared further down.

This does not make the submit path cheap, only bounded: at the default
share target the grind is small, so a submit rate limit and moving
SubmitBlock off the event loop are both still wanted.

Also add the authorization check every sibling handler has and
mining.submit lacked. Being explicit about what that is worth:
mining.authorize validates no credential, so the gate is parity and
handshake-ordering, not authentication. The reorder above is the part
that actually bounds an unknown peer.
This commit is contained in:
2026-08-28 23:35:06 -05:00
parent 5f40c8ede0
commit fa16e740b6

View File

@@ -1035,6 +1035,22 @@ bool SubmitBlock(StratumClient& client, const uint256& job_id, const StratumWork
} }
blkhdr.nNonce = (uint256) nonce; blkhdr.nNonce = (uint256) nonce;
// Cheap SHA256d filter first. This test used to sit *below* the RandomX verify, so 32
// arbitrary bytes from any peer bought a full ~65ms randomx_calculate_hash before anything
// rejected them -- on the shared HTTP/RPC libevent thread, and holding the global
// cs_randomx_validator that block validation also takes. GetHash() is SerializeHash over the
// header including nSolution, so passing this costs real grinding. Semantics are unchanged:
// an empty local_diff still parses to zero and still rejects, exactly as before.
if (!instance_of_cstratumparams.fAllowLowDiffShares &&
UintToArith256(blkhdr.GetHash()) > arith_uint256(current_work.local_diff)) {
CBlockIndex diff_index;
diff_index.nBits = UintToArith256(blkhdr.GetHash()).GetCompact();
const double share_diff = GetDifficulty(&diff_index);
diff_index.nBits = arith_uint256(current_work.local_diff).GetCompact();
const double target_diff = GetDifficulty(&diff_index);
throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("Low diff share (diff %g, local %g)", share_diff, target_diff));
}
// block is constructed, now it's time to VerifyEH // block is constructed, now it's time to VerifyEH
if (StratumIsRandomX()) { if (StratumIsRandomX()) {
@@ -1092,10 +1108,8 @@ bool SubmitBlock(StratumClient& client, const uint256& job_id, const StratumWork
std::chrono::duration<double, std::milli> elapsed; std::chrono::duration<double, std::milli> elapsed;
uint64_t shares_accepted_since_last; uint64_t shares_accepted_since_last;
// TODO: we need to check hash > local port diff, and if it's true -> throw an exception -> diff too low (!) // (the low-diff share check moved above the RandomX verify -- see SubmitBlock's cheap
if (!instance_of_cstratumparams.fAllowLowDiffShares) // SHA256d filter -- so that attacker-controlled bytes cannot buy a RandomX hash)
if (UintToArith256(blkhdr.GetHash()) > arith_uint256(current_work.local_diff))
throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("Low diff share (diff %g, local %g)", hush_real_diff, hush_local_diff));
if (finish > start) if (finish > start)
{ {
@@ -1343,6 +1357,18 @@ UniValue stratum_mining_submit(StratumClient& client, const UniValue& params)
const std::string method("mining.submit"); const std::string method("mining.submit");
BoundParams(method, params, 5,5); BoundParams(method, params, 5,5);
// Parity with every other handler (GetWorkUnit, mining.aux.*, mining.extranonce.*), which all
// refuse an unauthorized client. NOTE this is not authentication: mining.authorize validates no
// credential, so it only costs an attacker one extra line. It is here so the submit path cannot
// be reached without at least completing the handshake; the cheap-target check below is what
// actually bounds the work an unknown peer can force.
if (!client.m_authorized && client.m_aux_addr.empty()) {
const std::string msg = strprintf("%s: share submitted by an unauthorized client", __func__);
LogPrint("stratum", "%s\n", msg);
throw JSONRPCError(RPC_INVALID_REQUEST, "Stratum client not authorized. Use mining.authorize first, with a DragonX R.. address as the username or 'x' to mine to the default address.");
}
// First parameter is the client username, which is ignored. // First parameter is the client username, which is ignored.
/* EWBF 31 bytes job_id fix */ /* EWBF 31 bytes job_id fix */