js-confuser-vm 0.1.0 → 0.1.1
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/CHANGELOG.md +23 -0
- package/README.md +75 -94
- package/bench.ts +146 -0
- package/disassemble.ts +12 -0
- package/dist/build-runtime.js +41 -15
- package/dist/compiler.js +134 -60
- package/dist/disassembler.js +317 -0
- package/dist/index.js +7 -2
- package/dist/runtime.js +68 -46
- package/dist/template.js +116 -0
- package/dist/transforms/bytecode/aliasedOpcodes.js +4 -1
- package/dist/transforms/bytecode/controlFlowFlattening.js +451 -0
- package/dist/transforms/bytecode/dispatcher.js +13 -109
- package/dist/transforms/bytecode/macroOpcodes.js +2 -2
- package/dist/transforms/bytecode/resolveConstants.js +100 -0
- package/dist/transforms/bytecode/resolveRegisters.js +4 -0
- package/dist/transforms/bytecode/semanticOpcodes.js +162 -0
- package/dist/transforms/bytecode/specializedOpcodes.js +18 -10
- package/dist/transforms/bytecode/stringConcealing.js +110 -0
- package/dist/transforms/runtime/classObfuscation.js +43 -0
- package/dist/transforms/runtime/handlerTable.js +91 -0
- package/dist/transforms/runtime/semanticOpcodes.js +35 -0
- package/dist/transforms/runtime/specializedOpcodes.js +11 -5
- package/dist/types.js +1 -1
- package/dist/utils/ast-utils.js +14 -0
- package/dist/utils/op-utils.js +0 -2
- package/dist/utils/pass-utils.js +100 -0
- package/dist/utils/profile-utils.js +3 -0
- package/index.ts +22 -17
- package/jest.config.js +14 -2
- package/output.disassembled.js +41 -0
- package/package.json +2 -1
- package/src/build-runtime.ts +113 -78
- package/src/compiler.ts +2703 -2593
- package/src/disassembler.ts +329 -0
- package/src/index.ts +12 -2
- package/src/options.ts +7 -1
- package/src/runtime.ts +84 -51
- package/src/template.ts +125 -1
- package/src/transforms/bytecode/aliasedOpcodes.ts +4 -1
- package/src/transforms/bytecode/controlFlowFlattening.ts +566 -0
- package/src/transforms/bytecode/dispatcher.ts +19 -125
- package/src/transforms/bytecode/macroOpcodes.ts +2 -2
- package/src/transforms/bytecode/resolveRegisters.ts +5 -0
- package/src/transforms/bytecode/specializedOpcodes.ts +22 -11
- package/src/transforms/bytecode/stringConcealing.ts +130 -0
- package/src/transforms/runtime/classObfuscation.ts +59 -0
- package/src/transforms/runtime/specializedOpcodes.ts +14 -9
- package/src/types.ts +42 -1
- package/src/utils/ast-utils.ts +19 -0
- package/src/utils/op-utils.ts +0 -2
- package/src/utils/pass-utils.ts +126 -0
- package/src/utils/profile-utils.ts +3 -0
- package/tsconfig.json +1 -1
- package/src/transforms/bytecode/microOpcodes.ts +0 -291
- package/src/transforms/runtime/internalVariables.ts +0 -270
- package/src/transforms/runtime/microOpcodes.ts +0 -93
- /package/src/transforms/bytecode/{resolveContants.ts → resolveConstants.ts} +0 -0
|
@@ -78,8 +78,6 @@
|
|
|
78
78
|
// Runs BEFORE resolveRegisters (so injected RegisterOperands are picked up by
|
|
79
79
|
// liveness analysis) and BEFORE resolveLabels (so label operands with transforms
|
|
80
80
|
// are resolved as part of the normal label-resolution pass).
|
|
81
|
-
//
|
|
82
|
-
// Enabled by options.dispatcher = true.
|
|
83
81
|
|
|
84
82
|
import type {
|
|
85
83
|
Bytecode,
|
|
@@ -92,22 +90,19 @@ import { Compiler } from "../../compiler.ts";
|
|
|
92
90
|
import { getRandomInt } from "../../utils/random-utils.ts";
|
|
93
91
|
import { U16_MAX } from "../../utils/op-utils.ts";
|
|
94
92
|
import { Template } from "../../template.ts";
|
|
95
|
-
|
|
93
|
+
import {
|
|
94
|
+
ref,
|
|
95
|
+
buildMaxIdMap,
|
|
96
|
+
allocReg,
|
|
97
|
+
extractLabel,
|
|
98
|
+
forEachFunction,
|
|
99
|
+
} from "../../utils/pass-utils.ts";
|
|
96
100
|
// VERY IMPORTANT: All object operands should be unique objects for the entire compilation process.
|
|
97
101
|
// This ensures that other passes that may reference/modify operands (e.g. specializedOpcodes) don't accidentally break behavior by mutating cloned objects.
|
|
98
|
-
function ref(r: RegisterOperand): RegisterOperand {
|
|
99
|
-
return b.registerOperand(r.id, r.fnId);
|
|
100
|
-
}
|
|
101
102
|
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
// JSON.stringify(operands), which drops the transform function. Without this
|
|
105
|
-
// counter, two LOAD_INT instructions for the same label but different siteKeys
|
|
106
|
-
// would serialize identically and be coalesced into one specialized opcode
|
|
107
|
-
// sharing a single operand object — causing both sites to decode with the
|
|
108
|
-
// first site's key rather than their own.
|
|
103
|
+
// VERY IMPORTANT: All "encoded" label operands include a unique "_id" property that survives JSON.stringify.
|
|
104
|
+
// This allows Specialized Opcodes and other passes to correct distinguish them as the "transform" function WILL NOT be preserved
|
|
109
105
|
let _encodedLabelId = 0;
|
|
110
|
-
|
|
111
106
|
function encodedLabelOperand(
|
|
112
107
|
label: string,
|
|
113
108
|
siteKey: number,
|
|
@@ -131,39 +126,6 @@ function applyEncoding(pc: number, siteKey: number, fnSalt: number): number {
|
|
|
131
126
|
return ((pc - fnSalt) & U16_MAX) ^ siteKey;
|
|
132
127
|
}
|
|
133
128
|
|
|
134
|
-
// ── Register allocation helpers ───────────────────────────────────────────────
|
|
135
|
-
// At pass time FnContext objects are gone; we allocate new virtual registers by
|
|
136
|
-
// scanning the bytecode for the highest existing id per fnId and incrementing.
|
|
137
|
-
function buildMaxIdMap(bc: Bytecode): Map<number, number> {
|
|
138
|
-
const maxId = new Map<number, number>();
|
|
139
|
-
for (const instr of bc) {
|
|
140
|
-
for (let j = 1; j < instr.length; j++) {
|
|
141
|
-
const op = instr[j] as any;
|
|
142
|
-
if (op && op.type === "register") {
|
|
143
|
-
const cur = maxId.get(op.fnId) ?? -1;
|
|
144
|
-
if (op.id > cur) maxId.set(op.fnId, op.id);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
return maxId;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Allocate a new virtual register for fnId, updating maxId in-place.
|
|
152
|
-
function allocReg(fnId: number, maxId: Map<number, number>): RegisterOperand {
|
|
153
|
-
const next = (maxId.get(fnId) ?? -1) + 1;
|
|
154
|
-
maxId.set(fnId, next);
|
|
155
|
-
return b.registerOperand(next, fnId);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// ── Label operand extraction ──────────────────────────────────────────────────
|
|
159
|
-
// Returns the label string if the operand is a { type:"label" } object,
|
|
160
|
-
// otherwise returns null. Used to identify routable jump targets.
|
|
161
|
-
function extractLabel(op: InstrOperand | undefined): string | null {
|
|
162
|
-
if (op && typeof op === "object" && (op as any).type === "label")
|
|
163
|
-
return (op as any).label as string;
|
|
164
|
-
return null;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
129
|
// buildDispatcherBlock: emits the dispatcher label + call + indirect jump.
|
|
168
130
|
// rClosure is already live (created at function entry); this block simply
|
|
169
131
|
// calls the decode closure and jumps to the result.
|
|
@@ -201,7 +163,7 @@ function processFunctionBlock(
|
|
|
201
163
|
compiler: Compiler,
|
|
202
164
|
maxId: Map<number, number>,
|
|
203
165
|
labelCounter: () => string,
|
|
204
|
-
): { instrs: Bytecode;
|
|
166
|
+
): { instrs: Bytecode; tail: Bytecode } {
|
|
205
167
|
const OP = compiler.OP;
|
|
206
168
|
|
|
207
169
|
// Only transform functions that actually contain simple jumps.
|
|
@@ -209,15 +171,13 @@ function processFunctionBlock(
|
|
|
209
171
|
const op = instr[0];
|
|
210
172
|
return op === OP.JUMP || op === OP.JUMP_IF_FALSE || op === OP.JUMP_IF_TRUE;
|
|
211
173
|
});
|
|
212
|
-
if (!hasRoutableJump) return { instrs,
|
|
174
|
+
if (!hasRoutableJump) return { instrs, tail: [] };
|
|
213
175
|
|
|
214
176
|
// Per-function salt baked into this function's decode Template.
|
|
215
177
|
// Never stored as an operand — lives only inside the decode closure body.
|
|
216
178
|
const fnSalt = getRandomInt(1, U16_MAX);
|
|
217
179
|
|
|
218
180
|
// Compile a unique decode closure for this function.
|
|
219
|
-
// The fnSalt literal is inlined into the source so each function's closure
|
|
220
|
-
// body is structurally distinct; no single signature covers all functions.
|
|
221
181
|
const tmpl = new Template(
|
|
222
182
|
`function decode(x, k) { return ((x ^ k) + ${fnSalt}) & ${U16_MAX}; }`,
|
|
223
183
|
).compile({}, compiler);
|
|
@@ -238,6 +198,7 @@ function processFunctionBlock(
|
|
|
238
198
|
decodeDesc.paramCount, // 2 (x, k)
|
|
239
199
|
b.fnRegCountOperand(decodeDesc._fnIdx), // resolved by resolveRegisters()
|
|
240
200
|
0, // no upvalues
|
|
201
|
+
0, // hasRest = false
|
|
241
202
|
]);
|
|
242
203
|
|
|
243
204
|
// ── Transform each instruction ────────────────────────────────────────────
|
|
@@ -311,7 +272,7 @@ function processFunctionBlock(
|
|
|
311
272
|
...buildDispatcherBlock(compiler, rDisp, rKey, rClosure, dispatcherLabel),
|
|
312
273
|
);
|
|
313
274
|
|
|
314
|
-
return { instrs: out,
|
|
275
|
+
return { instrs: out, tail: tmpl.bytecode };
|
|
315
276
|
}
|
|
316
277
|
|
|
317
278
|
// ── Pass entry point ──────────────────────────────────────────────────────────
|
|
@@ -319,80 +280,13 @@ export function dispatcher(
|
|
|
319
280
|
bc: Bytecode,
|
|
320
281
|
compiler: Compiler,
|
|
321
282
|
): { bytecode: Bytecode } {
|
|
322
|
-
// Pre-compute max virtual register id per function across the whole bytecode.
|
|
323
283
|
const maxId = buildMaxIdMap(bc);
|
|
324
|
-
|
|
325
|
-
// Label factory that delegates to the compiler's own counter so labels
|
|
326
|
-
// produced here never collide with compiler-generated or pass-generated ones.
|
|
284
|
+
// Label factory delegates to the compiler's counter so labels never collide.
|
|
327
285
|
const labelCounter = () => compiler._makeLabel("dispatcher");
|
|
328
|
-
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
compiler.fnDescriptors.map((d) => [d.entryLabel!, d._fnIdx!]),
|
|
286
|
+
// forEachFunction collects each function's tail (decode closure bytecode) and
|
|
287
|
+
// appends them all after the last function body, so every MAKE_CLOSURE can
|
|
288
|
+
// reference its entryLabel regardless of where it appears in the bytecode.
|
|
289
|
+
return forEachFunction(bc, compiler, (fnInstrs, fnId) =>
|
|
290
|
+
processFunctionBlock(fnInstrs, fnId, compiler, maxId, labelCounter),
|
|
334
291
|
);
|
|
335
|
-
|
|
336
|
-
const result: Bytecode = [];
|
|
337
|
-
// Collect each function's decode Template bytecode; appended at the end so
|
|
338
|
-
// all MAKE_CLOSURE instructions can reference their entryLabels regardless
|
|
339
|
-
// of where in the bytecode the function appears.
|
|
340
|
-
const decodeBytecodes: Bytecode[] = [];
|
|
341
|
-
let i = 0;
|
|
342
|
-
|
|
343
|
-
while (i < bc.length) {
|
|
344
|
-
const instr = bc[i];
|
|
345
|
-
const [op, operand0] = instr;
|
|
346
|
-
const isEntryLabel =
|
|
347
|
-
op === null &&
|
|
348
|
-
(operand0 as any)?.type === "defineLabel" &&
|
|
349
|
-
entryLabels.has((operand0 as any).label);
|
|
350
|
-
|
|
351
|
-
if (!isEntryLabel) {
|
|
352
|
-
result.push(instr);
|
|
353
|
-
i++;
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// Found a function entry label. Collect all instructions belonging to
|
|
358
|
-
// this function (until the next entry label or end of bytecode).
|
|
359
|
-
const entryLabel = (operand0 as any).label as string;
|
|
360
|
-
const fnId = entryLabelToFnId.get(entryLabel)!;
|
|
361
|
-
i++; // step past the defineLabel itself
|
|
362
|
-
|
|
363
|
-
const fnInstrs: Bytecode = [];
|
|
364
|
-
while (i < bc.length) {
|
|
365
|
-
const next = bc[i];
|
|
366
|
-
const [nextOp, nextOp0] = next;
|
|
367
|
-
if (
|
|
368
|
-
nextOp === null &&
|
|
369
|
-
(nextOp0 as any)?.type === "defineLabel" &&
|
|
370
|
-
entryLabels.has((nextOp0 as any).label)
|
|
371
|
-
)
|
|
372
|
-
break; // next function starts here
|
|
373
|
-
fnInstrs.push(next);
|
|
374
|
-
i++;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// Emit the entry defineLabel, then the (potentially transformed) body.
|
|
378
|
-
result.push(instr); // the defineLabel
|
|
379
|
-
const { instrs: processed, templateBytecode } = processFunctionBlock(
|
|
380
|
-
fnInstrs,
|
|
381
|
-
fnId,
|
|
382
|
-
compiler,
|
|
383
|
-
maxId,
|
|
384
|
-
labelCounter,
|
|
385
|
-
);
|
|
386
|
-
result.push(...processed);
|
|
387
|
-
if (templateBytecode.length > 0) decodeBytecodes.push(templateBytecode);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// Append all per-function decode closure bodies at the end of the bytecode.
|
|
391
|
-
// Each block defines the entryLabel that the corresponding MAKE_CLOSURE
|
|
392
|
-
// instruction references.
|
|
393
|
-
for (const tb of decodeBytecodes) {
|
|
394
|
-
result.push(...tb);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
return { bytecode: result };
|
|
398
292
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Bytecode, Instruction } from "../../types.ts";
|
|
2
|
-
import { Compiler, SOURCE_NODE_SYM } from "../../compiler.ts";
|
|
2
|
+
import { Compiler, OP_ORIGINAL, SOURCE_NODE_SYM } from "../../compiler.ts";
|
|
3
3
|
import { nextFreeSlot } from "../../utils/op-utils.ts";
|
|
4
4
|
import { ok } from "assert";
|
|
5
5
|
|
|
@@ -68,7 +68,7 @@ export function macroOpcodes(
|
|
|
68
68
|
if (nonTerminalExcluded.find((name) => opName.includes(name)))
|
|
69
69
|
return false;
|
|
70
70
|
}
|
|
71
|
-
return OP_NAME[op] !== undefined;
|
|
71
|
+
return OP_NAME[op] !== undefined && OP_ORIGINAL[opName] !== undefined;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
// ── Step 1: count window frequencies ──────────────────────────────────────
|
|
@@ -38,7 +38,12 @@ export function resolveRegisters(
|
|
|
38
38
|
function registerPoolKey(op: {
|
|
39
39
|
kind?: string;
|
|
40
40
|
scopeId?: string | number;
|
|
41
|
+
pinned?: boolean;
|
|
41
42
|
}): string {
|
|
43
|
+
// Pinned registers must never share a slot with anything else.
|
|
44
|
+
// Passes set this on registers whose live range crosses a CFF back-edge
|
|
45
|
+
// that the linear-scan liveness analysis cannot see.
|
|
46
|
+
if (op.pinned) return "local::";
|
|
42
47
|
return `${op.kind ?? "local"}::${op.scopeId ?? ""}`;
|
|
43
48
|
}
|
|
44
49
|
|
|
@@ -35,6 +35,8 @@ export function specializedOpcodes(
|
|
|
35
35
|
}
|
|
36
36
|
>();
|
|
37
37
|
|
|
38
|
+
const instrToOperandKey = new WeakMap<Instruction, string>();
|
|
39
|
+
|
|
38
40
|
for (const instr of bc) {
|
|
39
41
|
const op = instr[0];
|
|
40
42
|
if (op === null || disallowedOps.has(op)) continue;
|
|
@@ -45,21 +47,26 @@ export function specializedOpcodes(
|
|
|
45
47
|
|
|
46
48
|
// Convert numbers into operand objects so they can be modified elsewhere and preserved
|
|
47
49
|
const oldOperands = instr.slice(1);
|
|
48
|
-
|
|
50
|
+
|
|
51
|
+
let operands = [];
|
|
52
|
+
|
|
53
|
+
for (const operand of oldOperands) {
|
|
49
54
|
if (typeof operand === "number") {
|
|
50
|
-
|
|
55
|
+
operands.push({
|
|
51
56
|
type: "number",
|
|
52
57
|
value: operand,
|
|
53
58
|
resolvedValue: operand,
|
|
54
|
-
} as InstrOperand;
|
|
59
|
+
} as InstrOperand);
|
|
60
|
+
} else {
|
|
61
|
+
operands.push(operand as InstrOperand);
|
|
55
62
|
}
|
|
56
|
-
|
|
57
|
-
});
|
|
63
|
+
}
|
|
58
64
|
|
|
59
65
|
instr.length = 1;
|
|
60
66
|
instr.push(...operands);
|
|
61
67
|
|
|
62
68
|
const operandsKey = JSON.stringify(operands);
|
|
69
|
+
instrToOperandKey.set(instr, operandsKey);
|
|
63
70
|
|
|
64
71
|
const key = `${op},${operandsKey}`;
|
|
65
72
|
const entry = freqMap.get(key);
|
|
@@ -78,7 +85,8 @@ export function specializedOpcodes(
|
|
|
78
85
|
// ── Step 2: keep combinations that appear >= 2 times, sort by frequency ───
|
|
79
86
|
const candidates = Array.from(freqMap.values())
|
|
80
87
|
.filter((e) => e.occurences >= 1)
|
|
81
|
-
.sort((a, b) => b.occurences - a.occurences)
|
|
88
|
+
.sort((a, b) => b.occurences - a.occurences)
|
|
89
|
+
.slice(0, 1000);
|
|
82
90
|
|
|
83
91
|
if (candidates.length === 0) return { bytecode: bc };
|
|
84
92
|
|
|
@@ -86,10 +94,10 @@ export function specializedOpcodes(
|
|
|
86
94
|
const sigToSpecial = new Map<string, number>();
|
|
87
95
|
const specializedOps: Compiler["SPECIALIZED_OPS"] = {};
|
|
88
96
|
|
|
89
|
-
for (
|
|
97
|
+
for (const candidate of candidates) {
|
|
90
98
|
const specialOp = nextFreeSlot(compiler);
|
|
91
99
|
if (specialOp === -1) break;
|
|
92
|
-
const { op: originalOp, operands, operandsKey } =
|
|
100
|
+
const { op: originalOp, operands, operandsKey } = candidate;
|
|
93
101
|
|
|
94
102
|
const key = `${originalOp},${operandsKey}`;
|
|
95
103
|
sigToSpecial.set(key, specialOp);
|
|
@@ -98,8 +106,7 @@ export function specializedOpcodes(
|
|
|
98
106
|
|
|
99
107
|
// Register a human-readable name for disassembly / debugging
|
|
100
108
|
const originalName = compiler.OP_NAME[originalOp] ?? `OP_${originalOp}`;
|
|
101
|
-
compiler.OP_NAME[specialOp] =
|
|
102
|
-
`${originalName}_${JSON.stringify(operandsKey)}`;
|
|
109
|
+
compiler.OP_NAME[specialOp] = `${originalName}_${operandsKey}`;
|
|
103
110
|
}
|
|
104
111
|
|
|
105
112
|
// Store mapping so the interpreter knows how to dispatch the specialized op
|
|
@@ -117,7 +124,11 @@ export function specializedOpcodes(
|
|
|
117
124
|
}
|
|
118
125
|
|
|
119
126
|
const operands = instr.slice(1);
|
|
120
|
-
const operandsKey =
|
|
127
|
+
const operandsKey = instrToOperandKey.get(instr);
|
|
128
|
+
if (!operandsKey) {
|
|
129
|
+
result.push(instr);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
121
132
|
|
|
122
133
|
const key = `${op},${operandsKey}`;
|
|
123
134
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// String Concealing
|
|
2
|
+
//
|
|
3
|
+
// Encodes every string constant in each function with base64, then inserts a
|
|
4
|
+
// decode closure (atob) that is called immediately after each LOAD_CONST to
|
|
5
|
+
// recover the original value at runtime.
|
|
6
|
+
//
|
|
7
|
+
// ── How it works ─────────────────────────────────────────────────────────────
|
|
8
|
+
//
|
|
9
|
+
// Each function that contains at least one string LOAD_CONST gets:
|
|
10
|
+
//
|
|
11
|
+
// rClosure — a register holding the decode closure, created ONCE at function
|
|
12
|
+
// entry (hoisted). All decode calls within the function reuse it.
|
|
13
|
+
//
|
|
14
|
+
// The decode function is compiled ONCE (shared across all functions) from a
|
|
15
|
+
// Template:
|
|
16
|
+
//
|
|
17
|
+
// function decode(encoded) { return atob(encoded); }
|
|
18
|
+
//
|
|
19
|
+
// String constant transformations:
|
|
20
|
+
//
|
|
21
|
+
// Original: LOAD_CONST rDst, "hello"
|
|
22
|
+
// Becomes: LOAD_CONST rDst, "aGVsbG8=" (base64-encoded)
|
|
23
|
+
// CALL rDst, rClosure, 1, rDst (decode in-place)
|
|
24
|
+
//
|
|
25
|
+
// ── Pipeline position ─────────────────────────────────────────────────────────
|
|
26
|
+
// Runs BEFORE resolveRegisters and resolveLabels (same slot as Dispatcher/CFF).
|
|
27
|
+
|
|
28
|
+
import { Compiler } from "../../compiler.ts";
|
|
29
|
+
import { Template } from "../../template.ts";
|
|
30
|
+
import type { Bytecode, RegisterOperand } from "../../types.ts";
|
|
31
|
+
import * as b from "../../types.ts";
|
|
32
|
+
import { ref, buildMaxIdMap, allocReg, forEachFunction } from "../../utils/pass-utils.ts";
|
|
33
|
+
|
|
34
|
+
// ── Per-function transformation ──────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
function processFunctionBlock(
|
|
37
|
+
instrs: Bytecode,
|
|
38
|
+
fnId: number,
|
|
39
|
+
compiler: Compiler,
|
|
40
|
+
maxId: Map<number, number>,
|
|
41
|
+
decodeDesc: any,
|
|
42
|
+
): { instrs: Bytecode } {
|
|
43
|
+
const OP = compiler.OP;
|
|
44
|
+
|
|
45
|
+
// Only transform functions that contain string constants.
|
|
46
|
+
const hasStringConst = instrs.some((instr) => {
|
|
47
|
+
if (instr[0] !== OP.LOAD_CONST) return false;
|
|
48
|
+
const operands = instr.slice(1);
|
|
49
|
+
return (
|
|
50
|
+
operands.length === 2 &&
|
|
51
|
+
(operands[1] as any)?.type === "constant" &&
|
|
52
|
+
typeof (operands[1] as any).value === "string"
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
if (!hasStringConst) return { instrs };
|
|
56
|
+
|
|
57
|
+
const rClosure = allocReg(fnId, maxId);
|
|
58
|
+
const out: Bytecode = [];
|
|
59
|
+
|
|
60
|
+
// Hoist: create the decode closure once at function entry.
|
|
61
|
+
out.push([
|
|
62
|
+
OP.MAKE_CLOSURE!,
|
|
63
|
+
ref(rClosure),
|
|
64
|
+
{ type: "label", label: decodeDesc.entryLabel },
|
|
65
|
+
decodeDesc.paramCount, // 1 (encoded)
|
|
66
|
+
b.fnRegCountOperand(decodeDesc._fnIdx),
|
|
67
|
+
0, // no upvalues
|
|
68
|
+
0, // hasRest = false
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
// Transform each instruction.
|
|
72
|
+
for (const instr of instrs) {
|
|
73
|
+
if (
|
|
74
|
+
instr[0] === OP.LOAD_CONST &&
|
|
75
|
+
instr.length === 3 &&
|
|
76
|
+
(instr[2] as any)?.type === "constant" &&
|
|
77
|
+
typeof (instr[2] as any).value === "string"
|
|
78
|
+
) {
|
|
79
|
+
const dst = instr[1] as RegisterOperand;
|
|
80
|
+
const constOp = instr[2] as any;
|
|
81
|
+
|
|
82
|
+
// Encode the string in-place.
|
|
83
|
+
constOp.value = Buffer.from(constOp.value as string, "utf-8").toString(
|
|
84
|
+
"base64",
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
out.push(instr);
|
|
88
|
+
|
|
89
|
+
// Decode: rDst = decode(rDst)
|
|
90
|
+
out.push([
|
|
91
|
+
OP.CALL!,
|
|
92
|
+
ref(dst), // dst — receives decoded string
|
|
93
|
+
ref(rClosure), // the hoisted decode closure
|
|
94
|
+
1, // argc
|
|
95
|
+
ref(dst), // arg[0] = encoded value
|
|
96
|
+
]);
|
|
97
|
+
} else {
|
|
98
|
+
out.push(instr);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { instrs: out };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Pass entry point ──────────────────────────────────────────────────────────
|
|
106
|
+
export function stringConcealing(
|
|
107
|
+
bc: Bytecode,
|
|
108
|
+
compiler: Compiler,
|
|
109
|
+
): { bytecode: Bytecode } {
|
|
110
|
+
const maxId = buildMaxIdMap(bc);
|
|
111
|
+
|
|
112
|
+
// Compile the decode function ONCE — all functions share the same closure body.
|
|
113
|
+
const decodeTemplate = new Template(`
|
|
114
|
+
function decode(encoded) {
|
|
115
|
+
return atob(encoded);
|
|
116
|
+
}
|
|
117
|
+
`).compile({}, compiler);
|
|
118
|
+
const decodeDesc = decodeTemplate.functions[0];
|
|
119
|
+
|
|
120
|
+
const { bytecode } = forEachFunction(bc, compiler, (fnInstrs, fnId) =>
|
|
121
|
+
processFunctionBlock(fnInstrs, fnId, compiler, maxId, decodeDesc),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Append the decode function's bytecode at the end (defines its entryLabel).
|
|
125
|
+
// This is a single shared closure, not per-function, so it lives outside
|
|
126
|
+
// forEachFunction's tail mechanism.
|
|
127
|
+
bytecode.push(...decodeTemplate.bytecode);
|
|
128
|
+
|
|
129
|
+
return { bytecode };
|
|
130
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Compiler } from "../../compiler.ts";
|
|
2
|
+
import * as t from "@babel/types";
|
|
3
|
+
import { shuffle } from "../../utils/random-utils.ts";
|
|
4
|
+
|
|
5
|
+
function hasComment(node: t.Node, text: string): boolean {
|
|
6
|
+
const all = [
|
|
7
|
+
...((node as any).leadingComments ?? []),
|
|
8
|
+
...((node as any).innerComments ?? []),
|
|
9
|
+
...((node as any).trailingComments ?? []),
|
|
10
|
+
];
|
|
11
|
+
return all.some((c) => c.value.includes(text));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isPrototypeAssignment(stmt: t.Statement): boolean {
|
|
15
|
+
if (!t.isExpressionStatement(stmt)) return false;
|
|
16
|
+
const expr = stmt.expression;
|
|
17
|
+
if (!t.isAssignmentExpression(expr)) return false;
|
|
18
|
+
const left = expr.left;
|
|
19
|
+
return (
|
|
20
|
+
t.isMemberExpression(left) &&
|
|
21
|
+
t.isMemberExpression(left.object) &&
|
|
22
|
+
t.isIdentifier((left.object as t.MemberExpression).property, {
|
|
23
|
+
name: "prototype",
|
|
24
|
+
})
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function applyClassObfuscation(ast: t.File, _compiler: Compiler): void {
|
|
29
|
+
const body = ast.program.body;
|
|
30
|
+
|
|
31
|
+
// Split at the first statement that carries the @BOOT comment.
|
|
32
|
+
// Everything from that statement onward is the boot section and must stay last.
|
|
33
|
+
let bootIdx = body.findIndex((stmt) => hasComment(stmt, "@BOOT"));
|
|
34
|
+
if (bootIdx === -1) bootIdx = body.length;
|
|
35
|
+
|
|
36
|
+
const shufflable = body.slice(0, bootIdx);
|
|
37
|
+
const boot = body.slice(bootIdx);
|
|
38
|
+
|
|
39
|
+
// Partition the shufflable section into two independent groups.
|
|
40
|
+
// Group A: variable/function declarations (constructors, standalone vars).
|
|
41
|
+
// Group B: prototype method assignments (X.prototype.Y = ...).
|
|
42
|
+
// Both groups are shuffled independently; A always precedes B so that
|
|
43
|
+
// constructors are defined before methods reference them.
|
|
44
|
+
const varDecls: t.Statement[] = [];
|
|
45
|
+
const methodDefs: t.Statement[] = [];
|
|
46
|
+
|
|
47
|
+
for (const stmt of shufflable) {
|
|
48
|
+
if (isPrototypeAssignment(stmt)) {
|
|
49
|
+
methodDefs.push(stmt);
|
|
50
|
+
} else {
|
|
51
|
+
varDecls.push(stmt);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
shuffle(varDecls);
|
|
56
|
+
shuffle(methodDefs);
|
|
57
|
+
|
|
58
|
+
ast.program.body = [...varDecls, ...methodDefs, ...boot];
|
|
59
|
+
}
|
|
@@ -27,6 +27,8 @@ function extractCaseBody(switchCase: t.SwitchCase): t.Statement[] {
|
|
|
27
27
|
// *exactly one* numeric operand, every `_operand()` call inside the original
|
|
28
28
|
// handler is replaced by the constant value that was baked into the opcode.
|
|
29
29
|
function inlineFixedOperands(
|
|
30
|
+
newName: string, // for debugging
|
|
31
|
+
info: any,
|
|
30
32
|
bodyStmts: t.Statement[],
|
|
31
33
|
resolvedValues: number[],
|
|
32
34
|
): void {
|
|
@@ -66,10 +68,12 @@ function inlineFixedOperands(
|
|
|
66
68
|
},
|
|
67
69
|
});
|
|
68
70
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
if (replaced !== resolvedValues.length) {
|
|
72
|
+
console.error(resolvedValues, info);
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Specialized Opcode Inline Error: Given ${resolvedValues.length} operands to replace, but only found ${replaced} for ${newName}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
// Append a generated switch case for every entry in compiler.SPECIALIZED_OPS.
|
|
@@ -119,12 +123,13 @@ export function applySpecializedOpcodes(ast: t.File, compiler: Compiler): void {
|
|
|
119
123
|
const placedOperands = info.operands;
|
|
120
124
|
ok(placedOperands, `Could not find operand for original opcode ${newName}`);
|
|
121
125
|
|
|
122
|
-
const resolvedValues = placedOperands
|
|
123
|
-
|
|
124
|
-
|
|
126
|
+
const resolvedValues = placedOperands
|
|
127
|
+
// .filter((x) => !(x as any)?.placeholder)
|
|
128
|
+
.map((placedOperand) => {
|
|
129
|
+
return (placedOperand as any)?.resolvedValue ?? placedOperand;
|
|
130
|
+
});
|
|
125
131
|
|
|
126
132
|
if (resolvedValues.find((v) => typeof v !== "number")) {
|
|
127
|
-
console.error(info);
|
|
128
133
|
throw new Error("Expected all resolved operand values to be numbers");
|
|
129
134
|
}
|
|
130
135
|
|
|
@@ -132,7 +137,7 @@ export function applySpecializedOpcodes(ast: t.File, compiler: Compiler): void {
|
|
|
132
137
|
compiler.OP_NAME[specialOpCode] = newName;
|
|
133
138
|
|
|
134
139
|
// Replace this._operand() with the baked-in constant
|
|
135
|
-
inlineFixedOperands(bodyStmts, resolvedValues);
|
|
140
|
+
inlineFixedOperands(newName, info, bodyStmts, resolvedValues);
|
|
136
141
|
|
|
137
142
|
// Add a leading comment so the generated source stays readable
|
|
138
143
|
if (bodyStmts.length > 0) {
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Bytecode supports both real instructions and IR pseudo-instructions
|
|
2
|
-
// Real instruction: [OP.ADD, 5] or multi-operand: [OP.MAKE_CLOSURE, labelRef, 2, 3, 0]
|
|
2
|
+
// Real instruction: [OP.ADD, 5] or multi-operand: [OP.MAKE_CLOSURE, labelRef, 2, 3, 0, 0]
|
|
3
3
|
// IR instruction: [null, { type: "defineLabel", label: "FN_ENTRY_1" }]
|
|
4
4
|
|
|
5
5
|
// IR instructions are used to hold symbolic information during compilation
|
|
@@ -15,6 +15,11 @@ export type RegisterOperand = Op<{
|
|
|
15
15
|
fnId: number;
|
|
16
16
|
kind?: string;
|
|
17
17
|
scopeId?: string | number;
|
|
18
|
+
// If true, resolveRegisters always assigns this register to the "local::"
|
|
19
|
+
// pool (no slot reuse). Set by passes that emit registers whose live ranges
|
|
20
|
+
// span CFF dispatch-loop back-edges — regions the linear-scan liveness
|
|
21
|
+
// analysis cannot reason about.
|
|
22
|
+
pinned?: boolean;
|
|
18
23
|
}>;
|
|
19
24
|
|
|
20
25
|
// A placeholder for a function's concrete regCount, emitted in MAKE_CLOSURE.
|
|
@@ -91,3 +96,39 @@ export function freeRegOperand(reg: RegisterOperand): FreeRegOperand {
|
|
|
91
96
|
if (reg.scopeId !== undefined) op.scopeId = reg.scopeId;
|
|
92
97
|
return op;
|
|
93
98
|
}
|
|
99
|
+
|
|
100
|
+
export interface ObfuscationResult {
|
|
101
|
+
code: string;
|
|
102
|
+
profileData?: {
|
|
103
|
+
handlerCount: number;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* How long the entire obfuscation process takes (ms)
|
|
107
|
+
*/
|
|
108
|
+
obfuscationTime?: number;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* How long @babel/parser takes (ms)
|
|
112
|
+
*/
|
|
113
|
+
parseTime?: number;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* How long @babel/generator takes (ms)
|
|
117
|
+
*/
|
|
118
|
+
generateTime?: number;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* How long the Compiler#compileAST takes (ms)
|
|
122
|
+
*/
|
|
123
|
+
compileTime?: number;
|
|
124
|
+
|
|
125
|
+
transforms: {
|
|
126
|
+
[transformName: string]: {
|
|
127
|
+
fileSize?: number;
|
|
128
|
+
bytecodeSize?: number;
|
|
129
|
+
transformTime?: number;
|
|
130
|
+
handlerCount?: number;
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as t from "@babel/types";
|
|
2
|
+
import traverseImport from "@babel/traverse";
|
|
3
|
+
|
|
4
|
+
const traverse = (traverseImport.default ||
|
|
5
|
+
traverseImport) as typeof traverseImport.default;
|
|
6
|
+
|
|
7
|
+
export function getSwitchStatement(ast: t.File) {
|
|
8
|
+
let switchStatement: t.SwitchStatement | null = null;
|
|
9
|
+
traverse(ast, {
|
|
10
|
+
SwitchStatement(path) {
|
|
11
|
+
if (path.node.leadingComments?.some((c) => c.value.includes("@SWITCH"))) {
|
|
12
|
+
switchStatement = path.node;
|
|
13
|
+
path.stop();
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return switchStatement;
|
|
19
|
+
}
|
package/src/utils/op-utils.ts
CHANGED
|
@@ -21,7 +21,6 @@ export function nextFreeSlot(compiler: Compiler): number {
|
|
|
21
21
|
while (attempts++ < 512) {
|
|
22
22
|
const candidate = getRandomInt(0, U16_MAX);
|
|
23
23
|
if (!usedOpcodes.has(candidate)) {
|
|
24
|
-
usedOpcodes.add(candidate);
|
|
25
24
|
return candidate;
|
|
26
25
|
}
|
|
27
26
|
}
|
|
@@ -32,7 +31,6 @@ export function nextFreeSlot(compiler: Compiler): number {
|
|
|
32
31
|
for (let i = 0; i <= U16_MAX; i++) {
|
|
33
32
|
const v = (start + i) & U16_MAX;
|
|
34
33
|
if (!usedOpcodes.has(v)) {
|
|
35
|
-
usedOpcodes.add(v);
|
|
36
34
|
return v;
|
|
37
35
|
}
|
|
38
36
|
}
|