A tiny, zero-dependency scanner that finds EtherHiding-style malware loaders —
obfuscated code injected into JavaScript/TypeScript build-config files
(next.config.js, postcss.config.js, vite.config.*, …) that pulls its real
payload from a public blockchain at build time — plus the repo-side artifacts of
the PolinRider campaign that uses the same technique (VS Code folder-open tasks,
fake-font payloads, force-push scripts, typosquatted deps, JSON-appended loaders).
It scans your working tree and git history (with an exhaustive --all-objects
mode that also reaches dangling / force-pushed commits), works as a CLI,
pre-commit hook, or CI gate, and never executes the code it inspects.
Built after catching one of these in the wild. A normal "known-bad-file" scan misses it, because the payload lives on-chain and the on-disk loader is unique and obfuscated. This looks for the loader and its structural tells instead.
Scope: this detects the EtherHiding loader + PolinRider's repo-side tells. It is not a host-level scanner (browser/wallet/persistence forensics) and not a full supply-chain guard — see Scope & non-goals.
EtherHiding (Mandiant tracks the actor as UNC5142) hides malware on public blockchains so it can't be taken down:
- A loader is appended to a build config after
module.exports. Anything that runsnext build/dev/start— including CI — executes it. - At runtime it reads a transaction pointer from an attacker-controlled TRON account (with Aptos as fallback).
- It uses that to fetch XOR-encrypted JavaScript from a transaction's calldata on BNB Smart Chain via public RPC, and decrypts it in memory (nothing malicious touches disk).
- Stage 1 runs inside the build; stage 2 launches as a detached, hidden process — an infostealer/RAT after env vars, npm/GitHub tokens, SSH keys, browser sessions, and crypto wallets.
The campaign is also self-propagating: reporting indicates that on an infected dev machine it reads cached git credentials and force-pushes itself into repos that account can write to, forging the commit to look like the repo owner's (the authenticated pusher is what's observed on the server; the credential theft on the developer's machine is the inferred mechanism).
No install required (zero dependencies). For your own repos, prefer vendoring
scan.js (or the SHA-pinned GitHub Action below) over npx — a malware scanner
shouldn't resolve itself from a live registry on every build.
# vendor scan.js into your repo, then:
node scan.js # scan current dir's build configs (working tree)
node scan.js --history # + ref-reachable git history
node scan.js --all-objects # + EVERY git object, incl. dangling/force-pushed (recommended for audits)
node scan.js path/to/reponpx etherhiding-detector / a global install also work for ad-hoc local scans, but
aren't recommended inside CI for the reason above.
| Flag | Effect |
|---|---|
--history |
Also scan config/package.json blobs across ref-reachable git history. |
--all-objects |
Exhaustive forensic-triage scan of every git object, incl. dangling/orphaned blobs left by a force-push — catches a dropper after it's removed from all branches. Slower; implies --history. Pair with --fail-on=critical (its HIGH/MEDIUM tiers are noisier on arbitrary blobs). |
--all |
Scan all .js/.ts/... files, not just known build configs. |
--signatures-only |
Known-IOC string matches only — lowest false-positive mode. |
--fail-on=critical,high |
Exit nonzero only for the listed severities (default: any finding). |
--exclude=a,b |
Skip files whose path contains any of these substrings (e.g. a vendored scan.js). |
--json / --quiet |
Machine-readable output / print only on findings. |
-- <path> |
Argv terminator — everything after -- is treated as a path. |
Exit codes: 0 clean · 1 findings (subject to --fail-on) · 2 usage/error.
--historyonly reaches objects anchored to a ref. A dropper in a commit that was force-pushed away is dangling and won't appear there — use--all-objectsto catch it (this is the normal post-eradication state).
Layer 1 — known IOCs (this campaign): the C2 IP (198[.]105[.]127[.]210, defanged
here — decoded in scan.js); the TRON/Aptos/BSC RPC hosts; on-chain dead-drop addresses;
stage XOR keys; the in-process marker in all forms (global.i / global._V /
global['!'], dot and bracket access, both quote styles, 8-NNNN / 8-NNNN-N
values); the _$_ + 4-hex obfuscation-table variable (labeled may mutate); and an
exact-hash match of the original dropper.
Layer 2 — generic heuristics (catch mutated / related variants), scoped to build configs:
| Rule | Flags |
|---|---|
heur.eval / heur.functionctor |
eval() / Function() constructors building code |
heur.hiddenproc |
detached / windowsHide / stdio:'ignore' child-process launches |
heur.megaline |
minified mega-lines (payload appended to one line) |
heur.entropyblob |
high-entropy encoded blobs |
heur.esmshim |
createRequire(import.meta.url) injected into a CommonJS config |
heur.network |
fetch/http/XMLHttpRequest inside a build config |
heur.appended |
code after the config's main module.exports |
heur.markercluster |
the loader's _t_* / global.i global cluster |
heur.installhook |
package.json install scripts that run/fetch remote code (the npm entry vector) |
heur.obfvar |
obfuscation-table variable family (_$_ + 4 hex; the suffix rotates per build) |
Layer 3 — PolinRider repo-side artifacts (same technique, broader campaign):
| Rule | Flags |
|---|---|
ioc.pr.sig |
known PolinRider payload signatures |
ioc.pr.pkg |
known typosquatted npm deps (tailwind-*animation* / tailwindcss-*style*) — critical in package.json |
ioc.pr.pushartifact |
the temp_auto_push.bat / temp_interactive_push.bat force-push scripts |
heur.pr.vscodeautorun |
a .vscode/tasks.json task set to run on folder open (the editor-exec trigger) |
heur.pr.vscodeautotask |
a .vscode/settings.json that lets tasks auto-run without a prompt |
heur.pr.fakefont |
a .woff2/.ttf/.otf whose magic bytes aren't a font — high if it's actually script, medium if it's HTML / a botched download |
heur.pr.jsonappended |
executable JS appended after a JSON document (package.json / config JSON) |
Note from real-world testing: the on-disk dropper is obfuscated, so the plaintext IOCs (C2 IP, keys, addresses) usually don't appear in it — they're decrypted at runtime. The heuristics are what catch the loader on disk; the IOC strings catch decoded payloads, memory dumps, and network/CI logs. Run both (the default).
GitHub Action (this repo ships one; pin it — and its deps — to commit SHAs):
# .github/workflows/malware-scan.yml
name: malware-scan
on: [push, pull_request]
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<sha> # fetch-depth: 0 so history is available
with: { fetch-depth: 0 }
- uses: Storybloq/etherhiding-detector@<sha> # pin to a release SHA
with:
history: 'true' # scan config/package.json across reachable history
fail-on: 'critical,high' # don't break CI on medium/low heuristics--history is the right gate: it only looks at build-config / package.json
paths, so it's precise and never trips on unrelated code. For a deeper, periodic
forensic sweep that also reaches dangling/force-pushed objects, run
--all-objects with fail-on: 'critical' (its HIGH/MEDIUM tiers are triage-only —
see Scope):
- uses: Storybloq/etherhiding-detector@<sha>
with: { all-objects: 'true', fail-on: 'critical' }Inputs are passed to the Action via env and quoted, so they can't inject shell —
but still pin the Action to a commit SHA, not a moving tag.
Pre-commit hook (.git/hooks/pre-commit, or via your hook manager):
#!/bin/sh
node path/to/scan.js --quiet || {
echo "etherhiding-detector flagged a build-config indicator — commit blocked."; exit 1; }npm pre-build guard (package.json):
{ "scripts": { "prebuild": "etherhiding-detector --quiet" } }- Do not run the build.
next.config.jsexecutes on build/dev/start. - Treat the pushing machine as compromised — full AV/EDR scan or reimage.
- Rotate every secret reachable from that machine/build — GitHub & npm tokens, SSH keys, cloud keys, browser sessions, crypto wallets.
- Eradicate from git history. Reset/replace the affected refs with clean
history (e.g. a reviewed force-push or server-side ref reset, per your org's
process). The malicious commits become dangling but stay fetchable by SHA
until GC — re-scan with
--all-objects, and request your host purge them. - Report it so the attacker's reused infra gets burned: VirusTotal, abuse.ch (ThreatFox + MalwareBazaar), AbuseIPDB, and the hosting provider's abuse contact for the C2 IP.
- Lock the repo down: block force-push, require reviewed PRs, and require signed commits (signing would flag the forged "owner" commits).
When sharing findings publicly, don't paste evidence snippets from a private repo unverified — they can leak source. Share IOCs/hashes, not code.
This detects the EtherHiding loader and PolinRider's repo-side artifacts — the
file classes these campaigns plant in a repo (build configs, package.json,
.vscode/ task config, font assets, force-push scripts) where the heuristics have
strong signal and low false-positive rates. It is not a complete supply-chain
scanner, and crucially not a host-level checker.
Not covered — PolinRider is broader than a repo scan. It also compromises the developer machine: browser credential DBs, crypto wallets, shell history, persistence (LaunchAgents / cron / startup items), running processes, and live C2 connections. None of that is visible to a repo scanner — run a dedicated host-level PolinRider checker on any machine you suspect, in addition to this.
Also not covered (use dedicated tools alongside it): lockfile / dependency-tree
analysis, .npmrc / registry config, CI workflow YAML, Dockerfiles, host configs
(Vercel/Codemagic/etc.), transitive package install scripts, and commit-identity
forensics. Treat this as one CI guard, not your whole hardening posture.
(IOCs are DEFANGED below — "[.]" = "." and the XOR keys / dead-drop list live decoded in
scan.js's KNOWN table — so this README does not itself trip the scanner under --all-objects.)
Dropper: obfuscated block appended to next.config.js after module.exports
sha256: e27abe7e810c79d71e8c1681ccd010d7ddbda6a9a34bf1124ba392a36ba9b476
in-process marker: global._V / global.i set to a value shaped "8-NNNN" (e.g. 4827, 4826)
obfuscation-table variable: "_$_" + 4 hex (e.g. 8dbe)
C2: 198[.]105[.]127[.]210 (ports 80/443; paths /verify-human/<id>, /u/f)
RPC hosts (a web build never needs these):
api[.]trongrid[.]io fullnode[.]mainnet[.]aptoslabs[.]com
bsc-dataseed[.]binance[.]org bsc-rpc[.]publicnode[.]com
XOR keys + full TRON/Aptos/BSC dead-drop list: decoded in scan.js -> KNOWN.xorKeys / KNOWN.deadDrops
PolinRider (same technique, distinct instance — these rotate per victim):
in-process marker: global['!'] set to a value shaped "8-NNNN-N" (e.g. 1638-2)
obfuscation-table variable: "_$_" + 4 hex (a different suffix from the EtherHiding one)
fake font: fa-solid-400.woff2 (a JS payload carried under a font extension)
VS Code: .vscode/tasks.json runOn "folderOpen"; settings allowAutomaticTasks
npm typosquats: tailwind-mainanimation, tailwind-autoanimation,
tailwind-animationbased, tailwindcss-style-animate, tailwindcss-style-modify,
tailwindcss-typography-style, tailwindcss-animate-style
force-push scripts: temp_auto_push.bat, temp_interactive_push.bat
(PolinRider also steals from the HOST — browser/wallet/persistence — which a repo
scan cannot see; see "Scope & non-goals".)
This scanner only reads files and runs read-only git commands
(rev-list, cat-file). It never executes, evals, requires, builds, or
network-fetches the code it inspects. The IOCs it ships are inert data. It is a
defensive tool — for scanning code you are authorized to scan.
- Heuristics favor low false positives on build configs. The gate is
--history --fail-on=critical,high(config/package.jsonpaths only — precise).--all-objectsis a forensic-triage mode: it scans every git object incl. dangling ones, so the low-precision config-shape heuristics (mega-line/entropy/network/appended) are suppressed for path-less objects and HIGH/MEDIUM hits are for human triage — gate it at--fail-on=critical. CRITICAL there is genuine indicator literals (C2 / keys / dead-drops / markers / signatures) plus dependency-key typosquats — clean on normal code, but a file that documents these (a security writeup, an IOC list) will legitimately flag; triage it. --all-objectsreaches dangling/orphaned objects in the local store; objects that were already GC'd are gone (re-scan beforegit gc, or scan a fresh full clone).--all-objectscontent-types path-less blobs for VS Code folder-open tasks; a dangling fake-font ortemp_*_push.batwhose only tell is its name (not its content) is best caught in the working tree / ref-reachable history, where the path is known.- Planned: forged-commit detection (committer-timezone / push-date / unsigned anomalies), decoded-stage IOC signatures, and more config ecosystems.
- IOC lists are point-in-time; PRs with new indicators welcome.
- Self-reference:
scan.jsis self-clean — every indicator it ships is base64-encoded, so vendoringscan.jsinto your repo and scanning it (including--all-objects) does not self-flag (regression-tested). The detector's test fixtures still carry indicators as data, and this README's IOC list is defanged for the same reason, so--all-objectson this repo is clean at--fail-on=critical. This repo's CI runs the test suite, not a self-scan.
MIT. No warranty. See LICENSE.