mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
571 lines
18 KiB
JavaScript
571 lines
18 KiB
JavaScript
/**
|
||
* PolinRider / Famous Chollima Supply-Chain Malware Scanner
|
||
*
|
||
* Detects the specific injection pattern used in the PolinRider campaign
|
||
* attributed to North Korean APT (Void Dokkaebi / Famous Chollima / UNC5342).
|
||
*
|
||
* IOCs sourced from:
|
||
* - Direct analysis of the injected tailwind.config.js sample
|
||
* - Socket Security report (May 2026) on roberts/leads compromise
|
||
* - PolinRider technical analysis report (Trend Micro / safedep.io)
|
||
*
|
||
* Zero external dependencies — runs on any Node.js >= 14.
|
||
*/
|
||
|
||
"use strict";
|
||
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const crypto = require("crypto");
|
||
|
||
// ─── Configuration ────────────────────────────────────────────────────────────
|
||
|
||
const CONFIG = {
|
||
// Maximum legitimate size for JS config files.
|
||
// Real tailwind/postcss/babel configs are rarely > 3 KB.
|
||
// Injected files jump to 5–8 KB instantly.
|
||
maxLegitConfigBytes: 3072,
|
||
|
||
// Minimum whitespace run on a single line that signals hidden payload.
|
||
// The campaign uses ~280–510 spaces to push payload off-screen.
|
||
minSuspiciousInlineSpaces: 100,
|
||
|
||
// Files that are high-value injection targets for this campaign.
|
||
targetedFilenames: [
|
||
"tailwind.config.js",
|
||
"tailwind.config.ts",
|
||
"tailwind.config.cjs",
|
||
"tailwind.js",
|
||
"postcss.config.js",
|
||
"postcss.config.mjs",
|
||
"postcss.config.cjs",
|
||
"babel.config.js",
|
||
"babel.config.cjs",
|
||
"next.config.js",
|
||
"next.config.mjs",
|
||
"next.config.cjs",
|
||
"astro.config.mjs",
|
||
"astro.config.js",
|
||
"vite.config.js",
|
||
"vite.config.ts",
|
||
"webpack.config.js",
|
||
"webpack.mix.js",
|
||
],
|
||
|
||
// Filesystem paths that indicate active persistence mechanisms
|
||
persistenceArtifacts: [
|
||
"temp_auto_push.bat",
|
||
"temp_interactive_push.bat",
|
||
"branch_structure.json",
|
||
// Note: queue.bat and .plist are OS-level; checked separately
|
||
],
|
||
};
|
||
|
||
// ─── IOC Definitions ──────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Each rule has:
|
||
* id – unique rule identifier for reporting
|
||
* severity – CRITICAL | HIGH | MEDIUM
|
||
* description – human-readable explanation
|
||
* test(content, filePath, lines) – returns array of match details or []
|
||
*/
|
||
const RULES = [
|
||
// ── Tier 1: Definitive campaign signatures ─────────────────────────────────
|
||
|
||
{
|
||
id: "POLINRIDER-001",
|
||
severity: "CRITICAL",
|
||
description:
|
||
"PolinRider string-shuffler variable _$_1e42 — present in every known sample of this campaign",
|
||
test(content) {
|
||
const matches = [];
|
||
const re = /_\$_1e42/g;
|
||
let m;
|
||
while ((m = re.exec(content)) !== null) {
|
||
matches.push(`offset ${m.index}`);
|
||
}
|
||
return matches;
|
||
},
|
||
},
|
||
|
||
{
|
||
id: "POLINRIDER-002",
|
||
severity: "CRITICAL",
|
||
description:
|
||
"PolinRider campaign marker global['!'] assignment — used to route C2 traffic",
|
||
test(content) {
|
||
const matches = [];
|
||
const re = /global\s*\[\s*['"]!\s*['"]\s*\]\s*=/g;
|
||
let m;
|
||
while ((m = re.exec(content)) !== null) {
|
||
const snippet = content
|
||
.slice(m.index, m.index + 40)
|
||
.replace(/\n/g, "\\n");
|
||
matches.push(`"${snippet}"`);
|
||
}
|
||
return matches;
|
||
},
|
||
},
|
||
|
||
{
|
||
id: "POLINRIDER-003",
|
||
severity: "CRITICAL",
|
||
description:
|
||
'PolinRider shuffler seed string "rmcej%otb%" — embedded in the string-decryption bootstrap of the specific variant targeting this repo',
|
||
test(content) {
|
||
return content.includes("rmcej%otb%") ? ["seed string found"] : [];
|
||
},
|
||
},
|
||
|
||
{
|
||
id: "POLINRIDER-004",
|
||
severity: "CRITICAL",
|
||
description:
|
||
"Known C2 IP addresses associated with PolinRider infrastructure",
|
||
test(content) {
|
||
const knownC2 = ["198.105.127.210", "166.88.54.158", "23.27.202.27"];
|
||
return knownC2.filter((ip) => content.includes(ip));
|
||
},
|
||
},
|
||
|
||
{
|
||
id: "POLINRIDER-005",
|
||
severity: "CRITICAL",
|
||
description:
|
||
"Known TRON blockchain wallet addresses used as dead-drop C2 resolvers",
|
||
test(content) {
|
||
const wallets = [
|
||
"TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP",
|
||
"TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG",
|
||
];
|
||
return wallets.filter((w) => content.includes(w));
|
||
},
|
||
},
|
||
|
||
{
|
||
id: "POLINRIDER-006",
|
||
severity: "CRITICAL",
|
||
description:
|
||
"Known Aptos blockchain addresses used as fallback dead-drop resolvers",
|
||
test(content) {
|
||
const addrs = [
|
||
"0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e",
|
||
"0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3",
|
||
];
|
||
return addrs.filter((a) => content.includes(a));
|
||
},
|
||
},
|
||
|
||
{
|
||
id: "POLINRIDER-007",
|
||
severity: "CRITICAL",
|
||
description:
|
||
"Known XOR decryption keys used to decrypt the second-stage payload from BSC transactions",
|
||
test(content) {
|
||
const keys = ["2[gWfGj;<:-93Z^C", "m6:tTh^D)cBz?NM]"];
|
||
return keys.filter((k) => content.includes(k));
|
||
},
|
||
},
|
||
|
||
{
|
||
id: "POLINRIDER-008",
|
||
severity: "CRITICAL",
|
||
description:
|
||
"Known SHA-256 hash of compromised tailwind.js file (Socket Security, 2026-05-31)",
|
||
test(content) {
|
||
const knownHashes = new Set([
|
||
"96afdba882046385242cbed46871e41147c8055c5d9eff7460847b2c01a77dc3",
|
||
"522b28a2f78771715497ba53729d4ab9a50e982322c391379f3bddf7c8cb363f",
|
||
]);
|
||
const hash = crypto.createHash("sha256").update(content).digest("hex");
|
||
return knownHashes.has(hash) ? [`SHA-256: ${hash}`] : [];
|
||
},
|
||
},
|
||
|
||
// ── 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) {
|
||
const endpoints = [
|
||
"trongrid.io",
|
||
"aptoslabs.com",
|
||
"bsc-dataseed.binance.org",
|
||
"bsc-rpc.publicnode.com",
|
||
"eth_getTransactionByHash",
|
||
];
|
||
return endpoints.filter((e) => content.includes(e));
|
||
},
|
||
},
|
||
|
||
{
|
||
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"]
|
||
: [];
|
||
},
|
||
},
|
||
|
||
{
|
||
id: "POLINRIDER-011",
|
||
severity: "HIGH",
|
||
description:
|
||
"Duplicate createRequire injection at file top — campaign restores require() for ES module environments by prepending two identical import statements",
|
||
test(content) {
|
||
const matches =
|
||
content.match(/import\s*\{\s*createRequire\s*\}\s*from/g) || [];
|
||
return matches.length >= 2
|
||
? [`Found ${matches.length} duplicate createRequire imports`]
|
||
: [];
|
||
},
|
||
},
|
||
|
||
{
|
||
id: "POLINRIDER-012",
|
||
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) {
|
||
const hits = [];
|
||
lines.forEach((line, i) => {
|
||
const spaceRun = line.match(/\s{100,}/);
|
||
if (spaceRun) {
|
||
hits.push(`line ${i + 1}: ${spaceRun[0].length} consecutive spaces`);
|
||
}
|
||
});
|
||
return hits;
|
||
},
|
||
},
|
||
|
||
{
|
||
id: "POLINRIDER-013",
|
||
severity: "HIGH",
|
||
description:
|
||
"Config file size anomaly — legitimate tailwind/postcss/babel configs are < 3 KB; injected files jump to 5–8 KB",
|
||
test(content, filePath) {
|
||
const bytes = Buffer.byteLength(content, "utf8");
|
||
const base = path.basename(filePath).toLowerCase();
|
||
const isTargeted = CONFIG.targetedFilenames.some(
|
||
(f) => f.toLowerCase() === base,
|
||
);
|
||
if (isTargeted && bytes > CONFIG.maxLegitConfigBytes) {
|
||
return [
|
||
`${bytes} bytes (threshold: ${CONFIG.maxLegitConfigBytes} bytes)`,
|
||
];
|
||
}
|
||
return [];
|
||
},
|
||
},
|
||
|
||
{
|
||
id: "POLINRIDER-014",
|
||
severity: "HIGH",
|
||
description:
|
||
"Persistence artifact detected — files used by temp_auto_push.bat to rewrite git history and propagate infection to all branches",
|
||
test(_content, filePath) {
|
||
const base = path.basename(filePath);
|
||
return CONFIG.persistenceArtifacts.includes(base) ? [base] : [];
|
||
},
|
||
},
|
||
|
||
// ── Tier 3: Supporting behavioral indicators ───────────────────────────────
|
||
|
||
{
|
||
id: "POLINRIDER-015",
|
||
severity: "MEDIUM",
|
||
description:
|
||
"Campaign marker pattern — numeric string assigned to global['!'], used to select C2 tier (alpha/beta/fallback)",
|
||
test(content) {
|
||
const matches = [];
|
||
// Matches patterns like '8-3317', '9-0264-2', '8-3946-1', 'A4-1928'
|
||
const re =
|
||
/global\s*\[\s*['"]!\s*['"]\s*\]\s*=\s*['"]([A-Z]?\d[\d-]+)['"]/g;
|
||
let m;
|
||
while ((m = re.exec(content)) !== null) {
|
||
matches.push(`marker value: "${m[1]}"`);
|
||
}
|
||
return matches;
|
||
},
|
||
},
|
||
|
||
{
|
||
id: "POLINRIDER-016",
|
||
severity: "MEDIUM",
|
||
description:
|
||
"sfL obfuscation function — secondary string-shuffler present in multi-stage loader variant",
|
||
test(content) {
|
||
// sfL appears as a named function used to decode the larger payload blob
|
||
const occurrences = (content.match(/\bsfL\b/g) || []).length;
|
||
return occurrences >= 3 ? [`sfL referenced ${occurrences} times`] : [];
|
||
},
|
||
},
|
||
|
||
{
|
||
id: "POLINRIDER-017",
|
||
severity: "MEDIUM",
|
||
description:
|
||
"global require/module injection — bootloader dynamically restores Node.js internals to bypass ES module restrictions",
|
||
test(content) {
|
||
const hits = [];
|
||
if (/global\s*\[.*\]\s*=\s*require/.test(content))
|
||
hits.push("global[x] = require");
|
||
if (/global\s*\[.*module.*\]\s*=\s*module/.test(content))
|
||
hits.push("global[x] = module");
|
||
return hits;
|
||
},
|
||
},
|
||
];
|
||
|
||
// ─── Scanner Engine ────────────────────────────────────────────────────────────
|
||
|
||
function scanFile(filePath) {
|
||
let content;
|
||
try {
|
||
content = fs.readFileSync(filePath, "utf8");
|
||
} catch (err) {
|
||
return { filePath, error: err.message, findings: [] };
|
||
}
|
||
|
||
const lines = content.split("\n");
|
||
const findings = [];
|
||
|
||
for (const rule of RULES) {
|
||
let matches;
|
||
try {
|
||
matches = rule.test(content, filePath, lines);
|
||
} catch (err) {
|
||
matches = [`[rule error: ${err.message}]`];
|
||
}
|
||
|
||
if (matches && matches.length > 0) {
|
||
findings.push({
|
||
id: rule.id,
|
||
severity: rule.severity,
|
||
description: rule.description,
|
||
matches,
|
||
});
|
||
}
|
||
}
|
||
|
||
return { filePath, findings };
|
||
}
|
||
|
||
function walkDir(dir, results = []) {
|
||
let entries;
|
||
try {
|
||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||
} catch {
|
||
return results;
|
||
}
|
||
|
||
for (const entry of entries) {
|
||
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
||
|
||
const full = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) {
|
||
walkDir(full, results);
|
||
} else if (entry.isFile()) {
|
||
const ext = path.extname(entry.name).toLowerCase();
|
||
const base = entry.name.toLowerCase();
|
||
|
||
// Scan all JS/TS config files + any file matching a targeted name
|
||
const isTargetedName = CONFIG.targetedFilenames.some(
|
||
(f) => f.toLowerCase() === base,
|
||
);
|
||
const isPersistenceArtifact = CONFIG.persistenceArtifacts.some(
|
||
(f) => f.toLowerCase() === base,
|
||
);
|
||
const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes(
|
||
ext,
|
||
);
|
||
|
||
if (isTargetedName || isPersistenceArtifact || isJsLike) {
|
||
results.push(full);
|
||
}
|
||
}
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
// ─── Reporting ─────────────────────────────────────────────────────────────────
|
||
|
||
const SEVERITY_RANK = { CRITICAL: 3, HIGH: 2, MEDIUM: 1 };
|
||
const ANSI = {
|
||
reset: "\x1b[0m",
|
||
bold: "\x1b[1m",
|
||
red: "\x1b[31m",
|
||
yellow: "\x1b[33m",
|
||
cyan: "\x1b[36m",
|
||
green: "\x1b[32m",
|
||
dim: "\x1b[2m",
|
||
};
|
||
|
||
function colorSeverity(sev) {
|
||
if (sev === "CRITICAL") return `${ANSI.bold}${ANSI.red}${sev}${ANSI.reset}`;
|
||
if (sev === "HIGH") return `${ANSI.yellow}${sev}${ANSI.reset}`;
|
||
return `${ANSI.cyan}${sev}${ANSI.reset}`;
|
||
}
|
||
|
||
function printReport(allResults, { json = false, outputFile = null } = {}) {
|
||
const infected = allResults.filter(
|
||
(r) => r.findings && r.findings.length > 0,
|
||
);
|
||
const errors = allResults.filter((r) => r.error);
|
||
|
||
if (json) {
|
||
const report = {
|
||
scannedAt: new Date().toISOString(),
|
||
totalFilesScanned: allResults.length,
|
||
infectedFiles: infected.length,
|
||
results: infected,
|
||
errors,
|
||
};
|
||
const out = JSON.stringify(report, null, 2);
|
||
if (outputFile) {
|
||
fs.writeFileSync(outputFile, out);
|
||
console.log(`JSON report written to: ${outputFile}`);
|
||
} else {
|
||
console.log(out);
|
||
}
|
||
return infected.length > 0;
|
||
}
|
||
|
||
// Human-readable output
|
||
console.log(
|
||
`\n${ANSI.bold}╔══════════════════════════════════════════════════════════╗`,
|
||
);
|
||
console.log(`║ PolinRider / Famous Chollima Malware Scanner ║`);
|
||
console.log(
|
||
`╚══════════════════════════════════════════════════════════╝${ANSI.reset}`,
|
||
);
|
||
console.log(
|
||
`${ANSI.dim}Scanned ${allResults.length} files · ${new Date().toISOString()}${ANSI.reset}\n`,
|
||
);
|
||
|
||
if (infected.length === 0) {
|
||
console.log(
|
||
`${ANSI.green}${ANSI.bold}✓ No infections detected.${ANSI.reset}\n`,
|
||
);
|
||
} else {
|
||
console.log(
|
||
`${ANSI.red}${ANSI.bold}✗ INFECTION DETECTED in ${infected.length} file(s)${ANSI.reset}\n`,
|
||
);
|
||
|
||
for (const result of infected) {
|
||
// Sort findings by severity descending
|
||
const sorted = [...result.findings].sort(
|
||
(a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity],
|
||
);
|
||
const topSev = sorted[0].severity;
|
||
|
||
console.log(
|
||
` ${colorSeverity(topSev)} ${ANSI.bold}${result.filePath}${ANSI.reset}`,
|
||
);
|
||
for (const f of sorted) {
|
||
console.log(
|
||
` ${ANSI.dim}[${f.id}]${ANSI.reset} ${colorSeverity(f.severity)} — ${f.description}`,
|
||
);
|
||
for (const m of f.matches) {
|
||
console.log(` → ${m}`);
|
||
}
|
||
}
|
||
console.log();
|
||
}
|
||
|
||
console.log(`${ANSI.bold}Remediation steps:${ANSI.reset}`);
|
||
console.log(
|
||
` 1. Immediately isolate the affected machine from the network.`,
|
||
);
|
||
console.log(
|
||
` 2. Do NOT run npm install, npm build, or any script on this repo.`,
|
||
);
|
||
console.log(
|
||
` 3. Check for running node.exe / node processes with obfuscated args.`,
|
||
);
|
||
console.log(
|
||
` 4. Remove all code after the legitimate config closing block.`,
|
||
);
|
||
console.log(
|
||
` 5. Remove duplicate 'import { createRequire }' lines at file top.`,
|
||
);
|
||
console.log(
|
||
` 6. Recover the git repository from a clean local clone (see docs).`,
|
||
);
|
||
console.log(
|
||
` 7. Revoke ALL secrets, tokens, and credentials in .env and CI.`,
|
||
);
|
||
console.log(
|
||
` 8. See full remediation guide in the attached incident report.\n`,
|
||
);
|
||
}
|
||
|
||
if (errors.length > 0) {
|
||
console.log(`${ANSI.yellow}Scan errors (${errors.length}):${ANSI.reset}`);
|
||
for (const e of errors) {
|
||
console.log(` ${e.filePath}: ${e.error}`);
|
||
}
|
||
console.log();
|
||
}
|
||
|
||
return infected.length > 0;
|
||
}
|
||
|
||
// ─── CLI Entry Point ───────────────────────────────────────────────────────────
|
||
|
||
function main() {
|
||
const args = process.argv.slice(2);
|
||
const jsonFlag = args.includes("--json");
|
||
const outputFileIdx = args.indexOf("--output");
|
||
const outputFile = outputFileIdx !== -1 ? args[outputFileIdx + 1] : null;
|
||
|
||
// Positional args after flags are scan targets
|
||
const targets = args.filter(
|
||
(a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--output",
|
||
);
|
||
|
||
if (targets.length === 0) {
|
||
console.error(
|
||
"Usage: scan.js [--json] [--output report.json] <path> [path...]",
|
||
);
|
||
console.error(
|
||
" path can be a file or directory (directories are walked recursively)",
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
const filesToScan = [];
|
||
for (const target of targets) {
|
||
if (!fs.existsSync(target)) {
|
||
console.error(`Path not found: ${target}`);
|
||
process.exit(1);
|
||
}
|
||
const stat = fs.statSync(target);
|
||
if (stat.isDirectory()) {
|
||
const found = walkDir(target);
|
||
filesToScan.push(...found);
|
||
} else {
|
||
filesToScan.push(target);
|
||
}
|
||
}
|
||
|
||
// Deduplicate
|
||
const unique = [...new Set(filesToScan)];
|
||
const allResults = unique.map(scanFile);
|
||
|
||
const infected = printReport(allResults, { json: jsonFlag, outputFile });
|
||
|
||
// Exit code 1 if any infection found — used by CI to block deployments
|
||
process.exit(infected ? 1 : 0);
|
||
}
|
||
|
||
main();
|