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.
- package/README.md +281 -147
- package/dist/build-runtime.js +41 -15
- package/dist/compiler.js +714 -265
- package/dist/disassembler.js +367 -0
- package/dist/index.js +7 -2
- package/dist/runtime.js +160 -119
- package/dist/template.js +163 -42
- package/dist/transforms/bytecode/aliasedOpcodes.js +4 -1
- package/dist/transforms/bytecode/concealConstants.js +2 -2
- package/dist/transforms/bytecode/controlFlowFlattening.js +569 -0
- package/dist/transforms/bytecode/dispatcher.js +15 -111
- package/dist/transforms/bytecode/macroOpcodes.js +2 -2
- package/{src/transforms/bytecode/resolveContants.ts → dist/transforms/bytecode/resolveConstants.js} +30 -56
- package/dist/transforms/bytecode/resolveRegisters.js +23 -4
- package/dist/transforms/bytecode/selfModifying.js +88 -21
- package/dist/transforms/bytecode/semanticOpcodes.js +162 -0
- package/dist/transforms/bytecode/specializedOpcodes.js +23 -12
- package/dist/transforms/bytecode/stringConcealing.js +288 -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 +75 -0
- package/dist/utils/op-utils.js +1 -2
- package/dist/utils/pass-utils.js +100 -0
- package/dist/utils/profile-utils.js +3 -0
- package/package.json +8 -1
- package/.gitmodules +0 -4
- package/.prettierignore +0 -1
- package/CHANGELOG.md +0 -335
- package/babel-plugin-inline-runtime.cjs +0 -34
- package/babel.config.json +0 -23
- package/index.ts +0 -38
- package/jest-strip-types.js +0 -10
- package/jest.config.js +0 -52
- package/src/build-runtime.ts +0 -78
- package/src/compiler.ts +0 -2593
- package/src/index.ts +0 -14
- package/src/minify.ts +0 -21
- package/src/options.ts +0 -18
- package/src/runtime.ts +0 -923
- package/src/template.ts +0 -141
- package/src/transforms/bytecode/aliasedOpcodes.ts +0 -148
- package/src/transforms/bytecode/concealConstants.ts +0 -52
- package/src/transforms/bytecode/dispatcher.ts +0 -398
- package/src/transforms/bytecode/macroOpcodes.ts +0 -193
- package/src/transforms/bytecode/microOpcodes.ts +0 -291
- package/src/transforms/bytecode/resolveLabels.ts +0 -112
- package/src/transforms/bytecode/resolveRegisters.ts +0 -221
- package/src/transforms/bytecode/selfModifying.ts +0 -121
- package/src/transforms/bytecode/specializedOpcodes.ts +0 -153
- package/src/transforms/runtime/aliasedOpcodes.ts +0 -191
- package/src/transforms/runtime/internalVariables.ts +0 -270
- package/src/transforms/runtime/macroOpcodes.ts +0 -138
- package/src/transforms/runtime/microOpcodes.ts +0 -93
- package/src/transforms/runtime/minify.ts +0 -1
- package/src/transforms/runtime/shuffleOpcodes.ts +0 -24
- package/src/transforms/runtime/specializedOpcodes.ts +0 -156
- package/src/types.ts +0 -93
- package/src/utils/op-utils.ts +0 -48
- package/src/utils/random-utils.ts +0 -31
- 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
|
-
|
|
28
|
+
let operands = [];
|
|
29
|
+
for (const operand of oldOperands) {
|
|
28
30
|
if (typeof operand === "number") {
|
|
29
|
-
|
|
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
|
-
|
|
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.
|
|
47
|
+
entry.occurrences++;
|
|
44
48
|
} else {
|
|
45
49
|
freqMap.set(key, {
|
|
46
50
|
op,
|
|
47
51
|
operands,
|
|
48
52
|
operandsKey,
|
|
49
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
} =
|
|
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}_${
|
|
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 =
|
|
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
|
+
}
|