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:
@@ -60,6 +60,50 @@ export const ROTATED_PAYLOAD =
|
||||
export const GENERIC_PAYLOAD =
|
||||
`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) {
|
||||
return LEGIT_CONFIG + "\n" + payload;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
ORIGINAL_PAYLOAD,
|
||||
ROTATED_PAYLOAD,
|
||||
GENERIC_PAYLOAD,
|
||||
ETHERHIDING_PAYLOAD,
|
||||
FUTURE_VARIANT_PAYLOAD,
|
||||
infectedConfig,
|
||||
goodFont,
|
||||
evilFont,
|
||||
@@ -70,6 +72,43 @@ test("generic obfuscation → manual-review only, suspicious (not infected)", as
|
||||
assert.equal(finding.contentConfirmed, false);
|
||||
assert.equal(f.severity, "suspicious");
|
||||
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 () => {
|
||||
|
||||
@@ -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