Skip to content

stratum: fix two pool-controlled crash vectors (notify type confusion, negative extranonce2_size) - #1880

Open
Schnitzel wants to merge 5 commits into
bitaxeorg:masterfrom
Schnitzel:fix/rt-2026-050-051
Open

stratum: fix two pool-controlled crash vectors (notify type confusion, negative extranonce2_size)#1880
Schnitzel wants to merge 5 commits into
bitaxeorg:masterfrom
Schnitzel:fix/rt-2026-050-051

Conversation

@Schnitzel

@Schnitzel Schnitzel commented Aug 14, 2026

Copy link
Copy Markdown

Reported via coordinated disclosure by the 256 Foundation Red Team (findings RT-2026-050 / RT-2026-051). Two independent pool-controlled crash vectors in the stratum v1 parser, each reboot-looping the miner. Both are availability-only (DoS); no code execution observed. Maintainer team asked for a public PR.

Issue 1 — mining.notify type confusion (crash → reboot loop)

parse_mining_notify() in components/stratum/stratum_api.c dereferenced ->valuestring on cJSON items without verifying they are strings. A mining.notify with non-string/null prevhash, coinbase1/2, merkle entries, version, nbits, or ntime reached strdup(NULL) / strtoul(NULL) / hex2bin(NULL) and crashed the stratum task → panic → reboot → reconnect → re-crash: a permanent reboot loop.

Trigger example:

{"id":null,"method":"mining.notify","params":["aa",12345,"00","00",[],"20000000","1a02b290","66000000",true]}

Reproduced on v2.14.2 hardware (panic loop, 9–16 s uptime cycles, 8 cycles observed) and on a host build of master @ dec2b46 (ASan: wrap_strdup → STRATUM_V1_parse). Since stratum v1 is plaintext by default, the trigger is reachable by the pool operator or any LAN MITM.

Fix (commit 1): validate cJSON_IsString for params[1..3], each merkle branch entry, and params[5..7] before use; reject the message (connection survives) like the existing job_id/param-count/merkle-count checks.

Issue 2 — negative extranonce2_size accepted (crash on next job)

parse_set_extranonce() / parse_subscribe_result() validated EN2 size only on the high side (>32 clamp). A negative size (e.g. −1) was stored as signed int, then passed to extranonce_2_generate() whose length parameter is uint32_t: −1 wraps to 4294967295 and the function memset()s a VLA of that size on the task stack → crash when the next job is generated.

Master-only regression: v2.14.2 parses the size as unsigned (−1 → 4294967295) and the existing >32 clamp fires (verified on hardware); the signed acceptance path exists on master @ dec2b46.

Fix (commit 2): reject extranonce_2_len < 1 in parse_set_extranonce and parse_subscribe_result, reject it in the task handler, and guard the generate_work() call site (defense in depth at the VLA).

Verification

Host fuzz harness (ESP-IDF linux target, ASan+UBSan, production stratum_v1_task + components/stratum built unmodified except these patches):

  • Before: 7 distinct crash sites from notify type confusion; EN2 −1 accepted.
  • After: zero crashes across the full 23-case battery (120 s loop); all 19 isolated prober cases survive; the 7 former crash sites are clean rejects (Invalid prev_block_hash/coinbase in mining.notify); EN2 −1/0 rejected.
  • No regressions: EN2 255/1 000 000 still clamp to 32; valid subscribe (EN2 4), valid notify parse, difficulty flows unchanged; 1 MB line tolerated; client.reconnect still not followed to advertised addresses.

See also

#1881 — the difficulty-validation fix (parse_set_difficulty range check, our ref RT-2026-052), split out per maintainer preference.

Schnitzel and others added 5 commits August 10, 2026 07:56
simpleMarkdownParser ran its regex chain on the raw, remotely authored
GitHub release body and bound the result to [innerHTML] with no
HTML-escaping step, relying entirely on Angular's sanitizer. The input
is now entity-escaped before any markup is generated, and all generated
target=_blank anchors carry rel="noopener noreferrer".

Co-authored-by: Michael Schmid <michael.schmid@amazee.io>
Signed-off-by: Michael Schmid <michael.schmid@amazee.io>
The swarm scanner accepted any LAN HTTP responder as a device and took
connectionAddress from the response-supplied ipv4 field, so a rogue
responder could redirect restart/identify POSTs to an arbitrary IP. The
address actually connected to is now used as the action target. Remote
swarmColor strings are also clamped to the known palette before being
rendered as CSS.

Co-authored-by: Michael Schmid <michael.schmid@amazee.io>
Signed-off-by: Michael Schmid <michael.schmid@amazee.io>
getQuickLink spliced stratumUser into pool stats URL templates with no
encoding, so a value containing /, ? or # mutated the destination the
link points to. The user token is now encodeURIComponent'd.

Co-authored-by: Michael Schmid <michael.schmid@amazee.io>
Signed-off-by: Michael Schmid <michael.schmid@amazee.io>
parse_mining_notify() dereferenced ->valuestring on cJSON items without
checking they are strings. A mining.notify with non-string (or null)
prevhash, coinbase1/2, merkle entries, version, nbits, or ntime reached
strdup(NULL)/strtoul(NULL)/hex2bin(NULL) and crashed the stratum task,
reboot-looping the device.

A hostile pool — or any LAN MITM, since stratum v1 is plaintext by
default — could kill every reachable miner this way (256 Foundation Red
Team RT-2026-050, reproduced on v2.14.2 hardware: panic reboot loop).

Validate cJSON_IsString for params[1..3], each merkle branch entry, and
params[5..7] before use; reject the message (connection survives) exactly
like the existing job_id/param-count/merkle-count checks.
extranonce_2_len was validated only on the high side (>32 clamp) in
parse_set_extranonce, parse_subscribe_result, and the task handler. A
negative size from mining.set_extranonce (or a subscribe result) was
stored as-is, then reached extranonce_2_generate() whose length parameter
is uint32_t: -1 wraps to 4294967295, and the function memset()s a
length-sized VLA on the task stack — an instant crash on the next job
(256 Foundation Red Team RT-2026-051).

Reject extranonce_2_len < 1 at both parse sites (message ignored,
connection survives), reject it in the task handler, and guard the
generate_work() call site (defense in depth at the VLA).

Note: v2.14.2 parses the size as unsigned so -1 becomes 4294967295 and
the existing >32 clamp fires (verified on hardware); the signed
acceptance path fixed here is a post-v2.14.2 regression.
@Schnitzel

Copy link
Copy Markdown
Author

Fix verified on real hardware (2026-08-15). Flashed a build of master + these two commits (v2.14.0-50-ga18520a-dirty) via OTA onto a Bitaxe (board 401 / BM1368) that previously panic-looped on v2.14.2, pointed it at a hostile pool, and re-sent the original payloads:

  • mining.notify with numeric prevhash (the pre-fix crash trigger): now rejected — device log shows Invalid prev_block_hash/coinbase in mining.notify, mining continues uninterrupted.
  • set_extranonce with size −1: now rejected — Invalid extranonce_2_len -1 in set_extranonce; the unit then mined 14+ hours on the hostile pool with zero resets (~23.6k shares).

(Unit reverted to stock v2.14.2 after the test.)

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.

1 participant