Skip to content

fix(yara): require standalone mining terms in crypto_coinjacking $wasm_miner - #648

Merged
rng1995 merged 5 commits into
NVIDIA:mainfrom
udsy19:fix/yara-wasm-miner-fp
Sep 28, 2026
Merged

rng1995 merged 5 commits into
NVIDIA:mainfrom
udsy19:fix/yara-wasm-miner-fp

Conversation

@udsy19

@udsy19 udsy19 commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Summary

crypto_coinjacking's $wasm_miner string is
/WebAssembly\.instantiate.*(mine|hash|crypto)/. The alternation has no word
boundaries, so it matches those three substrings anywhere on the line —
including inside unrelated identifiers and against ordinary Web APIs that
commonly sit next to a legitimate WebAssembly.instantiate() call. Any such
file is reported as CRITICAL cryptojacking.

Impact: silent-wrong-result

Who reaches it: any user running the static YARA analyzer over JavaScript that
loads WebAssembly — the default built-in ruleset, no configuration needed.
Minified bundles are hit hardest, because they keep the loader and unrelated
code on one line, which is exactly what .* spans.

Three benign inputs that are flagged today:

WebAssembly.instantiate(bytes).then(r=>{ hashmap.set(r,1) })          // "hash"
WebAssembly.instantiate(bytes).then(r=>{ crypto.getRandomValues(buf) })// "crypto"
WebAssembly.instantiate(bytes).then(r=>{ return determine(r) })        // "mine"

Changes

  • Narrow the alternation to standalone mining terms with word boundaries:
    \b(mine|miner|mining)\b. This drops hash and crypto as bare triggers,
    since neither is evidence of mining on its own, and prevents mid-word matches
    such as determine.
  • Add TestBuiltInCryptominerRules covering the three false positives above
    and a true positive (m.exports.mine()), so the narrowing cannot silently
    disable real detection.

Validation

Negative control: with the rule change reverted and the tests left in place,
the three false-positive cases fail while the true-positive case still passes.

Without the fix:

FAILED test_wasm_instantiate_with_unrelated_hash_call_is_not_coinjacking
FAILED test_wasm_instantiate_with_web_crypto_api_is_not_coinjacking
FAILED test_wasm_instantiate_with_mid_word_mine_is_not_coinjacking
3 failed, 2 passed, 85 deselected in 2.63s

With the fix:

5 passed, 85 deselected in 2.51s

Signed-off-by: Udaya Tejas udayatejas2004@gmail.com

…m_miner

The $wasm_miner string (/WebAssembly\.instantiate.*(mine|hash|crypto)/) has
no word boundaries and no nocase-independent anchoring, so it fires on any
WebAssembly.instantiate() call followed anywhere on the same line by the
bare substrings "mine", "hash", or "crypto" — including mid-word matches
(determine, combine) and ordinary, unrelated APIs (crypto.getRandomValues,
a hashmap variable) that are common in legitimate WASM-loading code,
especially minified bundles that keep a loader on one line.

Narrow the alternation to standalone mine/miner/mining tokens with \b
boundaries, matching the file's own convention elsewhere of using specific,
unambiguous mining indicators (xmrig, cryptonight, stratum+tcp) rather than
generic dictionary words.

Signed-off-by: Udaya Tejas <udayatejas2004@gmail.com>

@rng1995 rng1995 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SkillSpector Review]

Hi @udsy19, thank you for your contribution to SkillSpector — we really appreciate the time you put into this! A few items need attention before it can be merged; details below.

Reviewed exact head 37ecca28c3d8915d9ea07ed46254e876db311420. The PR narrows crypto_coinjacking's $wasm_miner (src/skillspector/yara_rules/cryptominers.yar:97) from WebAssembly\.instantiate.*(mine|hash|crypto) to WebAssembly\.instantiate.*\b(mine|miner|mining)\b, and adds three negatives and one positive. I compiled the main and head versions of all built-in rules with yara-python 4.5.4 and scanned my own samples; contributor tests were not run. YARA supports \b, and the three reported false positives (hashmap, crypto.getRandomValues, determine) are cleared. However, the narrowing also removes the identifiers that real browser miners use.

Findings

  1. [Blocking] Real WASM cryptominers are no longer detected. Browser cryptojackers ship Emscripten-compiled CryptoNight/RandomX modules. On the WebAssembly.instantiate line they expose names like cryptonight, _cryptonight_hash, hash_cn, cn_hash or randomx_*, not the English words mine/miner/mining. Each single-line loader below is crypto_coinjacking on main; at head no built-in YARA rule fires:

    • CoinHive-style glue: WebAssembly.instantiate(wasmBinary,info)…;var _cryptonight_hash=Module._cryptonight_hash=…
    • webminerpool-style: …Module.cwrap("hash_cn",…)
    • WebAssembly.instantiateStreaming(fetch("cryptonight.wasm"))…exports.cn_hash(blob,nonce++)
    • WebAssembly.instantiate(randomxWasm).then(m=>m.instance.exports.randomx_calculate_hash(blob))

    crypto_miner_software needs two of its names, so cryptonight or randomx alone doesn't trigger it. Please keep mining-specific tokens next to the bounded words, e.g. cryptonight, randomx, cn_?hash/hash_?cn, hashrate/hashes_?per_?second. These are far more specific than bare hash/crypto. Also consider matching Miner/Mining case-insensitively (new Miner(, startMining(), which main already misses. Add positive regressions for these shapes. The only positive today (m.exports.mine()) matches both the old and new expressions, so it cannot catch this regression.

  2. [Non-blocking] Adding mining introduces a new prose false positive. WebAssembly.instantiate(bytes).then(m=>runAnalytics(m)); // helpers for text mining is clean on main but CRITICAL at head. If you keep mining, consider a negative for data/text-mining wording.

  3. [Non-blocking] Bound the .*. $wasm_miner still triggers YARA's contains .* compiler warning. The .{0,N} bound YARA suggests would also limit how far apart WebAssembly.instantiate and an unrelated word can be in a minified bundle.

Tests/CI: All six hosted checks pass, and the PR merges cleanly with main. #489 only changed webshells.yar plus tests, so there is no interaction. The three new negatives fail on main, so they are meaningful. Real miner shapes are not covered. I found no linked issue and no competing PR.


Decision: Changes Requested (reviewed head 37ecca28c3d8915d9ea07ed46254e876db311420)

$minero_cc = "minero.cc" nocase
$monerominer = "monerominer" nocase
$wasm_miner = /WebAssembly\.instantiate.*(mine|hash|crypto)/
$wasm_miner = /WebAssembly\.instantiate.*\b(mine|miner|mining)\b/

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping bare hash/crypto is right for hashmap and crypto.getRandomValues, but it also drops the identifiers real CryptoNight/RandomX WASM miners expose. Each of these single-line loaders is crypto_coinjacking on main and has no built-in YARA finding at head:

  • _cryptonight_hash glue
  • Module.cwrap("hash_cn", …)
  • fetch("cryptonight.wasm") + exports.cn_hash(…)
  • randomx_calculate_hash

Please add mining-specific tokens next to \b(mine|miner|mining)\b, e.g. cryptonight|randomx|cn_?hash|hash_?cn|hashrate. Optionally, replace .* with a bounded .{0,N}.

findings = _run_builtin(content, "loader.js")
assert not _has_rule(findings, "crypto_coinjacking")

def test_wasm_instantiate_with_literal_miner_call_is_coinjacking(self):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This positive matches both the old and the new expression, so it cannot catch the recall regression. Please add positives for Emscripten CryptoNight/RandomX glue on the WebAssembly.instantiate line, e.g.:

  • _cryptonight_hash
  • Module.cwrap("hash_cn", …)
  • cryptonight.wasm + cn_hash
  • randomx_calculate_hash

The bounded \b(mine|miner|mining)\b alternation added to $wasm_miner
correctly drops "hashmap"/"crypto.getRandomValues"/"determine", but real
browser cryptojackers ship Emscripten-compiled CryptoNight/RandomX modules
that expose names like _cryptonight_hash, hash_cn/cn_hash, or
randomx_calculate_hash on the WebAssembly.instantiate line — none of which
contain the English words mine/miner/mining, so they went undetected.

Add mining-software-specific tokens (cryptonight, randomx, cn_hash/hash_cn,
hashrate) as unbounded alternatives alongside the bounded English-word
group. These tokens are distinctive enough to not need \b: in fact they
must NOT be \b-bounded, since real loaders join them to surrounding
identifiers with underscores (_cryptonight_hash, randomx_calculate_hash),
and underscore is a regex word character, so a \b boundary would silently
fail to match exactly these shapes.

Add four regression positives covering the reviewer's own reported shapes
(CoinHive-style _cryptonight_hash, webminerpool-style hash_cn via cwrap,
cryptonight.wasm + cn_hash, randomx_calculate_hash).

Signed-off-by: Udaya Tejas <udayatejas2004@gmail.com>
Three follow-ups from review:

- nocase, so the CoinHive-era shapes match: new Miner(, startMining().
  Bare hash/crypto stay out, so hashmap and crypto.getRandomValues are
  still clean.
- mining now requires a call, mining\s*\(, rather than a bare word. That
  keeps startMining( and drops the 'text mining' prose false positive
  adding 'mining' had introduced.
- .{0,400} instead of .*, which bounds how far apart the instantiate call
  and the identifier may sit in a minified bundle and clears YARA's
  contains-.* compiler warning.

Signed-off-by: Udaya Tejas <udayatejas2004@gmail.com>
@udsy19

udsy19 commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @rng1995 — this is a much better review than the PR deserved, and the
blocking finding is correct. Narrowing to the English words traded a false
positive problem for a false negative one, in a rule whose whole job is recall.
Worse, my only positive (m.exports.mine()) matched both the old and new
expression, so the test I added could not have caught the regression I
introduced. That's the part I'll be thinking about.

Fixed across two commits. The rule is now:

/WebAssembly\.instantiate.{0,400}(\b(mine|miner)\b|mining\s*\(|cryptonight|randomx|cn_?hash|hash_?cn|hashrate)/ nocase

1 (blocking) — real miner identifiers. Added cryptonight, randomx,
cn_?hash, hash_?cn, hashrate alongside the bounded words. All four loaders
you listed now fire; I scanned them directly against the compiled ruleset rather
than trusting the tests:

[OK] CoinHive glue        _cryptonight_hash
[OK] webminerpool         Module.cwrap("hash_cn", …)
[OK] streaming            fetch("cryptonight.wasm") + exports.cn_hash(…)
[OK] randomx              randomx_calculate_hash

Each has a positive regression test. You were right that the old positive was
worthless as a guard — these four fail if the identifiers are removed again.

Case-insensitivity. Added nocase, so new Miner( and startMining( match,
which as you note main misses too. Bare hash/crypto stay out, so hashmap
and crypto.getRandomValues are still clean under nocase.

2 (non-blocking) — the mining prose false positive. Real. Rather than
keeping a bare \bmining\b and documenting the FP, mining now requires a call:
mining\s*\(. That keeps startMining( and drops
// helpers for text mining, which has a negative test.

3 (non-blocking) — bounding the .*. Now .{0,400}. YARA compiles the
ruleset with zero warnings, and it limits how far apart the instantiate call
and an identifier can sit in a minified bundle.

Full file: 97 passed. The negatives still fail on main, so they remain
meaningful, and the new positives fail without the identifier additions.

One thing I should flag rather than leave you to find: I have not tested against
live cryptojacking samples, only against the single-line loader shapes from your
review and the ones already in the file. If you have samples that exercise the
hashrate or hash_?cn branches differently, I'd rather know now than ship
recall I only think I have.

CI lint job failed format-check on the text-mining-prose case added in
the prior commit; ruff wants the two-line string literal joined.

Signed-off-by: Udaya Tejas <udayatejas2004@gmail.com>
@rng1995
rng1995 force-pushed the fix/yara-wasm-miner-fp branch from de31a0e to 77314a0 Compare September 28, 2026 17:43

@rng1995 rng1995 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SkillSpector Review]

Hi @udsy19, thank you for the thorough follow-up and for your contribution to SkillSpector!

Re-reviewed head ca4f28524695a38e9ef9e80647a0a7af6e488d90. The earlier findings are addressed:

  1. [Blocking — fixed] cryptonight, randomx, cn_?hash, hash_?cn and hashrate restore detection of the four CryptoNight/RandomX loader shapes, and each has a positive regression test. nocase also catches new Miner(, startMining( and "RandomX", which main missed.
  2. [Non-blocking — fixed] mining\s*\( drops the text-mining prose false positive, and a negative test covers it.
  3. [Non-blocking — fixed] .{0,400} replaces .*, and the rule now compiles with no YARA warnings.

With the rule set back to main, five of the new tests fail, so they guard the change.

DCO: the DCO check failed on your merge commit de31a0e because it has no Signed-off-by. It was a clean merge, so I reset the branch to your last signed commit 77314a0 and let GitHub's update-branch merge main again. The DCO check exempts GitHub-made merge commits. If you push again, please first reset your local branch to the new remote head (git fetch then git reset --hard origin/fix/yara-wasm-miner-fp), and use git commit -s for any future merges.

Optional follow-ups (non-blocking):

  • randomx with nocase also matches coordinate names such as p.randomX = Math.random()*w, which main did not flag. randomx_ would still cover the real randomx_* API.
  • main's .* still matched a miner token 1,000 characters after instantiate, but the new rule stops at 400. If real minified samples put the export table farther away, a larger bound may be worth it.
  • hash_?cn also matches hash_cnt/hashCnt counters. main flags these too, so this is not a regression. hash_?cn\b would tighten it.

Tests/CI: test_static_yara.py passes locally on this head (108 passed). DCO Check passes, and the rest of CI is running.


Decision: Approved (reviewed head ca4f28524695a38e9ef9e80647a0a7af6e488d90)

@rng1995
rng1995 merged commit 8bf9c67 into NVIDIA:main Sep 28, 2026
6 checks passed
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.

2 participants