Skip to content

Storybloq/etherhiding-detector

Repository files navigation

etherhiding-detector

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.


Why this exists (the kill chain)

EtherHiding (Mandiant tracks the actor as UNC5142) hides malware on public blockchains so it can't be taken down:

  1. A loader is appended to a build config after module.exports. Anything that runs next build / dev / start — including CI — executes it.
  2. At runtime it reads a transaction pointer from an attacker-controlled TRON account (with Aptos as fallback).
  3. 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).
  4. 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).

Install / run

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/repo

npx etherhiding-detector / a global install also work for ad-hoc local scans, but aren't recommended inside CI for the reason above.

Flags

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.

--history only 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-objects to catch it (this is the normal post-eradication state).

What it detects

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).

Use it in CI (and as the pre-build guard)

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" } }

If it finds something

  1. Do not run the build. next.config.js executes on build/dev/start.
  2. Treat the pushing machine as compromised — full AV/EDR scan or reimage.
  3. Rotate every secret reachable from that machine/build — GitHub & npm tokens, SSH keys, cloud keys, browser sessions, crypto wallets.
  4. 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.
  5. 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.
  6. 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.

Scope & non-goals

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.

Indicators of compromise

(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".)

Safety & responsible use

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.

Limitations & roadmap

  • Heuristics favor low false positives on build configs. The gate is --history --fail-on=critical,high (config/package.json paths only — precise). --all-objects is 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-objects reaches dangling/orphaned objects in the local store; objects that were already GC'd are gone (re-scan before git gc, or scan a fresh full clone).
  • --all-objects content-types path-less blobs for VS Code folder-open tasks; a dangling fake-font or temp_*_push.bat whose 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.js is self-clean — every indicator it ships is base64-encoded, so vendoring scan.js into 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-objects on this repo is clean at --fail-on=critical. This repo's CI runs the test suite, not a self-scan.

License

MIT. No warranty. See LICENSE.

About

No description, website, or topics provided.

Resources

License

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors