From 3b739994ea1d55f49a41ed8cca205f9e7a5c9e75 Mon Sep 17 00:00:00 2001 From: Satendra Rawat Date: Sat, 26 Sep 2026 17:59:20 +0530 Subject: [PATCH] feat(malware-scan): detect published PolinRider signatures and propagation script - SIGNATURE_STRING: obfuscator signatures rmcej%otb% and Cot%3t=shtP - GLOBAL_MARKER: also global.i set to a 5-...-du or A8-... campaign value - PROPAGATION_SCRIPT: committed temp_auto_push.bat (clock-rewind force-push tool) Co-Authored-By: Claude Opus 5.5 --- malware-scan/README.md | 6 ++++-- malware-scan/scan_malicious.py | 16 +++++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/malware-scan/README.md b/malware-scan/README.md index 6ca04ce..5009d08 100644 --- a/malware-scan/README.md +++ b/malware-scan/README.md @@ -22,7 +22,9 @@ objects without checking anything out. |------|----------|---------| | `WHITESPACE_PADDING` | high in config files, or when code (200+ chars, or `require(`, `global[`, `eval(` and similar) follows; otherwise low | Code after 150+ spaces/tabs on one line (hidden off-screen). Aligned comments in vendor bundles and `.d.ts` files are low, so hidden unless you pass `--min-severity low` | | `GLOBAL_REQUIRE` | high | The `require` function itself assigned onto `global` (`global['r'] = require`). Storing the result of a call, like `global.x = require('y')`, is not flagged | -| `GLOBAL_MARKER` | high | The campaign's string marker assigned onto `global` | +| `GLOBAL_MARKER` | high | The campaign's string marker assigned onto `global` (`global.o`, `global['!']`, `global['_V']`, and `global.i` set to a `5-…-du` or `A8-…` value) | +| `SIGNATURE_STRING` | high | The published PolinRider obfuscator signatures `rmcej%otb%` and `Cot%3t=shtP` | +| `PROPAGATION_SCRIPT` | high | A committed `temp_auto_push.bat`, the script the campaign uses to amend commits with a rewound clock and force-push them | | `OBFUSCATOR_ID` | high | `_$_` + 4 hex identifiers from the obfuscator | | `LIFECYCLE_SCRIPT` | high | `preinstall`/`postinstall`/`prepare` npm scripts that download or pipe to a shell | | `VSCODE_FOLDEROPEN` | high / medium | `.vscode/tasks.json` tasks that auto-run when the folder is opened (high if they download/execute) | @@ -34,7 +36,7 @@ Only **high** findings fail the scan. On a clean checkout of `vitejs/vite` (2,17 reports 0 high and 3 legitimate medium findings. Scanned: `.js .cjs .mjs .jsx .ts .tsx .json .vue .svelte .html .py .sh .yml .yaml`, `*.config.*`, -dotfile configs, and extensionless hooks in `.husky/` and `.githooks/`. +dotfile configs, extensionless hooks in `.husky/` and `.githooks/`, and any file named `temp_auto_push.bat`. Skipped everywhere: lockfiles, `*.map`, `*.min.js`. Git modes (default, `--staged`, `--all-branches`) scan every tracked path, including committed `vendor/`, `dist/`, `build/` and `node_modules/`. Payloads have been found in a committed diff --git a/malware-scan/scan_malicious.py b/malware-scan/scan_malicious.py index a5ac336..e6b35a0 100755 --- a/malware-scan/scan_malicious.py +++ b/malware-scan/scan_malicious.py @@ -42,6 +42,8 @@ HOOK_DIR_RE = re.compile(r"(^|/)\.(husky|githooks)/[^/]+$") VSCODE_TASKS_RE = re.compile(r"(^|/)\.vscode/tasks\.json$") CLAUDE_SETTINGS_RE = re.compile(r"(^|/)\.claude/settings(\.local)?\.json$") +# Windows script the campaign uses to amend commits with a rewound clock and force-push. +PROPAGATION_RE = re.compile(r"(^|/)temp_auto_push\.bat$", re.I) # (rule id, severity, regex, description) RULES = [ @@ -51,8 +53,12 @@ re.compile(r"global\s*(\[[^=;]{1,60}\]|\.\w+)\s*=\s*require\b(?!\s*\()"), "require() exported onto the global object"), ("GLOBAL_MARKER", "high", - re.compile(r"""global\s*(\.\s*o|\[\s*['"](!|_V|o|r)['"]\s*\])\s*=\s*['"]"""), - "known campaign marker (global.o / global['!'] string assignment)"), + re.compile(r"""global\s*(\.\s*o|\[\s*['"](!|_V|o|r)['"]\s*\])\s*=\s*['"]""" + r"""|global\s*\.\s*i\s*=\s*['"](\d+-\d+(-\d+)?-du|A8-)"""), + "known campaign marker (global.o / global.i / global['!'] string assignment)"), + # [b] and [P] stop this file from matching its own pattern. + ("SIGNATURE_STRING", "high", re.compile(r"rmcej%ot[b]%|Cot%3t=sht[P]"), + "published PolinRider obfuscator signature string"), ("OBFUSCATOR_ID", "high", re.compile(r"_\$_[0-9a-fA-F]{4}\b"), "obfuscator-generated identifier (_$_ followed by 4 hex chars)"), ("CHAR127_DECODER", "medium", re.compile(r"fromCharCode\(\s*127\s*\)"), @@ -89,7 +95,8 @@ def should_scan(path, skip_dirs=frozenset()): if any(part in skip_dirs for part in parts[:-1]) or p.endswith(SKIP_SUFFIX): return False return (os.path.splitext(p)[1].lower() in TEXT_EXT - or bool(CONFIG_RE.search(p)) or bool(HOOK_DIR_RE.search(p))) + or bool(CONFIG_RE.search(p)) or bool(HOOK_DIR_RE.search(p)) + or bool(PROPAGATION_RE.search(p))) def preview(line, start): @@ -143,6 +150,9 @@ def walk_strings(obj): def scan_structured(path, real, text): """Checks for files that trigger code automatically (npm, VS Code, Claude Code).""" out = [] + if PROPAGATION_RE.search(real): + out.append(("high", "PROPAGATION_SCRIPT", path, 0, + "temp_auto_push.bat: the campaign's commit-amend and force-push script", "")) if real.endswith("package.json"): data = load_json(text) scripts = (data or {}).get("scripts", {}) if isinstance(data, dict) else {}