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:
+26
-2
@@ -132,8 +132,13 @@ async function detectJsPayloads(repoDir, findings, rel, isExcluded) {
|
||||
let m;
|
||||
while ((m = re.exec(text))) lastExport = m.index;
|
||||
const tail = lastExport >= 0 ? text.slice(lastExport) : text;
|
||||
const decodedTail = sig.decodeUnicodeEscapes(tail);
|
||||
|
||||
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({
|
||||
id: "js.payload.heuristic",
|
||||
category: "js",
|
||||
@@ -142,7 +147,26 @@ async function detectJsPayloads(repoDir, findings, rel, isExcluded) {
|
||||
action: "manual-review",
|
||||
contentConfirmed: false,
|
||||
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"],
|
||||
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.
|
||||
@@ -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
|
||||
// is appended after the exports, we flag it for MANUAL REVIEW only — never an
|
||||
// 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 = {
|
||||
globalAssignRe: /global\s*\[\s*(['"]).{1,12}?\1\s*\]\s*=/,
|
||||
obfArrayRe: /\bvar\s+_\$?_?[A-Za-z0-9$_]+\s*=\s*\[/, // e.g. var _$_1e42=[...]
|
||||
globalAssignRes: GLOBAL_FLAG_RES,
|
||||
obfArrayRe: OBF_ARRAY_RE,
|
||||
evalRe: /\beval\s*\(/,
|
||||
};
|
||||
|
||||
@@ -256,3 +292,61 @@ export function isFaFamilyName(basename) {
|
||||
export function isFontDropSidecar(basename) {
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user