js-confuser-vm 0.1.0 → 0.1.2

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.
Files changed (63) hide show
  1. package/README.md +281 -147
  2. package/dist/build-runtime.js +41 -15
  3. package/dist/compiler.js +714 -265
  4. package/dist/disassembler.js +367 -0
  5. package/dist/index.js +7 -2
  6. package/dist/runtime.js +160 -119
  7. package/dist/template.js +163 -42
  8. package/dist/transforms/bytecode/aliasedOpcodes.js +4 -1
  9. package/dist/transforms/bytecode/concealConstants.js +2 -2
  10. package/dist/transforms/bytecode/controlFlowFlattening.js +569 -0
  11. package/dist/transforms/bytecode/dispatcher.js +15 -111
  12. package/dist/transforms/bytecode/macroOpcodes.js +2 -2
  13. package/{src/transforms/bytecode/resolveContants.ts → dist/transforms/bytecode/resolveConstants.js} +30 -56
  14. package/dist/transforms/bytecode/resolveRegisters.js +23 -4
  15. package/dist/transforms/bytecode/selfModifying.js +88 -21
  16. package/dist/transforms/bytecode/semanticOpcodes.js +162 -0
  17. package/dist/transforms/bytecode/specializedOpcodes.js +23 -12
  18. package/dist/transforms/bytecode/stringConcealing.js +288 -0
  19. package/dist/transforms/runtime/classObfuscation.js +43 -0
  20. package/dist/transforms/runtime/handlerTable.js +91 -0
  21. package/dist/transforms/runtime/semanticOpcodes.js +35 -0
  22. package/dist/transforms/runtime/specializedOpcodes.js +11 -5
  23. package/dist/types.js +1 -1
  24. package/dist/utils/ast-utils.js +75 -0
  25. package/dist/utils/op-utils.js +1 -2
  26. package/dist/utils/pass-utils.js +100 -0
  27. package/dist/utils/profile-utils.js +3 -0
  28. package/package.json +8 -1
  29. package/.gitmodules +0 -4
  30. package/.prettierignore +0 -1
  31. package/CHANGELOG.md +0 -335
  32. package/babel-plugin-inline-runtime.cjs +0 -34
  33. package/babel.config.json +0 -23
  34. package/index.ts +0 -38
  35. package/jest-strip-types.js +0 -10
  36. package/jest.config.js +0 -52
  37. package/src/build-runtime.ts +0 -78
  38. package/src/compiler.ts +0 -2593
  39. package/src/index.ts +0 -14
  40. package/src/minify.ts +0 -21
  41. package/src/options.ts +0 -18
  42. package/src/runtime.ts +0 -923
  43. package/src/template.ts +0 -141
  44. package/src/transforms/bytecode/aliasedOpcodes.ts +0 -148
  45. package/src/transforms/bytecode/concealConstants.ts +0 -52
  46. package/src/transforms/bytecode/dispatcher.ts +0 -398
  47. package/src/transforms/bytecode/macroOpcodes.ts +0 -193
  48. package/src/transforms/bytecode/microOpcodes.ts +0 -291
  49. package/src/transforms/bytecode/resolveLabels.ts +0 -112
  50. package/src/transforms/bytecode/resolveRegisters.ts +0 -221
  51. package/src/transforms/bytecode/selfModifying.ts +0 -121
  52. package/src/transforms/bytecode/specializedOpcodes.ts +0 -153
  53. package/src/transforms/runtime/aliasedOpcodes.ts +0 -191
  54. package/src/transforms/runtime/internalVariables.ts +0 -270
  55. package/src/transforms/runtime/macroOpcodes.ts +0 -138
  56. package/src/transforms/runtime/microOpcodes.ts +0 -93
  57. package/src/transforms/runtime/minify.ts +0 -1
  58. package/src/transforms/runtime/shuffleOpcodes.ts +0 -24
  59. package/src/transforms/runtime/specializedOpcodes.ts +0 -156
  60. package/src/types.ts +0 -93
  61. package/src/utils/op-utils.ts +0 -48
  62. package/src/utils/random-utils.ts +0 -31
  63. package/tsconfig.json +0 -12
@@ -0,0 +1,162 @@
1
+ import { parse } from "@babel/parser";
2
+ import traverseImport from "@babel/traverse";
3
+ import * as t from "@babel/types";
4
+ import { stripTypeScriptTypes } from "module";
5
+ import { choice } from "../../utils/random-utils.js";
6
+ import { SOURCE_NODE_SYM } from "../../compiler.js";
7
+ import { nextFreeSlot } from "../../utils/op-utils.js";
8
+ const traverse = traverseImport.default || traverseImport;
9
+ const SEMANTIC_VARIANTS = {
10
+ ADD: {
11
+ matches(expr) {
12
+ return t.isBinaryExpression(expr, {
13
+ operator: "+"
14
+ });
15
+ },
16
+ variants: ["{ var dst = this._operand(); var a = regs[base + this._operand()]; var b = regs[base + this._operand()]; regs[base + dst] = -((-a) - b); }", "{ var dst = this._operand(); var a = regs[base + this._operand()]; var b = regs[base + this._operand()]; regs[base + dst] = a - -b; }"]
17
+ },
18
+ UNARY_BITNOT: {
19
+ matches(expr) {
20
+ return t.isUnaryExpression(expr, {
21
+ operator: "~"
22
+ });
23
+ },
24
+ variants: ["{ var dst = this._operand(); var a = regs[base + this._operand()]; regs[base + dst] = a ^ -1; }"]
25
+ },
26
+ UNARY_NEG: {
27
+ matches(expr) {
28
+ return t.isUnaryExpression(expr, {
29
+ operator: "-"
30
+ });
31
+ },
32
+ variants: ["{ var dst = this._operand(); var a = regs[base + this._operand()]; regs[base + dst] = a * -1; }", "{ var dst = this._operand(); var a = regs[base + this._operand()]; regs[base + dst] = 0 - a; }"]
33
+ },
34
+ EQ: {
35
+ matches(expr) {
36
+ return t.isBinaryExpression(expr, {
37
+ operator: "==="
38
+ });
39
+ },
40
+ variants: ["{ var dst = this._operand(); var a = regs[base + this._operand()]; var b = regs[base + this._operand()]; regs[base + dst] = !(a !== b); }"]
41
+ },
42
+ LOOSE_EQ: {
43
+ matches(expr) {
44
+ return t.isBinaryExpression(expr, {
45
+ operator: "=="
46
+ });
47
+ },
48
+ variants: ["{ var dst = this._operand(); var a = regs[base + this._operand()]; var b = regs[base + this._operand()]; regs[base + dst] = !(a != b); }"]
49
+ }
50
+ };
51
+ let cachedDefaultRuntimeAst = null;
52
+ function readDefaultRuntimeFile() {
53
+ let code;
54
+ try {
55
+ code = readFileSync(join(import.meta.dirname, "../../runtime.ts"), "utf-8");
56
+ } catch {
57
+ code = readFileSync(join(import.meta.dirname, "../../runtime.js"), "utf-8");
58
+ }
59
+ return stripTypeScriptTypes?.(code) || code;
60
+ }
61
+ function getDefaultRuntimeAst() {
62
+ if (!cachedDefaultRuntimeAst) {
63
+ cachedDefaultRuntimeAst = parse(readDefaultRuntimeFile(), {
64
+ sourceType: "unambiguous"
65
+ });
66
+ }
67
+ return cachedDefaultRuntimeAst;
68
+ }
69
+ function getSwitchStatement(ast) {
70
+ let switchStatement = null;
71
+ traverse(ast, {
72
+ SwitchStatement(path) {
73
+ if (path.node.leadingComments?.some(c => c.value.includes("@SWITCH"))) {
74
+ switchStatement = path.node;
75
+ path.stop();
76
+ }
77
+ }
78
+ });
79
+ if (!switchStatement) {
80
+ throw new Error("Could not find @SWITCH statement for semantic opcodes");
81
+ }
82
+ return switchStatement;
83
+ }
84
+ function extractResultExpression(switchCase) {
85
+ const consequent = switchCase.consequent.length === 1 && t.isBlockStatement(switchCase.consequent[0]) ? switchCase.consequent[0].body : switchCase.consequent;
86
+ for (const statement of consequent) {
87
+ if (t.isExpressionStatement(statement) && t.isAssignmentExpression(statement.expression, {
88
+ operator: "="
89
+ })) {
90
+ return statement.expression.right;
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+ function getEligibleSemanticVariants(compiler) {
96
+ const eligible = new Map();
97
+ const switchStatement = getSwitchStatement(getDefaultRuntimeAst());
98
+ for (const switchCase of switchStatement.cases) {
99
+ const test = switchCase.test;
100
+ if (!test || !t.isMemberExpression(test) || !t.isIdentifier(test.object, {
101
+ name: "OP"
102
+ }) || !t.isIdentifier(test.property)) {
103
+ continue;
104
+ }
105
+ const opName = test.property.name;
106
+ const config = SEMANTIC_VARIANTS[opName];
107
+ if (!config) continue;
108
+ const expr = extractResultExpression(switchCase);
109
+ if (!expr || !config.matches(expr)) continue;
110
+ const opcode = compiler.OP[opName];
111
+ if (typeof opcode !== "number") continue;
112
+ eligible.set(opcode, config.variants);
113
+ }
114
+ return eligible;
115
+ }
116
+ export function semanticOpcodes(bc, compiler) {
117
+ const eligibleVariants = getEligibleSemanticVariants(compiler);
118
+ if (eligibleVariants.size === 0) {
119
+ return {
120
+ bytecode: bc
121
+ };
122
+ }
123
+ const semanticOpsByOriginal = new Map();
124
+ const semanticOps = {};
125
+ for (const [originalOp, variants] of eligibleVariants) {
126
+ const originalName = compiler.OP_NAME[originalOp] ?? `OP_${originalOp}`;
127
+ for (let i = 0; i < variants.length; i++) {
128
+ const semanticOp = nextFreeSlot(compiler);
129
+ if (semanticOp === -1) break;
130
+ semanticOps[semanticOp] = {
131
+ originalOp,
132
+ code: variants[i]
133
+ };
134
+ compiler.OP_NAME[semanticOp] = `SEMANTIC_${originalName}_${i + 1}`;
135
+ const existing = semanticOpsByOriginal.get(originalOp) ?? [];
136
+ existing.push(semanticOp);
137
+ semanticOpsByOriginal.set(originalOp, existing);
138
+ }
139
+ }
140
+ compiler.SEMANTIC_OPS = semanticOps;
141
+ if (semanticOpsByOriginal.size === 0) {
142
+ return {
143
+ bytecode: bc
144
+ };
145
+ }
146
+ const result = [];
147
+ for (const instr of bc) {
148
+ const op = instr[0];
149
+ const semanticCandidates = typeof op === "number" ? semanticOpsByOriginal.get(op) : undefined;
150
+ if (!semanticCandidates || semanticCandidates.length === 0) {
151
+ result.push(instr);
152
+ continue;
153
+ }
154
+ const semanticOp = choice(semanticCandidates);
155
+ const newInstr = [semanticOp, ...instr.slice(1)];
156
+ newInstr[SOURCE_NODE_SYM] = instr[SOURCE_NODE_SYM];
157
+ result.push(newInstr);
158
+ }
159
+ return {
160
+ bytecode: result
161
+ };
162
+ }
@@ -14,6 +14,7 @@ export function specializedOpcodes(bc, compiler) {
14
14
 
15
15
  // ── Step 1: count frequency of eligible (op, operand) pairs ───────────────
16
16
  const freqMap = new Map();
17
+ const instrToOperandKey = new WeakMap();
17
18
  for (const instr of bc) {
18
19
  const op = instr[0];
19
20
  if (op === null || disallowedOps.has(op)) continue;
@@ -24,35 +25,38 @@ export function specializedOpcodes(bc, compiler) {
24
25
 
25
26
  // Convert numbers into operand objects so they can be modified elsewhere and preserved
26
27
  const oldOperands = instr.slice(1);
27
- const operands = oldOperands.map(operand => {
28
+ let operands = [];
29
+ for (const operand of oldOperands) {
28
30
  if (typeof operand === "number") {
29
- return {
31
+ operands.push({
30
32
  type: "number",
31
33
  value: operand,
32
34
  resolvedValue: operand
33
- };
35
+ });
36
+ } else {
37
+ operands.push(operand);
34
38
  }
35
- return operand;
36
- });
39
+ }
37
40
  instr.length = 1;
38
41
  instr.push(...operands);
39
42
  const operandsKey = JSON.stringify(operands);
43
+ instrToOperandKey.set(instr, operandsKey);
40
44
  const key = `${op},${operandsKey}`;
41
45
  const entry = freqMap.get(key);
42
46
  if (entry) {
43
- entry.occurences++;
47
+ entry.occurrences++;
44
48
  } else {
45
49
  freqMap.set(key, {
46
50
  op,
47
51
  operands,
48
52
  operandsKey,
49
- occurences: 1
53
+ occurrences: 1
50
54
  });
51
55
  }
52
56
  }
53
57
 
54
58
  // ── Step 2: keep combinations that appear >= 2 times, sort by frequency ───
55
- const candidates = Array.from(freqMap.values()).filter(e => e.occurences >= 1).sort((a, b) => b.occurences - a.occurences);
59
+ const candidates = Array.from(freqMap.values()).filter(e => e.occurrences >= 1).sort((a, b) => b.occurrences - a.occurrences).slice(0, 1000);
56
60
  if (candidates.length === 0) return {
57
61
  bytecode: bc
58
62
  };
@@ -60,14 +64,17 @@ export function specializedOpcodes(bc, compiler) {
60
64
  // ── Step 3: assign free opcode slots to the best candidates ───────────────
61
65
  const sigToSpecial = new Map();
62
66
  const specializedOps = {};
63
- for (let i = 0; i < candidates.length; i++) {
67
+ let opCounts = {};
68
+ for (const candidate of candidates) {
69
+ if (opCounts[candidate.op] > 3) continue;
70
+ opCounts[candidate.op] = (opCounts[candidate.op] || 0) + 1;
64
71
  const specialOp = nextFreeSlot(compiler);
65
72
  if (specialOp === -1) break;
66
73
  const {
67
74
  op: originalOp,
68
75
  operands,
69
76
  operandsKey
70
- } = candidates[i];
77
+ } = candidate;
71
78
  const key = `${originalOp},${operandsKey}`;
72
79
  sigToSpecial.set(key, specialOp);
73
80
  specializedOps[specialOp] = {
@@ -77,7 +84,7 @@ export function specializedOpcodes(bc, compiler) {
77
84
 
78
85
  // Register a human-readable name for disassembly / debugging
79
86
  const originalName = compiler.OP_NAME[originalOp] ?? `OP_${originalOp}`;
80
- compiler.OP_NAME[specialOp] = `${originalName}_${JSON.stringify(operandsKey)}`;
87
+ compiler.OP_NAME[specialOp] = `${originalName}_${operandsKey}`;
81
88
  }
82
89
 
83
90
  // Store mapping so the interpreter knows how to dispatch the specialized op
@@ -93,7 +100,11 @@ export function specializedOpcodes(bc, compiler) {
93
100
  continue;
94
101
  }
95
102
  const operands = instr.slice(1);
96
- const operandsKey = JSON.stringify(operands);
103
+ const operandsKey = instrToOperandKey.get(instr);
104
+ if (!operandsKey) {
105
+ result.push(instr);
106
+ continue;
107
+ }
97
108
  const key = `${op},${operandsKey}`;
98
109
  const specialOpCode = sigToSpecial.get(key);
99
110
  if (!specialOpCode) {
@@ -0,0 +1,288 @@
1
+ // String Concealing
2
+ //
3
+ // Replaces every string constant with a slice into a single shared "string
4
+ // bank" that is decoded at runtime with a position-dependent keystream cipher.
5
+ //
6
+ // ── Why a bank ───────────────────────────────────────────────────────────────
7
+ // Previously each string was its own encoded constant, so the encoded length
8
+ // leaked the plaintext length (an attacker could map by length + frequency).
9
+ // Here every string is concatenated into ONE opaque blob padded with random
10
+ // decoy bytes, so individual boundaries and lengths are no longer visible from
11
+ // static inspection of the constant pool.
12
+ //
13
+ // bank = [100‑250 decoys] str0 [0‑5 decoys] str1 [0‑5 decoys] … [100‑250 decoys]
14
+ //
15
+ // Each original string is referenced by the triple (key, start, length).
16
+ //
17
+ // ── Cipher ───────────────────────────────────────────────────────────────────
18
+ // A Weyl-sequence keystream (golden-ratio increment + xorshift mix) produces a
19
+ // fresh 16-bit keyword per character, XOR'd against the char code:
20
+ //
21
+ // key = (key + 0x9e3779b9) | 0 // 32-bit Weyl step
22
+ // ks = (key ^ (key >>> 13)) & 0xffff // 16-bit keystream word
23
+ // enc = charCode ^ ks // XOR (self-inverse)
24
+ //
25
+ // XOR over the full 16-bit range means EVERY UTF-16 code unit round-trips,
26
+ // including control characters, newlines and non-ASCII / astral text. The
27
+ // per-string key is a full 32-bit seed (2^32 keyspace) so the encoding is not
28
+ // trivially enumerable.
29
+ //
30
+ // ── Transport / storage ──────────────────────────────────────────────────────
31
+ // The encoded bank is full-range u16, which would serialise as a wall of CJK /
32
+ // control glyphs. Instead it is packed as u16-LE bytes and base64-encoded, so
33
+ // the stored constant is pure ASCII (and smaller on disk than the raw glyphs).
34
+ //
35
+ // ── Runtime shape — PROGRAM-LEVEL bank ───────────────────────────────────────
36
+ // The bank is inflated EXACTLY ONCE, in the program's main scope, into a plain
37
+ // main-scope register (NOT a global — nothing is written to globalThis). That
38
+ // register is shared with the functions that need it through the VM's ordinary
39
+ // upvalue mechanism: an extra upvalue is threaded down the closure-creation tree
40
+ // to every string-using function and its ancestors. Each string-using function
41
+ // reads the already-inflated bank from that upvalue and passes it to a small
42
+ // per-function `decode` closure (decode itself is function-level — cheap):
43
+ //
44
+ // main: MAKE_CLOSURE rInflate
45
+ // LOAD_CONST rB64, <base64 bank>
46
+ // CALL rBankMain, rInflate, 1, rB64 (once)
47
+ // string-using fn: LOAD_UPVALUE rBank, <threaded idx>
48
+ // MAKE_CLOSURE rDecode
49
+ // per site: LOAD_INT rKey/rStart/rLen
50
+ // CALL rDst, rDecode, 4, rBank, rKey, rStart, rLen
51
+ //
52
+ // ── Pipeline position ─────────────────────────────────────────────────────────
53
+ // Runs BEFORE resolveRegisters and resolveLabels (same slot as Dispatcher/CFF),
54
+ // and FIRST among the bytecode passes so each FnDescriptor.upvalues count is
55
+ // still pristine (used to pick the threaded upvalue index).
56
+
57
+ import { Template } from "../../template.js";
58
+ import * as b from "../../types.js";
59
+ import { ref, buildMaxIdMap, allocReg, forEachFunction } from "../../utils/pass-utils.js";
60
+ import { getRandomInt } from "../../utils/random-utils.js";
61
+ import { U32_MAX } from "../../utils/op-utils.js";
62
+
63
+ // ── Cipher ────────────────────────────────────────────────────────────────────
64
+ // Encode mirrors the runtime decode EXACTLY (see the decode template). XOR is
65
+ // self-inverse. `key` must be the raw (unmasked) seed emitted as the LOAD_INT
66
+ // operand, so both sides begin the Weyl sequence from the same integer.
67
+ function xorEncode(str, key) {
68
+ let k = key;
69
+ let out = "";
70
+ for (let i = 0; i < str.length; i++) {
71
+ k = k + 0x9e3779b9 | 0;
72
+ const ks = (k ^ k >>> 13) & 0xffff;
73
+ out += String.fromCharCode(str.charCodeAt(i) ^ ks);
74
+ }
75
+ return out;
76
+ }
77
+
78
+ // Random decoy run, full 16-bit range so decoys look like encoded payload.
79
+ function decoyRun(count) {
80
+ let out = "";
81
+ for (let i = 0; i < count; i++) out += String.fromCharCode(getRandomInt(0, 0xffff));
82
+ return out;
83
+ }
84
+
85
+ // Pack the u16 bank as little-endian bytes and base64-encode (ASCII, compact).
86
+ // Mirrored at runtime by the inflate template: byte[2i] = low, byte[2i+1] = high.
87
+ function bankToBase64(bank) {
88
+ const bytes = new Uint8Array(bank.length * 2);
89
+ for (let i = 0; i < bank.length; i++) {
90
+ const c = bank.charCodeAt(i);
91
+ bytes[i * 2] = c & 0xff;
92
+ bytes[i * 2 + 1] = c >> 8 & 0xff;
93
+ }
94
+ return Buffer.from(bytes).toString("base64");
95
+ }
96
+ function buildBank(strings) {
97
+ const parts = [];
98
+ const table = new Map();
99
+ let pos = 0;
100
+ const lead = decoyRun(getRandomInt(100, 250)); // leading decoys
101
+ parts.push(lead);
102
+ pos += lead.length;
103
+ for (const str of strings) {
104
+ const gap = decoyRun(getRandomInt(0, 5)); // 0‑5 decoys between strings
105
+ parts.push(gap);
106
+ pos += gap.length;
107
+ const key = getRandomInt(1, U32_MAX);
108
+ const encoded = xorEncode(str, key);
109
+ table.set(str, {
110
+ key,
111
+ start: pos,
112
+ length: str.length
113
+ });
114
+ parts.push(encoded);
115
+ pos += encoded.length;
116
+ }
117
+ parts.push(decoyRun(getRandomInt(100, 250))); // trailing decoys
118
+ return {
119
+ bank: parts.join(""),
120
+ table
121
+ };
122
+ }
123
+ function isStringLoadConst(instr, OP) {
124
+ return instr[0] === OP.LOAD_CONST && instr.length === 3 && instr[2]?.type === "constant" && typeof instr[2].value === "string";
125
+ }
126
+
127
+ // ── Pass entry point ──────────────────────────────────────────────────────────
128
+ export function stringConcealing(bc, compiler) {
129
+ const OP = compiler.OP;
130
+ const mainId = compiler.mainFn._fnIdx;
131
+ const entryLabelToFnId = new Map(compiler.fnDescriptors.map(d => [d.entryLabel, d._fnIdx]));
132
+ const entryLabels = new Set(entryLabelToFnId.keys());
133
+
134
+ // ── Prescan: collect strings + closure-creation graph ───────────────────────
135
+ // directUser — functions that contain a string LOAD_CONST.
136
+ // parentOf — childFnId → creating (lexical parent) fnId.
137
+ const strings = new Set();
138
+ const directUser = new Set();
139
+ const parentOf = new Map();
140
+ let curFn = -1;
141
+ for (const instr of bc) {
142
+ if (instr[0] === null && instr[1]?.type === "defineLabel" && entryLabels.has(instr[1].label)) {
143
+ curFn = entryLabelToFnId.get(instr[1].label);
144
+ continue;
145
+ }
146
+ if (curFn < 0) continue;
147
+ if (isStringLoadConst(instr, OP)) {
148
+ strings.add(instr[2].value);
149
+ directUser.add(curFn);
150
+ } else if (instr[0] === OP.MAKE_CLOSURE) {
151
+ const childId = entryLabelToFnId.get(instr[2]?.label);
152
+ if (childId !== undefined) parentOf.set(childId, curFn);
153
+ }
154
+ }
155
+ if (strings.size === 0) return {
156
+ bytecode: bc
157
+ };
158
+
159
+ // ── needSet = string users ∪ all their ancestors (so the upvalue can be
160
+ // threaded down to them). Walking each user to the root adds every ancestor. ──
161
+ const needSet = new Set();
162
+ for (const u of directUser) {
163
+ let p = u;
164
+ while (p !== undefined && !needSet.has(p)) {
165
+ needSet.add(p);
166
+ p = parentOf.get(p);
167
+ }
168
+ }
169
+
170
+ // Threaded upvalue index per function = its ORIGINAL upvalue count (appended
171
+ // last). main holds the bank as a local, so it has no threaded index.
172
+ const bankUvIndex = new Map();
173
+ for (const f of needSet) {
174
+ if (f === mainId) continue;
175
+ bankUvIndex.set(f, compiler.fnDescriptors[f]?.upvalues?.length ?? 0);
176
+ }
177
+ const maxId = buildMaxIdMap(bc);
178
+ const rBankMain = allocReg(mainId, maxId); // program-level inflated bank
179
+ const {
180
+ bank,
181
+ table
182
+ } = buildBank(strings);
183
+ const bankB64 = bankToBase64(bank);
184
+
185
+ // Helper closures, compiled once and shared by reference.
186
+ // inflate(b64) → reconstruct the u16 bank from base64
187
+ // decode(bank, key, start, len) → slice + keystream-decrypt one string
188
+ const helpers = new Template(`
189
+ function inflate(s) {
190
+ var bytes = atob(s);
191
+ var out = "";
192
+ for (var i = 0; i < bytes["length"]; i += 2) {
193
+ out += String["fromCharCode"](
194
+ bytes["charCodeAt"](i) | (bytes["charCodeAt"](i + 1) << 8)
195
+ );
196
+ }
197
+ return out;
198
+ }
199
+ function decode(bank, key, start, length) {
200
+ var result = "";
201
+ for (var i = 0; i < length; i++) {
202
+ key = (key + 0x9e3779b9) | 0;
203
+ var ks = (key ^ (key >>> 13)) & 0xffff;
204
+ result += String["fromCharCode"](bank["charCodeAt"](start + i) ^ ks);
205
+ }
206
+ return result;
207
+ }
208
+ `).compile({}, compiler);
209
+ const [inflateDesc, decodeDesc] = helpers.functions;
210
+ const mkClosure = (dst, desc, params) => [OP.MAKE_CLOSURE, ref(dst), {
211
+ type: "label",
212
+ label: desc.entryLabel
213
+ }, params, b.fnRegCountOperand(desc._fnIdx), 0,
214
+ // upvalue count
215
+ 0 // hasRest
216
+ ];
217
+ const {
218
+ bytecode
219
+ } = forEachFunction(bc, compiler, (fnInstrs, fnId) => {
220
+ if (!needSet.has(fnId)) return {
221
+ instrs: fnInstrs
222
+ };
223
+ const isMain = fnId === mainId;
224
+ const usesStrings = directUser.has(fnId);
225
+
226
+ // Bank source for closures created in THIS frame: main captures its local,
227
+ // every other frame inherits its own threaded upvalue.
228
+ const childUpvalue = isMain ? [1, ref(rBankMain)] : [0, bankUvIndex.get(fnId)];
229
+ const prologue = [];
230
+ let rBank = null;
231
+ let rDecode = null;
232
+ let rKey, rStart, rLen;
233
+ if (isMain) {
234
+ const rInflate = allocReg(fnId, maxId);
235
+ const rB64 = allocReg(fnId, maxId);
236
+ prologue.push(mkClosure(rInflate, inflateDesc, 1));
237
+ prologue.push([OP.LOAD_CONST, ref(rB64), b.constantOperand(bankB64)]);
238
+ prologue.push([OP.CALL, ref(rBankMain), ref(rInflate), 1, ref(rB64)]);
239
+ rBank = rBankMain;
240
+ } else if (usesStrings) {
241
+ rBank = allocReg(fnId, maxId);
242
+ prologue.push([OP.LOAD_UPVALUE, ref(rBank), bankUvIndex.get(fnId)]);
243
+ }
244
+ if (usesStrings) {
245
+ rDecode = allocReg(fnId, maxId);
246
+ prologue.push(mkClosure(rDecode, decodeDesc, 4));
247
+ rKey = allocReg(fnId, maxId);
248
+ rStart = allocReg(fnId, maxId);
249
+ rLen = allocReg(fnId, maxId);
250
+ }
251
+ const out = [...prologue];
252
+ for (const instr of fnInstrs) {
253
+ // Thread the bank upvalue into every closure this frame creates that
254
+ // needs it (string users + ancestors).
255
+ if (instr[0] === OP.MAKE_CLOSURE) {
256
+ const childId = entryLabelToFnId.get(instr[2]?.label);
257
+ if (childId !== undefined && needSet.has(childId)) {
258
+ instr[5] = instr[5] + 1; // bump uvCount
259
+ instr.push(childUpvalue[0], childUpvalue[1]);
260
+ }
261
+ out.push(instr);
262
+ continue;
263
+ }
264
+ if (usesStrings && isStringLoadConst(instr, OP)) {
265
+ const dst = instr[1];
266
+ const entry = table.get(instr[2].value);
267
+ out.push([OP.LOAD_INT, ref(rKey), entry.key]);
268
+ out.push([OP.LOAD_INT, ref(rStart), entry.start]);
269
+ out.push([OP.LOAD_INT, ref(rLen), entry.length]);
270
+ out.push([OP.CALL, ref(dst), ref(rDecode), 4, ref(rBank), ref(rKey), ref(rStart), ref(rLen)]);
271
+ continue;
272
+ }
273
+ out.push(instr);
274
+ }
275
+ return {
276
+ instrs: out
277
+ };
278
+ });
279
+
280
+ // Append the helper functions' bytecode (defines their entryLabels).
281
+ bytecode.push(...helpers.bytecode);
282
+ return {
283
+ bytecode
284
+ };
285
+ }
286
+
287
+ // [isLocal flag, upvalue source] — RegisterOperand when capturing a local,
288
+ // plain number when inheriting a parent upvalue.
@@ -0,0 +1,43 @@
1
+ import * as t from "@babel/types";
2
+ import { shuffle } from "../../utils/random-utils.js";
3
+ function hasComment(node, text) {
4
+ const all = [...(node.leadingComments ?? []), ...(node.innerComments ?? []), ...(node.trailingComments ?? [])];
5
+ return all.some(c => c.value.includes(text));
6
+ }
7
+ function isPrototypeAssignment(stmt) {
8
+ if (!t.isExpressionStatement(stmt)) return false;
9
+ const expr = stmt.expression;
10
+ if (!t.isAssignmentExpression(expr)) return false;
11
+ const left = expr.left;
12
+ return t.isMemberExpression(left) && t.isMemberExpression(left.object) && t.isIdentifier(left.object.property, {
13
+ name: "prototype"
14
+ });
15
+ }
16
+ export function applyClassObfuscation(ast, _compiler) {
17
+ const body = ast.program.body;
18
+
19
+ // Split at the first statement that carries the @BOOT comment.
20
+ // Everything from that statement onward is the boot section and must stay last.
21
+ let bootIdx = body.findIndex(stmt => hasComment(stmt, "@BOOT"));
22
+ if (bootIdx === -1) bootIdx = body.length;
23
+ const shufflable = body.slice(0, bootIdx);
24
+ const boot = body.slice(bootIdx);
25
+
26
+ // Partition the shufflable section into two independent groups.
27
+ // Group A: variable/function declarations (constructors, standalone vars).
28
+ // Group B: prototype method assignments (X.prototype.Y = ...).
29
+ // Both groups are shuffled independently; A always precedes B so that
30
+ // constructors are defined before methods reference them.
31
+ const varDecls = [];
32
+ const methodDefs = [];
33
+ for (const stmt of shufflable) {
34
+ if (isPrototypeAssignment(stmt)) {
35
+ methodDefs.push(stmt);
36
+ } else {
37
+ varDecls.push(stmt);
38
+ }
39
+ }
40
+ shuffle(varDecls);
41
+ shuffle(methodDefs);
42
+ ast.program.body = [...varDecls, ...methodDefs, ...boot];
43
+ }
@@ -0,0 +1,91 @@
1
+ import * as t from "@babel/types";
2
+ import traverseImport from "@babel/traverse";
3
+ import { ok } from "assert";
4
+ import { parse } from "@babel/parser";
5
+ const traverse = traverseImport.default || traverseImport;
6
+
7
+ // Converts the switch-case dispatch into a handler table:
8
+ //
9
+ // Before (in .run):
10
+ // switch(op) { case OP.ADD: { ... break; } default: { ... } }
11
+ //
12
+ // After (in .init):
13
+ // this[OP.ADD] = function(){ ... }
14
+ // this["default"] = function(){ ... }
15
+ //
16
+ // After (in .run, replacing the switch):
17
+ // if(!this[op]) this["default"]();
18
+ // else this[op]();
19
+ //
20
+ export function applyHandlerTable(ast) {
21
+ // 1. Find the @SWITCH statement
22
+ let switchPath = null;
23
+ traverse(ast, {
24
+ SwitchStatement(path) {
25
+ if (path.node.leadingComments?.some(c => c.value.includes("@SWITCH"))) {
26
+ switchPath = path;
27
+ path.stop();
28
+ }
29
+ }
30
+ });
31
+ ok(switchPath, "Could not find opcode handlers switch statement");
32
+ const switchNode = switchPath.node;
33
+ const discriminant = switchNode.discriminant; // `op`
34
+
35
+ // 2. Find the @INIT method
36
+ let initPath = null;
37
+ traverse(ast, {
38
+ BlockStatement(path) {
39
+ if (path.node.innerComments?.some(c => c.value.includes("@INIT"))) {
40
+ initPath = path;
41
+ path.stop();
42
+ }
43
+ }
44
+ });
45
+ ok(initPath, "Could not find @INIT method");
46
+ const initFn = initPath.parentPath;
47
+
48
+ // 3. Build handler assignments for each case
49
+ const handlerAssignments = [];
50
+ for (const switchCase of switchNode.cases) {
51
+ // Strip trailing `break` from body
52
+ let body = [...switchCase.consequent];
53
+ if (body.length === 1 && t.isBlockStatement(body[0])) {
54
+ body = body[0].body;
55
+ }
56
+ if (body.length > 0 && t.isBreakStatement(body[body.length - 1])) {
57
+ body.pop();
58
+ }
59
+ body.unshift(...parse("var frame = this._currentFrame; var base = frame._base; var pc = frame._pc - 1; var regs = this._regs; ").program.body);
60
+ const block = t.blockStatement(body);
61
+ traverse(block, {
62
+ noScope: true,
63
+ ThisExpression(path) {
64
+ path.replaceWith(t.identifier("_this"));
65
+ },
66
+ Function(path) {
67
+ path.skip();
68
+ }
69
+ });
70
+
71
+ // Key: the case test, or "default" for the default case
72
+ const key = switchCase.test ? switchCase.test : t.stringLiteral("default");
73
+
74
+ // this[key] = function(){ ...body }
75
+ const handlerFn = t.functionExpression(null, [], block);
76
+ const assignment = t.expressionStatement(t.assignmentExpression("=", t.memberExpression(t.thisExpression(), key, true), handlerFn));
77
+ handlerAssignments.push(assignment);
78
+ }
79
+
80
+ // 4. Inject handler assignments into the @INIT body
81
+ initPath.node.body = handlerAssignments;
82
+
83
+ // 5. Replace the switch statement with handler dispatch:
84
+ // if(!this[op]) this["default"]();
85
+ // else this[op]();
86
+ const thisLookup = t.memberExpression(t.thisExpression(), discriminant, true);
87
+ const defaultCall = t.expressionStatement(t.callExpression(t.memberExpression(t.thisExpression(), t.stringLiteral("default"), true), []));
88
+ const handlerCall = t.expressionStatement(t.callExpression(thisLookup, []));
89
+ const dispatch = t.ifStatement(t.unaryExpression("!", thisLookup), t.blockStatement([defaultCall]), t.blockStatement([handlerCall]));
90
+ switchPath.replaceWith(dispatch);
91
+ }