Last active
September 16, 2026 18:12
-
-
Save struppigel/d24cdd42c39bde7190b7b0bfd69a514d to your computer and use it in GitHub Desktop.
JS hook script with reflection (LLM generated)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // minimal_generic_hook.js — generic-hooking analysis harness (v3, pluggable loader) | |
| // Run: set ELECTRON_RUN_AS_NODE=1 && launcher.exe minimal_generic_hook.js | |
| // | |
| // Spies every module the sample itself requires (auto-wrapping all functions/constructors) | |
| // and PASSES THROUGH the real modules. Only child_process is neutralized (so it can't spawn an | |
| // uninstrumented next stage). Real modules execute — run on an isolated VM with no Internet. | |
| // ===== per-sample CONFIG (the only part you edit between samples) ===== | |
| const CONFIG = { | |
| payload: "-put path to .jsc here-", // .jsc bytecode OR a .js entry | |
| modulePaths: [ | |
| "-put modulepathes here-" | |
| ], | |
| logFile: "log_min.txt", | |
| watchdogMs: 30000, // raise if the interesting behavior fires late | |
| loader: "auto", // "auto" (detect) | "bytenode" | "js" | |
| // load: () => { ... }, // optional full manual override; if set, it wins over `loader` | |
| }; | |
| // ===== 1) capture real tools BEFORE the sample can clobber the globals ===== | |
| const { setInterval, setTimeout } = require("node:timers"); | |
| const realExit = process.exit.bind(process); | |
| const realLog = console.log.bind(console); | |
| const fs = require("fs"); // our own (unspied) fs handle for logging | |
| const EE = require("events").EventEmitter; | |
| // ===== 2) buffered logging with timestamp + sequence (file + live console) ===== | |
| const L = CONFIG.logFile; | |
| const W = []; | |
| const t0 = Date.now(); | |
| let seq = 0; | |
| const log = (m) => { const line = "+" + (Date.now() - t0) + "ms #" + (++seq) + " " + m; W.push(line); realLog(line); }; | |
| const flush = () => { if (W.length) { fs.appendFileSync(L, W.join("\n") + "\n"); W.length = 0; } }; | |
| fs.writeFileSync(L, ""); | |
| setInterval(flush, 500); | |
| process.on("exit", flush); | |
| // ===== 3) survive async failures so the run isn't cut short (network-off rejects, etc.) ===== | |
| process.on("unhandledRejection", (r) => log("[unhandledRejection] " + (r && r.message ? r.message : String(r)))); | |
| process.on("uncaughtException", (e) => log("[uncaughtException] " + (e && e.stack ? e.stack.split("\n")[0] : String(e)))); | |
| // ===== 4) keep the run alive; block the sample's exit; stop ourselves via the watchdog ===== | |
| process.exit = (code) => { log("[EXIT blocked] " + code); }; | |
| process.env.NODE_OPTIONS = ""; process.env.DEBUG = ""; // defuse the anti-debug env checks | |
| setTimeout(() => { flush(); realExit(0); }, CONFIG.watchdogMs); | |
| // ===== 5) make the app's bundled modules resolvable (front of the search path) ===== | |
| module.paths.unshift(...CONFIG.modulePaths); | |
| // ===== 6) rich argument preview (Buffers, typed arrays, streams, errors, truncation, circular-safe) ===== | |
| function fmt(v, depth) { | |
| if (v === null) return "null"; | |
| const t = typeof v; | |
| if (t === "string") return JSON.stringify(v.length > 200 ? v.slice(0, 200) + "\u2026" : v); | |
| if (t === "number" || t === "boolean" || t === "bigint") return String(v); | |
| if (t === "undefined") return "undefined"; | |
| if (t === "symbol") return v.toString(); | |
| if (t === "function") return "[Function " + (v.name || "anonymous") + "]"; | |
| if (Buffer.isBuffer(v)) return "<Buffer " + v.length + " bytes>"; | |
| if (ArrayBuffer.isView(v)) return "<" + v.constructor.name + " " + v.byteLength + " bytes>"; | |
| if (v instanceof Error) return "Error(" + JSON.stringify(v.message) + ")"; | |
| if (Array.isArray(v)) { | |
| if (depth <= 0) return "[Array(" + v.length + ")]"; | |
| return "[" + v.slice(0, 8).map((x) => fmt(x, depth - 1)).join(", ") + (v.length > 8 ? ", \u2026" : "") + "]"; | |
| } | |
| if (t === "object") { | |
| const ctor = v.constructor && v.constructor.name; | |
| if (depth <= 0) return ctor && ctor !== "Object" ? "[" + ctor + "]" : "[Object]"; | |
| try { | |
| const keys = Object.keys(v); | |
| const body = keys.slice(0, 8).map((k) => k + ":" + fmt(v[k], depth - 1)).join(", "); | |
| const tag = ctor && ctor !== "Object" ? ctor + " " : ""; | |
| return tag + "{" + body + (keys.length > 8 ? ", \u2026" : "") + "}"; | |
| } catch (e) { return ctor ? "[" + ctor + "]" : "[Object]"; } | |
| } | |
| return String(v); | |
| } | |
| const preview = (args) => { | |
| try { return Array.from(args).map((a) => fmt(a, 2)).join(", ").slice(0, 400); } | |
| catch (e) { return "<args>"; } | |
| }; | |
| // ===== 7) generic spy: memoized, receiver-correct, wraps calls/constructions/members ===== | |
| const cache = new WeakMap(); // original -> its proxy (stable identity, no dup work, breaks cycles) | |
| function spy(name, val, receiver) { | |
| const t = val === null ? "null" : typeof val; | |
| if (t !== "function" && t !== "object") return val; | |
| if (cache.has(val)) return cache.get(val); | |
| const handler = { | |
| get(target, prop, recv) { | |
| const p = Reflect.get(target, prop, recv); | |
| if (typeof prop === "symbol") return p; // skip Symbol keys -> cuts engine-probe noise | |
| if (typeof p === "function") return spy(name + "." + String(prop), p, target); // method: receiver = its object | |
| if (p && typeof p === "object") return spy(name + "." + String(prop), p); // nested namespace (fs.promises) | |
| return p; | |
| }, | |
| }; | |
| if (t === "function") { | |
| handler.apply = (target, thisArg, args) => { | |
| log("[" + name + "()] " + preview(args)); | |
| // receiver fix: call with the REAL owning object, not our proxy (native methods reject a proxy receiver) | |
| return Reflect.apply(target, receiver !== undefined ? receiver : thisArg, args); | |
| }; | |
| handler.construct = (target, args, nt) => { | |
| log("[new " + name + "] " + preview(args)); | |
| return Reflect.construct(target, args, nt); | |
| }; | |
| } | |
| const proxy = new Proxy(val, handler); | |
| cache.set(val, proxy); | |
| return proxy; | |
| } | |
| // ===== the one kept neutralization: never let the sample spawn an uninstrumented next stage ===== | |
| function fakeChildProcess() { | |
| const proc = () => { const e = new EE(); e.stdout = new EE(); e.stderr = new EE(); | |
| e.stdin = { write() {}, end() {} }; e.pid = 1; e.kill = () => {}; | |
| setTimeout(() => e.emit("close", 0), 20); return e; }; | |
| const cb = (a) => a.find((x) => typeof x === "function"); | |
| return { | |
| exec: (c, ...a) => { log("[EXEC] " + c); const f = cb(a); if (f) f(null, "", ""); return proc(); }, | |
| execSync: (c) => { log("[EXECSYNC] " + c); return Buffer.from(""); }, | |
| execFile: (file, ar, ...a) => { log("[EXECFILE] " + file + " " + preview(ar)); const f = cb(a); if (f) f(null, "", ""); return proc(); }, | |
| spawn: (c, ar) => { log("[SPAWN] " + c + " " + preview(ar)); return proc(); }, | |
| fork: (m, ar) => { log("[FORK] " + m + " " + preview(ar)); return proc(); }, | |
| }; | |
| } | |
| // ===== generic require hook: wrap ONLY what the malware itself requires ===== | |
| const oR = module.constructor.prototype.require; | |
| module.constructor.prototype.require = function (id) { | |
| const real = oR.apply(this, arguments); | |
| const caller = (this && this.filename) || ""; | |
| if (/[\\/]node_modules[\\/]/.test(caller)) return real; // library-internal require -> leave untouched | |
| log("[REQ] " + id); | |
| if (id === "child_process" || id === "node:child_process") return fakeChildProcess(); | |
| return spy(id, real); // everything else: generic spy + pass-through | |
| }; | |
| // ===== payload loader: setting-driven with auto-detection, plus the manual override ===== | |
| function detectLoader(p) { // -> "bytenode" (V8 bytecode) or "js" (source) | |
| try { | |
| const head = fs.readFileSync(p).subarray(0, 64); | |
| if (/\.jsc$/i.test(p) || head.includes(0)) return "bytenode"; // .jsc ext, or a null byte in the header | |
| } catch (e) {} | |
| return "js"; // UTF-8 source essentially never has a null byte up front | |
| } | |
| function loadPayload() { | |
| if (typeof CONFIG.load === "function") { log("[loader] manual override"); return CONFIG.load(); } | |
| const auto = CONFIG.loader === "auto"; | |
| const mode = auto ? detectLoader(CONFIG.payload) : CONFIG.loader; | |
| if (mode === "bytenode") { | |
| log("[loader] " + (auto ? "auto-detected " : "") + "bytenode (.jsc)"); | |
| try { require("bytenode"); } | |
| catch (e) { log("[loader] bytenode not resolvable — run `npm i bytenode` or add its node_modules to CONFIG.modulePaths"); throw e; } | |
| return require(CONFIG.payload); | |
| } | |
| log("[loader] " + (auto ? "auto-detected " : "") + "plain JS source"); | |
| return require(CONFIG.payload); // plain / obfuscated JS entry | |
| } | |
| // ===== go ===== | |
| log("[START] minimal generic hook v3"); | |
| try { | |
| loadPayload(); | |
| } catch (e) { | |
| log("[ERR] " + e.message); | |
| log("[STACK] " + e.stack); | |
| } | |
| log("[END] synchronous load done"); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment