muaddib-scanner 2.2.4 → 2.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/muaddib.js +11 -1
- package/datasets/holdout-v4/atob-eval/index.js +2 -0
- package/datasets/holdout-v4/atob-eval/package.json +5 -0
- package/datasets/holdout-v4/base64-require/index.js +3 -0
- package/datasets/holdout-v4/base64-require/package.json +5 -0
- package/datasets/holdout-v4/charcode-fetch/index.js +3 -0
- package/datasets/holdout-v4/charcode-fetch/package.json +5 -0
- package/datasets/holdout-v4/charcode-spread-homedir/index.js +5 -0
- package/datasets/holdout-v4/charcode-spread-homedir/package.json +5 -0
- package/datasets/holdout-v4/concat-env-steal/index.js +4 -0
- package/datasets/holdout-v4/concat-env-steal/package.json +5 -0
- package/datasets/holdout-v4/double-decode-exfil/index.js +4 -0
- package/datasets/holdout-v4/double-decode-exfil/package.json +5 -0
- package/datasets/holdout-v4/hex-array-exec/index.js +3 -0
- package/datasets/holdout-v4/hex-array-exec/package.json +5 -0
- package/datasets/holdout-v4/mixed-obfuscation-stealer/index.js +10 -0
- package/datasets/holdout-v4/mixed-obfuscation-stealer/package.json +5 -0
- package/datasets/holdout-v4/nested-base64-concat/index.js +4 -0
- package/datasets/holdout-v4/nested-base64-concat/package.json +5 -0
- package/datasets/holdout-v4/template-literal-hide/index.js +3 -0
- package/datasets/holdout-v4/template-literal-hide/package.json +5 -0
- package/datasets/holdout-v5/callback-exfil/main.js +8 -0
- package/datasets/holdout-v5/callback-exfil/package.json +5 -0
- package/datasets/holdout-v5/callback-exfil/reader.js +10 -0
- package/datasets/holdout-v5/class-method-exfil/collector.js +10 -0
- package/datasets/holdout-v5/class-method-exfil/main.js +7 -0
- package/datasets/holdout-v5/class-method-exfil/package.json +5 -0
- package/datasets/holdout-v5/conditional-split/detector.js +2 -0
- package/datasets/holdout-v5/conditional-split/package.json +5 -0
- package/datasets/holdout-v5/conditional-split/stealer.js +16 -0
- package/datasets/holdout-v5/event-emitter-flow/listener.js +12 -0
- package/datasets/holdout-v5/event-emitter-flow/package.json +5 -0
- package/datasets/holdout-v5/event-emitter-flow/scanner.js +11 -0
- package/datasets/holdout-v5/mixed-inline-split/index.js +6 -0
- package/datasets/holdout-v5/mixed-inline-split/package.json +5 -0
- package/datasets/holdout-v5/mixed-inline-split/reader.js +3 -0
- package/datasets/holdout-v5/mixed-inline-split/sender.js +6 -0
- package/datasets/holdout-v5/named-export-steal/main.js +6 -0
- package/datasets/holdout-v5/named-export-steal/package.json +5 -0
- package/datasets/holdout-v5/named-export-steal/utils.js +1 -0
- package/datasets/holdout-v5/reexport-chain/a.js +2 -0
- package/datasets/holdout-v5/reexport-chain/b.js +1 -0
- package/datasets/holdout-v5/reexport-chain/c.js +11 -0
- package/datasets/holdout-v5/reexport-chain/package.json +5 -0
- package/datasets/holdout-v5/split-env-exfil/env.js +2 -0
- package/datasets/holdout-v5/split-env-exfil/exfil.js +5 -0
- package/datasets/holdout-v5/split-env-exfil/package.json +5 -0
- package/datasets/holdout-v5/split-npmrc-steal/index.js +2 -0
- package/datasets/holdout-v5/split-npmrc-steal/package.json +5 -0
- package/datasets/holdout-v5/split-npmrc-steal/reader.js +8 -0
- package/datasets/holdout-v5/split-npmrc-steal/sender.js +17 -0
- package/datasets/holdout-v5/three-hop-chain/package.json +5 -0
- package/datasets/holdout-v5/three-hop-chain/reader.js +8 -0
- package/datasets/holdout-v5/three-hop-chain/sender.js +11 -0
- package/datasets/holdout-v5/three-hop-chain/transform.js +3 -0
- package/package.json +1 -1
- package/src/index.js +26 -3
- package/src/response/playbooks.js +10 -0
- package/src/rules/index.js +26 -0
- package/src/scanner/ast.js +107 -24
- package/src/scanner/dataflow.js +18 -1
- package/src/scanner/deobfuscate.js +557 -0
- package/src/scanner/module-graph.js +883 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const acorn = require('acorn');
|
|
4
|
+
const walk = require('acorn-walk');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Lightweight static deobfuscation pre-processor.
|
|
8
|
+
* Resolves common JS obfuscation patterns via AST rewriting (no eval).
|
|
9
|
+
*
|
|
10
|
+
* @param {string} sourceCode — raw JS source
|
|
11
|
+
* @returns {{ code: string, transforms: Array<{type: string, start: number, end: number, before: string, after: string}> }}
|
|
12
|
+
*/
|
|
13
|
+
function deobfuscate(sourceCode) {
|
|
14
|
+
const transforms = [];
|
|
15
|
+
|
|
16
|
+
// Parse AST — if parsing fails, return source unchanged (fail-safe)
|
|
17
|
+
let ast;
|
|
18
|
+
try {
|
|
19
|
+
ast = acorn.parse(sourceCode, {
|
|
20
|
+
ecmaVersion: 2024,
|
|
21
|
+
sourceType: 'module',
|
|
22
|
+
allowHashBang: true,
|
|
23
|
+
ranges: true
|
|
24
|
+
});
|
|
25
|
+
} catch {
|
|
26
|
+
return { code: sourceCode, transforms };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Collect replacements as { start, end, value, type, before }
|
|
30
|
+
const replacements = [];
|
|
31
|
+
|
|
32
|
+
walk.simple(ast, {
|
|
33
|
+
// ---- 1. STRING CONCAT FOLDING ----
|
|
34
|
+
// 'ch' + 'il' + 'd_' + 'process' → 'child_process'
|
|
35
|
+
BinaryExpression(node) {
|
|
36
|
+
if (node.operator !== '+') return;
|
|
37
|
+
const folded = tryFoldConcat(node);
|
|
38
|
+
if (folded === null) return;
|
|
39
|
+
// Avoid folding single literals (no transformation needed)
|
|
40
|
+
if (node.left.type === 'Literal' && node.right.type === 'Literal' &&
|
|
41
|
+
typeof node.left.value === 'string' && typeof node.right.value === 'string') {
|
|
42
|
+
// Simple two-literal concat — always fold
|
|
43
|
+
} else if (node.type === 'BinaryExpression') {
|
|
44
|
+
// Nested concat — only fold if top-level (not already inside a folded parent)
|
|
45
|
+
// We check this by not folding if parent already covers this range
|
|
46
|
+
}
|
|
47
|
+
const before = sourceCode.slice(node.start, node.end);
|
|
48
|
+
const after = quoteString(folded);
|
|
49
|
+
replacements.push({
|
|
50
|
+
start: node.start,
|
|
51
|
+
end: node.end,
|
|
52
|
+
value: after,
|
|
53
|
+
type: 'string_concat',
|
|
54
|
+
before
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
// ---- 2. CHARCODE REBUILD + 3. BASE64 DECODE ----
|
|
59
|
+
CallExpression(node) {
|
|
60
|
+
// String.fromCharCode(99, 104, 105, 108, 100) → "child"
|
|
61
|
+
if (isStringFromCharCode(node)) {
|
|
62
|
+
const nums = extractNumericArgs(node);
|
|
63
|
+
if (nums === null) return;
|
|
64
|
+
try {
|
|
65
|
+
const decoded = String.fromCharCode(...nums);
|
|
66
|
+
const before = sourceCode.slice(node.start, node.end);
|
|
67
|
+
const after = quoteString(decoded);
|
|
68
|
+
replacements.push({
|
|
69
|
+
start: node.start,
|
|
70
|
+
end: node.end,
|
|
71
|
+
value: after,
|
|
72
|
+
type: 'charcode',
|
|
73
|
+
before
|
|
74
|
+
});
|
|
75
|
+
} catch { /* invalid char codes — skip */ }
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Buffer.from('...', 'base64').toString() → decoded string
|
|
80
|
+
if (isBufferBase64ToString(node)) {
|
|
81
|
+
const b64str = extractBufferBase64Arg(node);
|
|
82
|
+
if (b64str === null) return;
|
|
83
|
+
try {
|
|
84
|
+
const decoded = Buffer.from(b64str, 'base64').toString();
|
|
85
|
+
// Sanity: only replace if decoded is printable ASCII/UTF-8
|
|
86
|
+
if (!isPrintable(decoded)) return;
|
|
87
|
+
const before = sourceCode.slice(node.start, node.end);
|
|
88
|
+
const after = quoteString(decoded);
|
|
89
|
+
replacements.push({
|
|
90
|
+
start: node.start,
|
|
91
|
+
end: node.end,
|
|
92
|
+
value: after,
|
|
93
|
+
type: 'base64',
|
|
94
|
+
before
|
|
95
|
+
});
|
|
96
|
+
} catch { /* decode failure — skip */ }
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// atob('...') → decoded string
|
|
101
|
+
if (isAtobCall(node)) {
|
|
102
|
+
const b64str = node.arguments[0]?.value;
|
|
103
|
+
if (typeof b64str !== 'string') return;
|
|
104
|
+
try {
|
|
105
|
+
const decoded = Buffer.from(b64str, 'base64').toString();
|
|
106
|
+
if (!isPrintable(decoded)) return;
|
|
107
|
+
const before = sourceCode.slice(node.start, node.end);
|
|
108
|
+
const after = quoteString(decoded);
|
|
109
|
+
replacements.push({
|
|
110
|
+
start: node.start,
|
|
111
|
+
end: node.end,
|
|
112
|
+
value: after,
|
|
113
|
+
type: 'base64',
|
|
114
|
+
before
|
|
115
|
+
});
|
|
116
|
+
} catch { /* skip */ }
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- 4. HEX ARRAY MAP ----
|
|
121
|
+
// [0x63, 0x68, ...].map(c => String.fromCharCode(c)).join('')
|
|
122
|
+
const hexResult = tryResolveHexArrayMap(node, sourceCode);
|
|
123
|
+
if (hexResult !== null) {
|
|
124
|
+
replacements.push(hexResult);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// De-duplicate: nested BinaryExpression nodes produce overlapping replacements.
|
|
130
|
+
// Keep only the outermost (widest) replacement for each overlapping range.
|
|
131
|
+
replacements.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
132
|
+
const filtered = [];
|
|
133
|
+
let lastEnd = -1;
|
|
134
|
+
for (const r of replacements) {
|
|
135
|
+
if (r.start < lastEnd) continue; // nested inside a wider replacement — skip
|
|
136
|
+
filtered.push(r);
|
|
137
|
+
lastEnd = r.end;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Apply replacements from end to start to preserve positions
|
|
141
|
+
filtered.sort((a, b) => b.start - a.start);
|
|
142
|
+
|
|
143
|
+
let code = sourceCode;
|
|
144
|
+
for (const r of filtered) {
|
|
145
|
+
code = code.slice(0, r.start) + r.value + code.slice(r.end);
|
|
146
|
+
transforms.push({
|
|
147
|
+
type: r.type,
|
|
148
|
+
start: r.start,
|
|
149
|
+
end: r.end,
|
|
150
|
+
before: r.before,
|
|
151
|
+
after: r.value
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Reverse transforms so they're in source order (start ascending)
|
|
156
|
+
transforms.reverse();
|
|
157
|
+
|
|
158
|
+
// ---- PHASE 2: CONST PROPAGATION ----
|
|
159
|
+
// If phase 1 produced transforms, re-parse and propagate const string assignments.
|
|
160
|
+
// const a = 'child_'; const b = 'process'; require(a + b) → require('child_' + 'process') → require('child_process')
|
|
161
|
+
if (transforms.length > 0) {
|
|
162
|
+
const phase2 = propagateConsts(code);
|
|
163
|
+
if (phase2.transforms.length > 0) {
|
|
164
|
+
code = phase2.code;
|
|
165
|
+
transforms.push(...phase2.transforms);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { code, transforms };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Phase 2: Propagate const string literal assignments into identifier references,
|
|
174
|
+
* then fold any resulting string concatenations.
|
|
175
|
+
*/
|
|
176
|
+
function propagateConsts(sourceCode) {
|
|
177
|
+
const transforms = [];
|
|
178
|
+
let ast;
|
|
179
|
+
try {
|
|
180
|
+
ast = acorn.parse(sourceCode, {
|
|
181
|
+
ecmaVersion: 2024,
|
|
182
|
+
sourceType: 'module',
|
|
183
|
+
allowHashBang: true,
|
|
184
|
+
ranges: true
|
|
185
|
+
});
|
|
186
|
+
} catch {
|
|
187
|
+
return { code: sourceCode, transforms };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Collect const declarations: name → { value, initStart, initEnd }
|
|
191
|
+
const constMap = new Map();
|
|
192
|
+
// Track which names are assigned more than once (not safe to propagate)
|
|
193
|
+
const reassigned = new Set();
|
|
194
|
+
|
|
195
|
+
walk.simple(ast, {
|
|
196
|
+
VariableDeclaration(node) {
|
|
197
|
+
if (node.kind !== 'const') return;
|
|
198
|
+
for (const decl of node.declarations) {
|
|
199
|
+
if (decl.id?.type !== 'Identifier') continue;
|
|
200
|
+
if (!decl.init) continue;
|
|
201
|
+
if (decl.init.type === 'Literal' && typeof decl.init.value === 'string') {
|
|
202
|
+
constMap.set(decl.id.name, {
|
|
203
|
+
value: decl.init.value,
|
|
204
|
+
declStart: decl.init.start,
|
|
205
|
+
declEnd: decl.init.end
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
AssignmentExpression(node) {
|
|
211
|
+
if (node.left?.type === 'Identifier') {
|
|
212
|
+
reassigned.add(node.left.name);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Remove reassigned names from constMap (not safe)
|
|
218
|
+
for (const name of reassigned) {
|
|
219
|
+
constMap.delete(name);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (constMap.size === 0) {
|
|
223
|
+
return { code: sourceCode, transforms };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Find all Identifier references to propagate (excluding declarations and property names)
|
|
227
|
+
const replacements = [];
|
|
228
|
+
walk.simple(ast, {
|
|
229
|
+
Identifier(node) {
|
|
230
|
+
if (!constMap.has(node.name)) return;
|
|
231
|
+
const info = constMap.get(node.name);
|
|
232
|
+
// Skip the declaration site itself
|
|
233
|
+
if (node.start === info.declStart || (node.start >= info.declStart && node.end <= info.declEnd)) return;
|
|
234
|
+
replacements.push({
|
|
235
|
+
start: node.start,
|
|
236
|
+
end: node.end,
|
|
237
|
+
value: quoteString(info.value),
|
|
238
|
+
type: 'const_propagation',
|
|
239
|
+
before: sourceCode.slice(node.start, node.end)
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Filter: skip property access identifiers (obj.prop — prop is not a variable ref)
|
|
245
|
+
// We detect this by checking if the identifier is a property of a MemberExpression
|
|
246
|
+
const propPositions = new Set();
|
|
247
|
+
walk.simple(ast, {
|
|
248
|
+
MemberExpression(node) {
|
|
249
|
+
if (!node.computed && node.property?.type === 'Identifier') {
|
|
250
|
+
propPositions.add(node.property.start);
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
VariableDeclarator(node) {
|
|
254
|
+
// Skip the declaration name itself
|
|
255
|
+
if (node.id?.type === 'Identifier') {
|
|
256
|
+
propPositions.add(node.id.start);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const validReplacements = replacements.filter(r => !propPositions.has(r.start));
|
|
262
|
+
|
|
263
|
+
if (validReplacements.length === 0) {
|
|
264
|
+
return { code: sourceCode, transforms };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Apply replacements from end to start
|
|
268
|
+
validReplacements.sort((a, b) => b.start - a.start);
|
|
269
|
+
let code = sourceCode;
|
|
270
|
+
for (const r of validReplacements) {
|
|
271
|
+
code = code.slice(0, r.start) + r.value + code.slice(r.end);
|
|
272
|
+
transforms.push({
|
|
273
|
+
type: r.type,
|
|
274
|
+
start: r.start,
|
|
275
|
+
end: r.end,
|
|
276
|
+
before: r.before,
|
|
277
|
+
after: r.value
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Now re-run concat folding on the propagated code
|
|
282
|
+
const phase3 = foldConcatsOnly(code);
|
|
283
|
+
if (phase3.transforms.length > 0) {
|
|
284
|
+
code = phase3.code;
|
|
285
|
+
transforms.push(...phase3.transforms);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
transforms.reverse();
|
|
289
|
+
return { code, transforms };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Run only string concat folding on code (phase 3 after const propagation).
|
|
294
|
+
*/
|
|
295
|
+
function foldConcatsOnly(sourceCode) {
|
|
296
|
+
const transforms = [];
|
|
297
|
+
let ast;
|
|
298
|
+
try {
|
|
299
|
+
ast = acorn.parse(sourceCode, {
|
|
300
|
+
ecmaVersion: 2024,
|
|
301
|
+
sourceType: 'module',
|
|
302
|
+
allowHashBang: true,
|
|
303
|
+
ranges: true
|
|
304
|
+
});
|
|
305
|
+
} catch {
|
|
306
|
+
return { code: sourceCode, transforms };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const replacements = [];
|
|
310
|
+
walk.simple(ast, {
|
|
311
|
+
BinaryExpression(node) {
|
|
312
|
+
if (node.operator !== '+') return;
|
|
313
|
+
const folded = tryFoldConcat(node);
|
|
314
|
+
if (folded === null) return;
|
|
315
|
+
const before = sourceCode.slice(node.start, node.end);
|
|
316
|
+
const after = quoteString(folded);
|
|
317
|
+
replacements.push({ start: node.start, end: node.end, value: after, type: 'string_concat', before });
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// De-duplicate overlapping
|
|
322
|
+
replacements.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
323
|
+
const filtered = [];
|
|
324
|
+
let lastEnd = -1;
|
|
325
|
+
for (const r of replacements) {
|
|
326
|
+
if (r.start < lastEnd) continue;
|
|
327
|
+
filtered.push(r);
|
|
328
|
+
lastEnd = r.end;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
filtered.sort((a, b) => b.start - a.start);
|
|
332
|
+
let code = sourceCode;
|
|
333
|
+
for (const r of filtered) {
|
|
334
|
+
code = code.slice(0, r.start) + r.value + code.slice(r.end);
|
|
335
|
+
transforms.push({ type: r.type, start: r.start, end: r.end, before: r.before, after: r.value });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return { code, transforms };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ============================================================
|
|
342
|
+
// HELPERS
|
|
343
|
+
// ============================================================
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Recursively fold string concat BinaryExpression.
|
|
347
|
+
* Returns the concatenated string, or null if any part is not a string literal.
|
|
348
|
+
*/
|
|
349
|
+
function tryFoldConcat(node) {
|
|
350
|
+
if (node.type === 'Literal' && typeof node.value === 'string') {
|
|
351
|
+
return node.value;
|
|
352
|
+
}
|
|
353
|
+
if (node.type === 'BinaryExpression' && node.operator === '+') {
|
|
354
|
+
const left = tryFoldConcat(node.left);
|
|
355
|
+
if (left === null) return null;
|
|
356
|
+
const right = tryFoldConcat(node.right);
|
|
357
|
+
if (right === null) return null;
|
|
358
|
+
return left + right;
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Check if node is String.fromCharCode(...)
|
|
365
|
+
*/
|
|
366
|
+
function isStringFromCharCode(node) {
|
|
367
|
+
if (node.type !== 'CallExpression') return false;
|
|
368
|
+
const c = node.callee;
|
|
369
|
+
if (c.type !== 'MemberExpression') return false;
|
|
370
|
+
// String.fromCharCode
|
|
371
|
+
if (c.object?.type === 'Identifier' && c.object.name === 'String' &&
|
|
372
|
+
c.property?.type === 'Identifier' && c.property.name === 'fromCharCode') {
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Extract numeric arguments from a call (handles direct numbers and spread of array).
|
|
380
|
+
* Returns array of numbers, or null if any argument is non-numeric.
|
|
381
|
+
*/
|
|
382
|
+
function extractNumericArgs(node) {
|
|
383
|
+
const nums = [];
|
|
384
|
+
for (const arg of node.arguments) {
|
|
385
|
+
if (arg.type === 'SpreadElement' && arg.argument?.type === 'ArrayExpression') {
|
|
386
|
+
for (const el of arg.argument.elements) {
|
|
387
|
+
if (el?.type === 'Literal' && typeof el.value === 'number') {
|
|
388
|
+
nums.push(el.value);
|
|
389
|
+
} else {
|
|
390
|
+
return null; // non-numeric — abort
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
} else if (arg.type === 'Literal' && typeof arg.value === 'number') {
|
|
394
|
+
nums.push(arg.value);
|
|
395
|
+
} else {
|
|
396
|
+
return null; // non-numeric argument (variable, expression) — abort
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return nums.length > 0 ? nums : null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Check if node is Buffer.from('...', 'base64').toString()
|
|
404
|
+
*/
|
|
405
|
+
function isBufferBase64ToString(node) {
|
|
406
|
+
if (node.type !== 'CallExpression') return false;
|
|
407
|
+
const callee = node.callee;
|
|
408
|
+
// .toString() call
|
|
409
|
+
if (callee.type !== 'MemberExpression') return false;
|
|
410
|
+
if (callee.property?.type !== 'Identifier' || callee.property.name !== 'toString') return false;
|
|
411
|
+
// The object is Buffer.from(str, 'base64')
|
|
412
|
+
const inner = callee.object;
|
|
413
|
+
if (inner?.type !== 'CallExpression') return false;
|
|
414
|
+
const innerCallee = inner.callee;
|
|
415
|
+
if (innerCallee?.type !== 'MemberExpression') return false;
|
|
416
|
+
if (innerCallee.object?.type !== 'Identifier' || innerCallee.object.name !== 'Buffer') return false;
|
|
417
|
+
if (innerCallee.property?.type !== 'Identifier' || innerCallee.property.name !== 'from') return false;
|
|
418
|
+
// Args: (string, 'base64')
|
|
419
|
+
if (inner.arguments.length < 2) return false;
|
|
420
|
+
if (inner.arguments[1]?.type !== 'Literal' || inner.arguments[1].value !== 'base64') return false;
|
|
421
|
+
if (inner.arguments[0]?.type !== 'Literal' || typeof inner.arguments[0].value !== 'string') return false;
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Extract the base64 string argument from Buffer.from(str, 'base64').toString()
|
|
427
|
+
*/
|
|
428
|
+
function extractBufferBase64Arg(node) {
|
|
429
|
+
const inner = node.callee.object;
|
|
430
|
+
return inner.arguments[0].value;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Check if node is atob('...')
|
|
435
|
+
*/
|
|
436
|
+
function isAtobCall(node) {
|
|
437
|
+
if (node.type !== 'CallExpression') return false;
|
|
438
|
+
if (node.callee?.type !== 'Identifier' || node.callee.name !== 'atob') return false;
|
|
439
|
+
if (node.arguments.length !== 1) return false;
|
|
440
|
+
if (node.arguments[0]?.type !== 'Literal' || typeof node.arguments[0].value !== 'string') return false;
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Try to resolve [0x63, ...].map(c => String.fromCharCode(c)).join('')
|
|
446
|
+
* Returns a replacement object or null.
|
|
447
|
+
*/
|
|
448
|
+
function tryResolveHexArrayMap(node, source) {
|
|
449
|
+
// Pattern: <expr>.join('') where <expr> is <array>.map(<fn>)
|
|
450
|
+
// node is the .join('') call
|
|
451
|
+
if (node.type !== 'CallExpression') return null;
|
|
452
|
+
const callee = node.callee;
|
|
453
|
+
if (callee?.type !== 'MemberExpression') return null;
|
|
454
|
+
if (callee.property?.type !== 'Identifier' || callee.property.name !== 'join') return null;
|
|
455
|
+
// Verify .join('') or .join("")
|
|
456
|
+
if (node.arguments.length !== 1) return null;
|
|
457
|
+
if (node.arguments[0]?.type !== 'Literal' || node.arguments[0].value !== '') return null;
|
|
458
|
+
|
|
459
|
+
// The object of .join should be a .map(...) call
|
|
460
|
+
const mapCall = callee.object;
|
|
461
|
+
if (mapCall?.type !== 'CallExpression') return null;
|
|
462
|
+
if (mapCall.callee?.type !== 'MemberExpression') return null;
|
|
463
|
+
if (mapCall.callee.property?.type !== 'Identifier' || mapCall.callee.property.name !== 'map') return null;
|
|
464
|
+
|
|
465
|
+
// The map callback should reference String.fromCharCode
|
|
466
|
+
if (mapCall.arguments.length < 1) return null;
|
|
467
|
+
const mapFn = mapCall.arguments[0];
|
|
468
|
+
if (!containsFromCharCode(mapFn)) return null;
|
|
469
|
+
|
|
470
|
+
// The object of .map should be an ArrayExpression of numbers
|
|
471
|
+
const arr = mapCall.callee.object;
|
|
472
|
+
if (arr?.type !== 'ArrayExpression') return null;
|
|
473
|
+
const nums = [];
|
|
474
|
+
for (const el of arr.elements) {
|
|
475
|
+
if (el?.type === 'Literal' && typeof el.value === 'number') {
|
|
476
|
+
nums.push(el.value);
|
|
477
|
+
} else {
|
|
478
|
+
return null; // non-numeric element — abort
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (nums.length === 0) return null;
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
const decoded = String.fromCharCode(...nums);
|
|
485
|
+
const before = source.slice(node.start, node.end);
|
|
486
|
+
return {
|
|
487
|
+
start: node.start,
|
|
488
|
+
end: node.end,
|
|
489
|
+
value: quoteString(decoded),
|
|
490
|
+
type: 'hex_array',
|
|
491
|
+
before
|
|
492
|
+
};
|
|
493
|
+
} catch {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Check if an AST node (a function/arrow function) contains a reference to String.fromCharCode.
|
|
500
|
+
*/
|
|
501
|
+
function containsFromCharCode(node) {
|
|
502
|
+
if (!node || typeof node !== 'object') return false;
|
|
503
|
+
|
|
504
|
+
// Direct check on this node
|
|
505
|
+
if (node.type === 'MemberExpression' &&
|
|
506
|
+
node.object?.type === 'Identifier' && node.object.name === 'String' &&
|
|
507
|
+
node.property?.type === 'Identifier' && node.property.name === 'fromCharCode') {
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Recurse into child nodes
|
|
512
|
+
for (const key of Object.keys(node)) {
|
|
513
|
+
if (key === 'type' || key === 'start' || key === 'end' || key === 'range') continue;
|
|
514
|
+
const child = node[key];
|
|
515
|
+
if (Array.isArray(child)) {
|
|
516
|
+
for (const c of child) {
|
|
517
|
+
if (c && typeof c === 'object' && containsFromCharCode(c)) return true;
|
|
518
|
+
}
|
|
519
|
+
} else if (child && typeof child === 'object' && child.type) {
|
|
520
|
+
if (containsFromCharCode(child)) return true;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Quote a string value as a JS single-quoted string literal.
|
|
528
|
+
*/
|
|
529
|
+
function quoteString(str) {
|
|
530
|
+
const escaped = str
|
|
531
|
+
.replace(/\\/g, '\\\\')
|
|
532
|
+
.replace(/'/g, "\\'")
|
|
533
|
+
.replace(/\n/g, '\\n')
|
|
534
|
+
.replace(/\r/g, '\\r')
|
|
535
|
+
.replace(/\t/g, '\\t');
|
|
536
|
+
return `'${escaped}'`;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Check if a decoded string is "printable" (no control chars except whitespace).
|
|
541
|
+
* Prevents replacing base64 that decodes to binary garbage.
|
|
542
|
+
*/
|
|
543
|
+
function isPrintable(str) {
|
|
544
|
+
// Allow printable ASCII + common unicode + whitespace
|
|
545
|
+
// Reject if more than 20% of chars are control characters
|
|
546
|
+
let controlCount = 0;
|
|
547
|
+
for (let i = 0; i < str.length; i++) {
|
|
548
|
+
const code = str.charCodeAt(i);
|
|
549
|
+
if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
|
|
550
|
+
controlCount++;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (str.length === 0) return false;
|
|
554
|
+
return (controlCount / str.length) < 0.2;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
module.exports = { deobfuscate };
|