/** * 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", ], // Files intentionally containing malware indicators for scanner logic/tests. // These filenames are skipped before malware rules are evaluated. ignoredFilenames: ["scan.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) { if (shouldIgnoreFile(filePath)) { return { filePath, findings: [], skipped: true }; } 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 shouldIgnoreFile(filePath) { const base = path.basename(filePath).toLowerCase(); return CONFIG.ignoredFilenames.some((f) => f.toLowerCase() === base); } 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() && !shouldIgnoreFile(full)) { 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...]", ); 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();