Detect EtherHiding PolinRider variant and generalize unknown-variant heuristic
Adds a third JS_VARIANTS entry for the dot-notation, decoder-array-free payload that slipped past both known-signature matching and the old generic heuristic (which required bracket notation, a decoder array, and eval - all three, none of which this variant used). Also broadens the generic heuristic to OR-based shape categories and adds a separate technique-fingerprint detector for the EtherHiding blockchain C2 pattern, matched against unicode-decoded text so escaping individual strings can't evade it.
This commit is contained in:
@@ -35,12 +35,12 @@ For each repo in your org:
|
|||||||
1. **Clones** the default branch (shallow, `--depth=1`, git hooks disabled)
|
1. **Clones** the default branch (shallow, `--depth=1`, git hooks disabled)
|
||||||
2. **Scans in-process** — reads files as inert text/bytes and pattern-matches known PolinRider signatures. It **never executes** the files it scans (see [Runtime hardening](#runtime-hardening)). Detection is _content-confirmed_: a repo is marked infected only when a real signature matches.
|
2. **Scans in-process** — reads files as inert text/bytes and pattern-matches known PolinRider signatures. It **never executes** the files it scans (see [Runtime hardening](#runtime-hardening)). Detection is _content-confirmed_: a repo is marked infected only when a real signature matches.
|
||||||
3. **Surgically remediates** infected repos — removing only what is confirmed malicious and preserving legitimate code, tasks, fonts, and configs:
|
3. **Surgically remediates** infected repos — removing only what is confirmed malicious and preserving legitimate code, tasks, fonts, and configs:
|
||||||
- **Strips** the appended obfuscated payload (original + rotated variants) from any `.js/.ts/.mjs` file (config files, `App.js`, `vite.config.js`, …), keeping everything before the payload byte-for-byte
|
- **Strips** the appended obfuscated payload (original, rotated + EtherHiding variants) from any `.js/.ts/.mjs` file (config files, `App.js`, `vite.config.js`, …), keeping everything before the payload byte-for-byte
|
||||||
- **Removes the entire `.vscode` directory** when any task/launch entry is malicious — `curl … | bash`, `runOn: folderOpen` auto-runs, C2 hosts, or running an interpreter against a font/asset (e.g. `node ./public/fonts/x.woff2`)
|
- **Removes the entire `.vscode` directory** when any task/launch entry is malicious — `curl … | bash`, `runOn: folderOpen` auto-runs, C2 hosts, or running an interpreter against a font/asset (e.g. `node ./public/fonts/x.woff2`)
|
||||||
- **Removes font carriers strategically.** A font is a _confirmed carrier_ only when it is unreferenced, fails structural font validation (no valid magic / table directory — real fonts, including commercial `.otf` files with embedded license URLs and binary blobs, are trusted), _and_ contains an appended code payload. When a carrier is found, the scanner removes the carrier plus the whole Font-Awesome-named disguise set (`fa-brands/solid/regular-…`) and its `README.md`, while **preserving clean, non-`fa-` fonts in the same directory** (only the leaf `fonts/` dir is removed if nothing clean remains). `fa-`-named fonts with **no** payload are flagged `suspicious` for manual review — never auto-removed.
|
- **Removes font carriers strategically.** A font is a _confirmed carrier_ only when it is unreferenced, fails structural font validation (no valid magic / table directory — real fonts, including commercial `.otf` files with embedded license URLs and binary blobs, are trusted), _and_ contains an appended code payload. When a carrier is found, the scanner removes the carrier plus the whole Font-Awesome-named disguise set (`fa-brands/solid/regular-…`) and its `README.md`, while **preserving clean, non-`fa-` fonts in the same directory** (only the leaf `fonts/` dir is removed if nothing clean remains). `fa-`-named fonts with **no** payload are flagged `suspicious` for manual review — never auto-removed.
|
||||||
- **Deletes** `temp_auto_push.bat`, `temp_interactive_push.bat`, `config.bat`, `branch_structure.json`
|
- **Deletes** `temp_auto_push.bat`, `temp_interactive_push.bat`, `config.bat`, `branch_structure.json`
|
||||||
- **Fixes** `.gitignore` (removes all injected lines — `config.bat`, `temp_*.bat`, `branch_structure.json` — re-adds `.env*` patterns) and untracks committed `.env` files
|
- **Fixes** `.gitignore` (removes all injected lines — `config.bat`, `temp_*.bat`, `branch_structure.json` — re-adds `.env*` patterns) and untracks committed `.env` files
|
||||||
- **Flags for manual review** (never auto-edits): impostor npm dependencies, fetch-and-exec lifecycle scripts, and unknown-but-obfuscated appended code
|
- **Flags for manual review** (never auto-edits): impostor npm dependencies, fetch-and-exec lifecycle scripts, unknown-but-obfuscated appended code (any global-flag + encoded-payload + eval/spawn shape), and EtherHiding-style blockchain C2-resolution + detached-persistence patterns
|
||||||
4. **Opens a PR** on a new branch with a precise summary of every change — your branch protection rules apply, nothing merges automatically
|
4. **Opens a PR** on a new branch with a precise summary of every change — your branch protection rules apply, nothing merges automatically
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -182,8 +182,9 @@ jobs:
|
|||||||
- **`fix` + `commit` is push-event only.** On `pull_request` it's skipped (detached
|
- **`fix` + `commit` is push-event only.** On `pull_request` it's skipped (detached
|
||||||
HEAD; fork PRs can't be pushed) — use `mode: pr` there. Commit-back uses the
|
HEAD; fork PRs can't be pushed) — use `mode: pr` there. Commit-back uses the
|
||||||
default `GITHUB_TOKEN`, whose pushes don't retrigger workflows (no loop).
|
default `GITHUB_TOKEN`, whose pushes don't retrigger workflows (no loop).
|
||||||
- Only **content-confirmed** infections fail by default; impostor deps and
|
- Only **content-confirmed** infections fail by default; impostor deps,
|
||||||
fetch-and-exec scripts are flagged for manual review and (in `fix`/`pr`) still
|
fetch-and-exec scripts, and the generalized/EtherHiding-fingerprint heuristics
|
||||||
|
for unknown variants are flagged for manual review and (in `fix`/`pr`) still
|
||||||
fail the check since they can't be auto-removed.
|
fail the check since they can't be auto-removed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+26
-2
@@ -132,8 +132,13 @@ async function detectJsPayloads(repoDir, findings, rel, isExcluded) {
|
|||||||
let m;
|
let m;
|
||||||
while ((m = re.exec(text))) lastExport = m.index;
|
while ((m = re.exec(text))) lastExport = m.index;
|
||||||
const tail = lastExport >= 0 ? text.slice(lastExport) : text;
|
const tail = lastExport >= 0 ? text.slice(lastExport) : text;
|
||||||
|
const decodedTail = sig.decodeUnicodeEscapes(tail);
|
||||||
|
|
||||||
const h = sig.GENERIC_HEURISTIC;
|
const h = sig.GENERIC_HEURISTIC;
|
||||||
if (h.globalAssignRe.test(tail) && h.obfArrayRe.test(tail) && h.evalRe.test(tail)) {
|
const hasGlobalFlag = h.globalAssignRes.some((globalRe) => globalRe.test(decodedTail));
|
||||||
|
const hasObfEncoding = h.obfArrayRe.test(tail) || sig.hasHeavyUnicodeEscapeDensity(tail);
|
||||||
|
const hasExecPrimitive = h.evalRe.test(decodedTail) || sig.isDetachedSpawn(decodedTail);
|
||||||
|
if (hasGlobalFlag && hasObfEncoding && hasExecPrimitive) {
|
||||||
findings.push({
|
findings.push({
|
||||||
id: "js.payload.heuristic",
|
id: "js.payload.heuristic",
|
||||||
category: "js",
|
category: "js",
|
||||||
@@ -142,7 +147,26 @@ async function detectJsPayloads(repoDir, findings, rel, isExcluded) {
|
|||||||
action: "manual-review",
|
action: "manual-review",
|
||||||
contentConfirmed: false,
|
contentConfirmed: false,
|
||||||
description:
|
description:
|
||||||
"Obfuscated code appended after the last export (global[...] assignment + obfuscated array + eval). Possible unknown PolinRider variant — review manually.",
|
"Obfuscated code appended after the last export (global flag assignment + obfuscated/encoded payload + code-execution primitive). Possible unknown PolinRider variant — review manually.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Technique fingerprint: EtherHiding-style blockchain C2 resolution +
|
||||||
|
// detached persistence — independent of any syntactic obfuscation
|
||||||
|
// shape (dot vs bracket notation, decoder array vs inline escapes), so
|
||||||
|
// it survives future variable/quote-style rotations. Checked against
|
||||||
|
// DECODED text so escaping the RPC method name or "spawn" itself
|
||||||
|
// doesn't evade it.
|
||||||
|
if (sig.hasEthRpcMethodLiteral(decodedTail) && sig.isDetachedSpawn(decodedTail)) {
|
||||||
|
findings.push({
|
||||||
|
id: "js.payload.etherhiding-heuristic",
|
||||||
|
category: "js",
|
||||||
|
file: rel(file),
|
||||||
|
confidence: "high",
|
||||||
|
action: "manual-review",
|
||||||
|
contentConfirmed: false,
|
||||||
|
description:
|
||||||
|
"Code appended after the last export queries Ethereum JSON-RPC methods (eth_getBlockByNumber/eth_getTransactionCount/eth_blockNumber) AND spawns a detached, unref'd child process — the EtherHiding C2-resolution + persistence pattern. Review manually; not auto-removed.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-2
@@ -37,6 +37,17 @@ export const JS_VARIANTS = [
|
|||||||
seeds: ["1111436", "3896884"],
|
seeds: ["1111436", "3896884"],
|
||||||
startRe: /global\s*\[\s*(['"])_V\1\s*\]\s*=/,
|
startRe: /global\s*\[\s*(['"])_V\1\s*\]\s*=/,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "etherhiding",
|
||||||
|
label: "PolinRider payload (EtherHiding variant — dot-notation flag, blockchain-RPC C2 resolution)",
|
||||||
|
confidence: "high",
|
||||||
|
signature: 'global.r=require;typeof module==="object"&&(global.m=module);const http=require(',
|
||||||
|
decoder: null, // no `var _$_xxx=[...]` array — strings are inlined \uXXXX escapes instead
|
||||||
|
seeds: ["eth_getBlockByNumber", "eth_getTransactionCount"],
|
||||||
|
// Deliberately does NOT reference the rotating marker value (e.g. "A11--#") —
|
||||||
|
// only the flag name, so this keeps matching after the marker rotates again.
|
||||||
|
startRe: /global\s*\.\s*i\s*=\s*(['"])/,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Marks the end of legitimate code; the payload is appended after the LAST one.
|
// Marks the end of legitimate code; the payload is appended after the LAST one.
|
||||||
@@ -45,9 +56,34 @@ export const EXPORT_MARKER_RE = /(?:export\s+default|module\.exports)/g;
|
|||||||
// Low-confidence heuristic for UNKNOWN future variants. When an obfuscated blob
|
// Low-confidence heuristic for UNKNOWN future variants. When an obfuscated blob
|
||||||
// is appended after the exports, we flag it for MANUAL REVIEW only — never an
|
// is appended after the exports, we flag it for MANUAL REVIEW only — never an
|
||||||
// automatic strip (avoids damaging legitimate-but-unusual code).
|
// automatic strip (avoids damaging legitimate-but-unusual code).
|
||||||
|
//
|
||||||
|
// Each category below accepts an alternative SHAPE (bracket vs dot notation,
|
||||||
|
// decoder array vs inline-escape density, eval vs detached spawn) rather than
|
||||||
|
// one fixed regex per category — the EtherHiding variant slipped through the
|
||||||
|
// old shape-specific-AND version by using dot notation + no decoder array.
|
||||||
|
// The overall test stays an AND-of-3-categories so a single broad regex can't
|
||||||
|
// trip this on its own.
|
||||||
|
export const GLOBAL_FLAG_RES = [
|
||||||
|
/global\s*\[\s*(['"]).{1,12}?\1\s*\]\s*=/, // global['!']= (bracket notation)
|
||||||
|
/global\s*\.\s*[A-Za-z_$][\w$]{0,11}\s*=/, // global.i= (dot notation)
|
||||||
|
];
|
||||||
|
|
||||||
|
export const OBF_ARRAY_RE = /\bvar\s+_\$?_?[A-Za-z0-9$_]+\s*=\s*\[/; // e.g. var _$_1e42=[...]
|
||||||
|
export const UNICODE_ESCAPE_RE = /\\u[0-9a-fA-F]{4}/g;
|
||||||
|
|
||||||
|
export const SPAWN_CALL_RE = /\bspawn\s*\(/;
|
||||||
|
export const DETACHED_OPTION_RE = /\bdetached\s*:\s*(?:true|!0)\b/; // minifiers use !0 for true
|
||||||
|
export const UNREF_CALL_RE = /\.unref\s*\(\s*\)/;
|
||||||
|
|
||||||
|
export const ETH_RPC_METHOD_RES = [
|
||||||
|
/["']eth_getBlockByNumber["']/,
|
||||||
|
/["']eth_getTransactionCount["']/,
|
||||||
|
/["']eth_blockNumber["']/,
|
||||||
|
];
|
||||||
|
|
||||||
export const GENERIC_HEURISTIC = {
|
export const GENERIC_HEURISTIC = {
|
||||||
globalAssignRe: /global\s*\[\s*(['"]).{1,12}?\1\s*\]\s*=/,
|
globalAssignRes: GLOBAL_FLAG_RES,
|
||||||
obfArrayRe: /\bvar\s+_\$?_?[A-Za-z0-9$_]+\s*=\s*\[/, // e.g. var _$_1e42=[...]
|
obfArrayRe: OBF_ARRAY_RE,
|
||||||
evalRe: /\beval\s*\(/,
|
evalRe: /\beval\s*\(/,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -256,3 +292,61 @@ export function isFaFamilyName(basename) {
|
|||||||
export function isFontDropSidecar(basename) {
|
export function isFontDropSidecar(basename) {
|
||||||
return typeof basename === "string" && FONT_DROP_SIDECARS.includes(basename.toLowerCase());
|
return typeof basename === "string" && FONT_DROP_SIDECARS.includes(basename.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode \uXXXX escapes in raw source TEXT — a plain string substitution
|
||||||
|
* (never eval/Function), the same transform a JS string literal undergoes at
|
||||||
|
* parse time. Real captured EtherHiding samples escape some string literals
|
||||||
|
* (RPC method names, "child_process") but not others in the SAME file —
|
||||||
|
* evidence that uniformly escaping any given substring is a trivial, cheap
|
||||||
|
* evasion. Running the technique-fingerprint checks below against the
|
||||||
|
* decoded text makes them immune to that, regardless of which strings a
|
||||||
|
* future variant chooses to escape.
|
||||||
|
*/
|
||||||
|
export function decodeUnicodeEscapes(text) {
|
||||||
|
if (typeof text !== "string") return "";
|
||||||
|
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if `text` is dominated by \uXXXX-escaped characters rather than plain
|
||||||
|
* source — the signature of a payload that inlines obfuscated strings
|
||||||
|
* instead of using a `var _$_xxx=[...]` decoder array. Requires BOTH a
|
||||||
|
* minimum absolute count (so a file with a couple of legitimate unicode
|
||||||
|
* escapes never trips this) AND a minimum density (escaped chars as a
|
||||||
|
* fraction of text length) — a legitimate i18n/locale file has scattered
|
||||||
|
* escapes among lots of plain text, so density stays low; an inlined
|
||||||
|
* obfuscated payload is mostly escapes. MUST be measured on RAW text, never
|
||||||
|
* on decodeUnicodeEscapes() output (decoding erases the very signal this
|
||||||
|
* measures).
|
||||||
|
*/
|
||||||
|
export function hasHeavyUnicodeEscapeDensity(text, { minCount = 20, minDensity = 0.12 } = {}) {
|
||||||
|
if (typeof text !== "string" || text.length === 0) return false;
|
||||||
|
const matches = text.match(UNICODE_ESCAPE_RE);
|
||||||
|
const count = matches ? matches.length : 0;
|
||||||
|
if (count < minCount) return false;
|
||||||
|
const escapedChars = count * 6; // "\uXXXX" is 6 source characters per escape
|
||||||
|
return escapedChars / text.length >= minDensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if `text` spawns a child process with `detached` set AND calls
|
||||||
|
* `.unref()` on it — the "survive past this process" persistence idiom.
|
||||||
|
* Requires all three signals together since each alone is common in
|
||||||
|
* legitimate daemon/orchestration code. Callers should pass DECODED text.
|
||||||
|
*/
|
||||||
|
export function isDetachedSpawn(text) {
|
||||||
|
if (typeof text !== "string") return false;
|
||||||
|
return SPAWN_CALL_RE.test(text) && DETACHED_OPTION_RE.test(text) && UNREF_CALL_RE.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if `text` contains an Ethereum JSON-RPC method-name literal associated
|
||||||
|
* with EtherHiding-style C2 resolution. Deliberately permissive alone — see
|
||||||
|
* the etherhiding-heuristic finding in scanner.js, which only fires combined
|
||||||
|
* with isDetachedSpawn. Callers should pass DECODED text.
|
||||||
|
*/
|
||||||
|
export function hasEthRpcMethodLiteral(text) {
|
||||||
|
if (typeof text !== "string") return false;
|
||||||
|
return ETH_RPC_METHOD_RES.some((re) => re.test(text));
|
||||||
|
}
|
||||||
@@ -60,6 +60,50 @@ export const ROTATED_PAYLOAD =
|
|||||||
export const GENERIC_PAYLOAD =
|
export const GENERIC_PAYLOAD =
|
||||||
`global['zz']=42;var _$_abcd=[12,34,56,78,90];eval(atob('MSt1'));\n`;
|
`global['zz']=42;var _$_abcd=[12,34,56,78,90];eval(atob('MSt1'));\n`;
|
||||||
|
|
||||||
|
// Confirmed 3rd variant ("EtherHiding"): dot-notation flag, no decoder array
|
||||||
|
// (inlined \uXXXX escapes instead), Ethereum RPC method literals, detached
|
||||||
|
// spawn+unref persistence. Structurally mirrors the real captured sample;
|
||||||
|
// the eval/spawn payload argument is the same harmless "1+1" placeholder
|
||||||
|
// used by ORIGINAL_PAYLOAD/ROTATED_PAYLOAD — never real shellcode.
|
||||||
|
export const ETHERHIDING_PAYLOAD =
|
||||||
|
`global.i="A11--#";global.r=require;typeof module==="object"&&(global.m=module);` +
|
||||||
|
`const http=require("\\u0068\\u0074\\u0074\\u0070");` +
|
||||||
|
`function rc(n){return n;}rc("eth_getBlockByNumber");rc("eth_getTransactionCount");` +
|
||||||
|
`eval(String.fromCharCode(49,43,49));` +
|
||||||
|
`spawn("node",["-e",String.fromCharCode(49,43,49)],{detached:!0,stdio:"ignore"}).unref();\n`;
|
||||||
|
|
||||||
|
// Proves layer 2 GENERALIZES rather than being curve-fit to
|
||||||
|
// ETHERHIDING_PAYLOAD: a hypothetical "variant 4" sharing NONE of the exact
|
||||||
|
// signature/seed strings above, so it must NOT match the etherhiding
|
||||||
|
// JS_VARIANTS entry — but it uses dot notation + heavy \uXXXX-escape density
|
||||||
|
// (broadened heuristic, 2a) and fully-escaped RPC-method names + detached
|
||||||
|
// spawn (fingerprint, 2b, proving the unicode-decode step is load-bearing —
|
||||||
|
// without it this fixture's "eth_getBlockByNumber" would never match since
|
||||||
|
// it's escaped here, same as in the real sample's inconsistent escaping).
|
||||||
|
const HEAVY_ESCAPES = Array.from(
|
||||||
|
{ length: 40 },
|
||||||
|
(_, i) => `\\u${(0x41 + (i % 26)).toString(16).padStart(4, "0")}`,
|
||||||
|
).join("");
|
||||||
|
const ESCAPED_RPC_METHOD = "eth_getBlockByNumber"
|
||||||
|
.split("")
|
||||||
|
.map((c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`)
|
||||||
|
.join("");
|
||||||
|
export const FUTURE_VARIANT_PAYLOAD =
|
||||||
|
`global.z9="${HEAVY_ESCAPES}";` +
|
||||||
|
`const m="${ESCAPED_RPC_METHOD}";` +
|
||||||
|
`const cp=require("child_process");` +
|
||||||
|
`cp.spawn("node",["-e",String.fromCharCode(49,43,49)],{detached:!0,stdio:"ignore"}).unref();\n`;
|
||||||
|
|
||||||
|
// Legitimate, unicode-heavy i18n module — proves hasHeavyUnicodeEscapeDensity
|
||||||
|
// doesn't false-positive on ordinary internationalization code (sparse
|
||||||
|
// escapes among lots of plain text/keys).
|
||||||
|
export const LEGIT_I18N_MODULE = `export default {
|
||||||
|
en: { greeting: "Caf\\u00e9 \\u2014 welcome!", currency: "\\u0024" },
|
||||||
|
ja: { greeting: "\\u3053\\u3093\\u306b\\u3061\\u306f", currency: "\\u00a5" },
|
||||||
|
fr: { greeting: "Bonjour \\u00e0 tous", currency: "\\u20ac" },
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
export function infectedConfig(payload) {
|
export function infectedConfig(payload) {
|
||||||
return LEGIT_CONFIG + "\n" + payload;
|
return LEGIT_CONFIG + "\n" + payload;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
ORIGINAL_PAYLOAD,
|
ORIGINAL_PAYLOAD,
|
||||||
ROTATED_PAYLOAD,
|
ROTATED_PAYLOAD,
|
||||||
GENERIC_PAYLOAD,
|
GENERIC_PAYLOAD,
|
||||||
|
ETHERHIDING_PAYLOAD,
|
||||||
|
FUTURE_VARIANT_PAYLOAD,
|
||||||
infectedConfig,
|
infectedConfig,
|
||||||
goodFont,
|
goodFont,
|
||||||
evilFont,
|
evilFont,
|
||||||
@@ -70,6 +72,43 @@ test("generic obfuscation → manual-review only, suspicious (not infected)", as
|
|||||||
assert.equal(finding.contentConfirmed, false);
|
assert.equal(finding.contentConfirmed, false);
|
||||||
assert.equal(f.severity, "suspicious");
|
assert.equal(f.severity, "suspicious");
|
||||||
assert.equal(f.hasContentConfirmed, false);
|
assert.equal(f.hasContentConfirmed, false);
|
||||||
|
assert.equal(byId(f, "js.payload.etherhiding-heuristic"), undefined, "no RPC/spawn signal in this fixture");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("EtherHiding variant payload (dot-notation, inline escapes) → confirmed strip finding, infected", async () => {
|
||||||
|
const repo = await makeRepo({ "next.config.mjs": infectedConfig(ETHERHIDING_PAYLOAD) });
|
||||||
|
const f = await scanRepo(repo);
|
||||||
|
assert.equal(f.severity, "infected");
|
||||||
|
const finding = byId(f, "js.payload.etherhiding");
|
||||||
|
assert.ok(finding, "expected etherhiding-variant finding");
|
||||||
|
assert.equal(finding.action, "strip-js-payload");
|
||||||
|
assert.equal(finding.contentConfirmed, true);
|
||||||
|
assert.ok(finding.edit.offset > 0);
|
||||||
|
assert.equal(byId(f, "js.payload.heuristic"), undefined);
|
||||||
|
assert.equal(byId(f, "js.payload.etherhiding-heuristic"), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("future unknown variant (dot-notation + heavy escapes + escaped RPC/spawn technique, no matching signature) → generalized layer catches it, suspicious not infected", async () => {
|
||||||
|
const repo = await makeRepo({ "babel.config.js": infectedConfig(FUTURE_VARIANT_PAYLOAD) });
|
||||||
|
const f = await scanRepo(repo);
|
||||||
|
assert.equal(byId(f, "js.payload.original"), undefined);
|
||||||
|
assert.equal(byId(f, "js.payload.rotated"), undefined);
|
||||||
|
assert.equal(byId(f, "js.payload.etherhiding"), undefined);
|
||||||
|
|
||||||
|
const heuristic = byId(f, "js.payload.heuristic");
|
||||||
|
assert.ok(heuristic, "expected broadened generic heuristic to fire");
|
||||||
|
assert.equal(heuristic.action, "manual-review");
|
||||||
|
assert.equal(heuristic.contentConfirmed, false);
|
||||||
|
|
||||||
|
const fingerprint = byId(f, "js.payload.etherhiding-heuristic");
|
||||||
|
assert.ok(fingerprint, "expected EtherHiding technique fingerprint to fire even though the RPC method name is unicode-escaped");
|
||||||
|
assert.equal(fingerprint.action, "manual-review");
|
||||||
|
assert.equal(fingerprint.confidence, "high");
|
||||||
|
assert.equal(fingerprint.contentConfirmed, false);
|
||||||
|
|
||||||
|
assert.equal(f.severity, "suspicious");
|
||||||
|
assert.equal(f.hasContentConfirmed, false);
|
||||||
|
assert.ok(!f.findings.some((x) => x.action === "strip-js-payload"), "must never auto-strip an unconfirmed match");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("malicious .vscode (curl|bash C2 task) → whole-dir remove finding", async () => {
|
test("malicious .vscode (curl|bash C2 task) → whole-dir remove finding", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { test, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
decodeUnicodeEscapes,
|
||||||
|
hasHeavyUnicodeEscapeDensity,
|
||||||
|
isDetachedSpawn,
|
||||||
|
hasEthRpcMethodLiteral,
|
||||||
|
} from "../src/signatures.js";
|
||||||
|
import { cleanupAll, LEGIT_I18N_MODULE, FUTURE_VARIANT_PAYLOAD, GENERIC_PAYLOAD } from "./helpers.js";
|
||||||
|
|
||||||
|
after(cleanupAll);
|
||||||
|
|
||||||
|
test("decodeUnicodeEscapes: plain text passes through; \\uXXXX sequences decode to characters", () => {
|
||||||
|
assert.equal(decodeUnicodeEscapes("plain text"), "plain text");
|
||||||
|
assert.equal(decodeUnicodeEscapes("\\u0068\\u0069"), "hi");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hasHeavyUnicodeEscapeDensity: false on empty/non-string/sparse legit i18n text, true on inlined-escape payload", () => {
|
||||||
|
assert.equal(hasHeavyUnicodeEscapeDensity(""), false);
|
||||||
|
assert.equal(hasHeavyUnicodeEscapeDensity(undefined), false);
|
||||||
|
assert.equal(hasHeavyUnicodeEscapeDensity(LEGIT_I18N_MODULE), false);
|
||||||
|
assert.equal(hasHeavyUnicodeEscapeDensity(FUTURE_VARIANT_PAYLOAD), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isDetachedSpawn: requires spawn + detached(true|!0) + .unref() together; matches minified !0 boolean", () => {
|
||||||
|
assert.equal(isDetachedSpawn(`spawn("node",[])`), false);
|
||||||
|
assert.equal(isDetachedSpawn(`spawn("node",[],{detached:true})`), false); // no .unref()
|
||||||
|
assert.equal(isDetachedSpawn(`const c=spawn("node",["-e","1"],{detached:!0});c.unref();`), true);
|
||||||
|
assert.equal(isDetachedSpawn(`const c=spawn("node",["-e","1"],{detached:true});c.unref();`), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hasEthRpcMethodLiteral: matches known EtherHiding RPC method names only", () => {
|
||||||
|
assert.equal(hasEthRpcMethodLiteral(`fetch("/api/eth_call")`), false);
|
||||||
|
assert.equal(hasEthRpcMethodLiteral(`method:"eth_getBlockByNumber"`), true);
|
||||||
|
assert.equal(hasEthRpcMethodLiteral(`method:"eth_getTransactionCount"`), true);
|
||||||
|
assert.equal(hasEthRpcMethodLiteral(`method:"eth_blockNumber"`), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decode-before-match matters: escaped RPC method name only matches after decoding", () => {
|
||||||
|
const escaped =
|
||||||
|
'"' +
|
||||||
|
"eth_getBlockByNumber"
|
||||||
|
.split("")
|
||||||
|
.map((c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`)
|
||||||
|
.join("") +
|
||||||
|
'"';
|
||||||
|
assert.equal(hasEthRpcMethodLiteral(escaped), false, "raw escaped text should not match directly");
|
||||||
|
assert.equal(hasEthRpcMethodLiteral(decodeUnicodeEscapes(escaped)), true, "decoded text should match");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GENERIC_PAYLOAD (bracket+decoder-array variant) does not trip the new EtherHiding-specific signals", () => {
|
||||||
|
assert.equal(hasEthRpcMethodLiteral(GENERIC_PAYLOAD), false);
|
||||||
|
assert.equal(isDetachedSpawn(GENERIC_PAYLOAD), false);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user