add malware scan

This commit is contained in:
SennayT
2026-08-07 07:45:14 +00:00
parent 73edf1fffa
commit 87a6c6df84
4 changed files with 560 additions and 14 deletions

View File

@@ -35,23 +35,50 @@ const CONFIG = {
"tailwind.config.js",
"tailwind.config.ts",
"tailwind.config.cjs",
"tailwind.config.mjs",
"tailwind.js",
"postcss.config.js",
"postcss.config.ts",
"postcss.config.mjs",
"postcss.config.cjs",
"babel.config.js",
"babel.config.ts",
"babel.config.mjs",
"babel.config.cjs",
"next.config.js",
"next.config.ts",
"next.config.mjs",
"next.config.cjs",
"eslint.config.js",
"eslint.config.ts",
"eslint.config.mjs",
"eslint.config.cjs",
"astro.config.mjs",
"astro.config.js",
"vite.config.js",
"vite.config.ts",
"vite.config.mjs",
"vite.config.cjs",
"webpack.config.js",
"webpack.mix.js",
"svelte.config.js",
"nuxt.config.ts",
],
// Font files — the campaign appends JS payloads to web fonts, which are
// never executed directly but are fetched by the build and used as a
// staging blob. Binary formats, so they are read as latin1.
fontExtensions: [".woff", ".woff2", ".ttf", ".otf", ".eot"],
// Minimum number of \uXXXX escape sequences in one file before it is
// treated as deliberately obfuscated. Legitimate source files use a
// handful at most; PolinRider samples carry 400600.
maxLegitUnicodeEscapes: 20,
// Any single line longer than this inside a config file means code was
// appended past the real export block.
maxLegitConfigLineLength: 1000,
// Files intentionally containing malware indicators for scanner logic/tests.
// These filenames are skipped before malware rules are evaluated.
ignoredFilenames: ["scan.js"],
@@ -187,14 +214,166 @@ const RULES = [
},
},
{
id: "POLINRIDER-018",
severity: "CRITICAL",
description:
"Unicode-escape string obfuscation — the payload writes plain ASCII literals such as require and https as \\u0068\\u0074\\u0074\\u0070 so that grep, code review, and GitHub diff search cannot see the API calls it makes",
test(content) {
// Only printable-ASCII escapes count. Minified vendor bundles legitimately
// carry hundreds of \uXXXX escapes, but those decode to emoji, diacritics
// and CJK ranges — escaping ASCII that could have been written literally
// has no purpose other than hiding it from a reader.
const asciiEscapes = (content.match(/\\u00[0-7][0-9a-fA-F]/g) || []).filter(
(e) => {
const code = parseInt(e.slice(2), 16);
return code >= 0x20 && code <= 0x7e;
},
);
return asciiEscapes.length >= CONFIG.maxLegitUnicodeEscapes
? [
`${asciiEscapes.length} printable-ASCII \\uXXXX escapes (threshold: ${CONFIG.maxLegitUnicodeEscapes})`,
]
: [];
},
},
{
id: "POLINRIDER-019",
severity: "CRITICAL",
description:
"Ethereum dead-drop C2 resolver — the attacker wallet's latest transaction encodes the C2 IP addresses in the 'to' field, so the C2 can be rotated without touching the implant",
test(_content, _filePath, _lines, decoded) {
const iocs = [
"0xa322e5f3d311d3080e6f0121063e9adc2490ef1a",
"eth.blockscout.com",
"eth_getBlockByNumber",
"eth_getTransactionCount",
"eth_blockNumber",
"ethereum-rpc.publicnode.com",
"eth-mainnet.public.blastapi.io",
"1rpc.io/eth",
"eth.drpc.org",
];
const hay = decoded.toLowerCase();
return iocs.filter((i) => hay.includes(i));
},
},
{
id: "POLINRIDER-020",
severity: "CRITICAL",
description:
"Remote code execution stager — fetches a payload over HTTP, XOR-decrypts it, eval()s it in-process and re-launches it as a detached hidden node -e child process that survives the build exiting",
test(_content, _filePath, _lines, decoded) {
const hits = [];
if (/spawn\s*\(\s*["']node["']\s*,\s*\[\s*["']-e["']/.test(decoded))
hits.push('spawn("node", ["-e", <remote payload>])');
if (/detached\s*:\s*(!0|true)/.test(decoded))
hits.push("detached child process");
if (/stdio\s*:\s*["']ignore["']/.test(decoded))
hits.push('stdio:"ignore" (output suppressed)');
if (/\beval\s*\(\s*\w+\s*\+/.test(decoded))
hits.push("eval() of concatenated remote string");
if (/x-payload-b64/i.test(decoded))
hits.push("x-payload-b64 C2 response header");
// Only report when this is a genuine stager, not an isolated keyword.
return hits.length >= 2 ? hits : [];
},
},
{
id: "POLINRIDER-021",
severity: "CRITICAL",
description:
"Campaign marker + Node internals capture via dot notation — the implant stores its victim/campaign ID and re-exposes require/module on globalThis so later stages can load native modules from inside an ES module",
test(_content, _filePath, _lines, decoded) {
const hits = [];
const marker = decoded.match(
/global\s*\.\s*[a-zA-Z_$]\w*\s*=\s*["']([A-Z]{0,2}\d[\d-]{3,})["']/,
);
if (marker) hits.push(`campaign marker: "${marker[1]}"`);
if (/global\s*\.\s*\w+\s*=\s*require\b/.test(decoded))
hits.push("global.<x> = require");
if (/global\s*\.\s*\w+\s*=\s*module\b/.test(decoded))
hits.push("global.<x> = module");
return hits;
},
},
{
id: "POLINRIDER-022",
severity: "CRITICAL",
description:
"Web font file carrying an executable payload — .woff/.woff2 files are treated as opaque binary assets by reviewers and linters, so the campaign uses them to smuggle JavaScript past code review",
test(content, filePath) {
const ext = path.extname(filePath).toLowerCase();
if (!CONFIG.fontExtensions.includes(ext)) return [];
const hits = [];
const magic = content.slice(0, 4);
const expected = { ".woff": "wOFF", ".woff2": "wOF2", ".otf": "OTTO" };
if (expected[ext] && magic !== expected[ext]) {
hits.push(
`bad magic bytes: expected "${expected[ext]}", got "${magic.replace(/[^\x20-\x7e]/g, ".")}"`,
);
}
const codeMarkers = [
"require(",
"eval(",
"child_process",
"global.",
"createRequire",
"process.env",
];
const found = codeMarkers.filter((m) => content.includes(m));
if (found.length > 0) {
hits.push(`embedded JS markers: ${found.join(", ")}`);
}
return hits;
},
},
{
id: "POLINRIDER-023",
severity: "CRITICAL",
description:
".vscode/tasks.json configured to auto-execute on folder open — gives the campaign code execution the moment a developer opens the repo in VS Code, before any build or install command is run",
test(content, filePath) {
if (!/\.vscode[\\/]tasks\.json$/.test(filePath.replace(/\\/g, "/")))
return [];
const hits = [];
if (/"runOn"\s*:\s*"folderOpen"/.test(content))
hits.push('runOn: "folderOpen" (executes without user action)');
const cmd = content.match(/"command"\s*:\s*"([^"]{0,120})"/);
if (cmd && /node|curl|wget|powershell|bash|-e\b|eval/i.test(cmd[1]))
hits.push(`command: "${cmd[1]}"`);
return hits.length >= 1 ? hits : [];
},
},
{
id: "POLINRIDER-024",
severity: "HIGH",
description:
".gitignore lists this campaign's persistence artifacts — the implant appends these entries so its own dropped files never appear in git status and the developer never sees them",
test(content, filePath) {
if (path.basename(filePath) !== ".gitignore") return [];
const lines = content.split("\n").map((l) => l.trim());
return CONFIG.persistenceArtifacts.filter((a) => lines.includes(a)).map(
(a) => `.gitignore hides "${a}"`,
);
},
},
// ── Tier 2: Behavioral / structural indicators ─────────────────────────────
{
id: "POLINRIDER-009",
severity: "HIGH",
description:
"Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, and BSC as dead-drop C2 resolvers",
test(content) {
"Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, BSC and Ethereum as dead-drop C2 resolvers",
test(_content, _filePath, _lines, decoded) {
const endpoints = [
"trongrid.io",
"aptoslabs.com",
@@ -202,7 +381,7 @@ const RULES = [
"bsc-rpc.publicnode.com",
"eth_getTransactionByHash",
];
return endpoints.filter((e) => content.includes(e));
return endpoints.filter((e) => decoded.includes(e));
},
},
@@ -210,11 +389,10 @@ const RULES = [
id: "POLINRIDER-010",
severity: "HIGH",
description:
"Hidden process spawn with windowsHide:true — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly",
test(content) {
return /windowsHide\s*:\s*true/.test(content)
? ["windowsHide:true found"]
: [];
"Hidden process spawn with windowsHide — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly. Matches both true and its minified form !0",
test(_content, _filePath, _lines, decoded) {
const m = decoded.match(/windowsHide\s*:\s*(true|!0)/);
return m ? [`windowsHide:${m[1]} found`] : [];
},
},
@@ -237,7 +415,10 @@ const RULES = [
severity: "HIGH",
description:
"Payload hidden after large horizontal whitespace (>100 spaces on one line) — evasion technique to hide code off-screen in editors and GitHub diff views",
test(content, _filePath, lines) {
test(_content, filePath, lines) {
// Binary assets contain long runs of 0x20 as padding — not an indicator.
if (CONFIG.fontExtensions.includes(path.extname(filePath).toLowerCase()))
return [];
const hits = [];
lines.forEach((line, i) => {
const spaceRun = line.match(/\s{100,}/);
@@ -269,6 +450,27 @@ const RULES = [
},
},
{
id: "POLINRIDER-025",
severity: "HIGH",
description:
"Minified code appended past the end of a config file — real config files are hand-written and line-wrapped; a single multi-thousand-character line means a payload was concatenated onto the original file",
test(_content, filePath, lines) {
const base = path.basename(filePath).toLowerCase();
if (!CONFIG.targetedFilenames.some((f) => f.toLowerCase() === base))
return [];
const hits = [];
lines.forEach((line, i) => {
if (line.length > CONFIG.maxLegitConfigLineLength) {
hits.push(
`line ${i + 1}: ${line.length} chars (threshold: ${CONFIG.maxLegitConfigLineLength})`,
);
}
});
return hits;
},
},
{
id: "POLINRIDER-014",
severity: "HIGH",
@@ -330,25 +532,58 @@ const RULES = [
// ─── Scanner Engine ────────────────────────────────────────────────────────────
/**
* Resolve \uXXXX and \xXX escapes so string-matching rules see the real API
* calls. The campaign writes every literal as escapes specifically to defeat
* grep, so rules that match on plain text must run against this form.
* The decoded text is appended to the original rather than replacing it, so a
* rule can still match either representation with one pass.
*/
function deobfuscate(content) {
if (!/\\[ux]/.test(content)) return content;
const decoded = content
.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (m, h) => {
try {
return String.fromCodePoint(parseInt(h, 16));
} catch {
return m;
}
})
.replace(/\\u([0-9a-fA-F]{4})/g, (_m, h) =>
String.fromCharCode(parseInt(h, 16)),
)
.replace(/\\x([0-9a-fA-F]{2})/g, (_m, h) =>
String.fromCharCode(parseInt(h, 16)),
);
return content + "\n/* --- deobfuscated --- */\n" + decoded;
}
function scanFile(filePath) {
if (shouldIgnoreFile(filePath)) {
return { filePath, findings: [], skipped: true };
}
// Fonts are binary. latin1 maps bytes 1:1 to chars, so magic-byte checks and
// ASCII payload searches both work without mangling the content.
const isBinary = CONFIG.fontExtensions.includes(
path.extname(filePath).toLowerCase(),
);
let content;
try {
content = fs.readFileSync(filePath, "utf8");
content = fs.readFileSync(filePath, isBinary ? "latin1" : "utf8");
} catch (err) {
return { filePath, error: err.message, findings: [] };
}
const lines = content.split("\n");
const decoded = deobfuscate(content);
const findings = [];
for (const rule of RULES) {
let matches;
try {
matches = rule.test(content, filePath, lines);
matches = rule.test(content, filePath, lines, decoded);
} catch (err) {
matches = [`[rule error: ${err.message}]`];
}
@@ -388,6 +623,7 @@ function walkDir(dir, results = []) {
} else if (entry.isFile() && !shouldIgnoreFile(full)) {
const ext = path.extname(entry.name).toLowerCase();
const base = entry.name.toLowerCase();
const rel = full.replace(/\\/g, "/");
// Scan all JS/TS config files + any file matching a targeted name
const isTargetedName = CONFIG.targetedFilenames.some(
@@ -399,8 +635,18 @@ function walkDir(dir, results = []) {
const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes(
ext,
);
const isFont = CONFIG.fontExtensions.includes(ext);
const isGitignore = base === ".gitignore";
const isVsCodeTask = /\.vscode\/tasks\.json$/.test(rel);
if (isTargetedName || isPersistenceArtifact || isJsLike) {
if (
isTargetedName ||
isPersistenceArtifact ||
isJsLike ||
isFont ||
isGitignore ||
isVsCodeTask
) {
results.push(full);
}
}
@@ -532,10 +778,141 @@ function printReport(allResults, { json = false, outputFile = null } = {}) {
return infected.length > 0;
}
// ─── Self-test ─────────────────────────────────────────────────────────────────
/**
* Runs the rule set against synthetic samples. Guards the two properties that
* matter: the live payload is still caught, and minified vendor bundles that
* legitimately contain \uXXXX escapes are still not flagged.
* Run with: node scan.js --self-test
*/
function selfTest() {
const os = require("os");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "polinrider-selftest-"));
const write = (name, body) => {
const p = path.join(tmp, name);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
return p;
};
const esc = (s) =>
[...s].map((c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0")).join("");
const cases = [];
// 1. The live payload shape: ASCII-escaped strings + ETH dead drop + stager.
cases.push({
name: "infected postcss.config.js",
file: write(
"infected/postcss.config.js",
`export default { plugins: {} };` +
" ".repeat(300) +
`global.i="A8-4299";global.r=require;global.m=module;` +
`const http=require("${esc("http")}"),{spawn}=require("${esc("child_process")}");` +
`S="0xa322E5f3D311D3080e6f0121063e9aDC2490Ef1a".toLowerCase(),` +
`I="${esc("https://eth.blockscout.com/api")}";` +
`rc(t,"${esc("eth_getBlockByNumber")}");eval(r+o);` +
`spawn("node",["-e",r+o],{detached:!0,stdio:"${esc("ignore")}",windowsHide:!0});`,
),
expect: true,
});
// 2. Clean config — must stay silent.
cases.push({
name: "clean postcss.config.js",
file: write(
"clean/postcss.config.js",
"export default {\n plugins: {\n tailwindcss: {},\n autoprefixer: {},\n },\n};\n",
),
expect: false,
});
// 3. Minified vendor bundle with many non-ASCII escapes — must stay silent.
cases.push({
name: "minified vendor bundle",
file: write(
"vendor/emoji.min.js",
"var e=[" +
Array.from({ length: 400 }, (_, i) => `"\\ud83d\\ude${(i % 90) + 10}"`).join(",") +
"];",
),
expect: false,
});
// 4. Font carrying an appended JS payload.
cases.push({
name: "trojanised .woff2",
file: write(
"fonts/bad.woff2",
"wOF2" + "".repeat(64) + 'require("child_process");global.x=1;eval(a+b);',
),
expect: true,
});
// 5. Clean font — correct magic, no code markers.
cases.push({
name: "clean .woff2",
file: write("fonts/good.woff2", "wOF2" + "".repeat(200)),
expect: false,
});
// 6. .gitignore hiding persistence artifacts.
cases.push({
name: ".gitignore with persistence artifacts",
file: write(
"ignore/.gitignore",
"node_modules/\ntemp_auto_push.bat\nbranch_structure.json\n",
),
expect: true,
});
// 7. VS Code task auto-executing on folder open.
cases.push({
name: ".vscode/tasks.json auto-exec",
file: write(
"vscode/.vscode/tasks.json",
JSON.stringify({
version: "2.0.0",
tasks: [
{
label: "build",
command: "node -e require('http')",
runOptions: { runOn: "folderOpen" },
},
],
}),
),
expect: true,
});
let failed = 0;
for (const c of cases) {
const { findings } = scanFile(c.file);
const detected = findings.length > 0;
const ok = detected === c.expect;
if (!ok) failed++;
const ids = findings.map((f) => f.id).join(", ") || "none";
console.log(
` ${ok ? `${ANSI.green}PASS${ANSI.reset}` : `${ANSI.red}FAIL${ANSI.reset}`} ` +
`${c.name} — expected ${c.expect ? "detection" : "clean"}, got: ${ids}`,
);
}
fs.rmSync(tmp, { recursive: true, force: true });
console.log(
failed === 0
? `\n${ANSI.green}${ANSI.bold}Self-test passed (${cases.length}/${cases.length}).${ANSI.reset}\n`
: `\n${ANSI.red}${ANSI.bold}Self-test FAILED: ${failed}/${cases.length} case(s).${ANSI.reset}\n`,
);
process.exit(failed === 0 ? 0 : 1);
}
// ─── CLI Entry Point ───────────────────────────────────────────────────────────
function main() {
const args = process.argv.slice(2);
if (args.includes("--self-test")) return selfTest();
const jsonFlag = args.includes("--json");
const outputFileIdx = args.indexOf("--output");
const outputFile = outputFileIdx !== -1 ? args[outputFileIdx + 1] : null;