luau-obfuscator 1.0.1 → 1.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "luau-obfuscator",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "luau obfuscator for roblox",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
package/scripts/test.js CHANGED
@@ -1,16 +1,24 @@
1
1
  import fs from 'fs'
2
2
  import { obfuscate } from '../dist/index.js'
3
+ import path from 'path'
3
4
 
4
- const exampleScript = fs.readFileSync('./scripts/example.luau', 'utf-8')
5
- const obfuscated = obfuscate(exampleScript, {
6
- Minify: {active: false},
7
-
8
- })
9
- fs.writeFileSync('./generated/final.luau', obfuscated)
5
+ const smokedir = path.join(process.cwd(), "smoketest")
10
6
 
11
- const smoketest = fs.readFileSync('./scripts/smoketest.luau', 'utf-8')
12
- const smoketestobfuscated = obfuscate(smoketest, {
13
- Minify: {active: false},
14
-
15
- })
16
- fs.writeFileSync('./generated/smoketest.luau', smoketestobfuscated)
7
+ const GENERATE_DIR = path.join(process.cwd(), "generated")
8
+
9
+ fs.rmSync(GENERATE_DIR, { recursive: true, force: true });
10
+ fs.mkdirSync(GENERATE_DIR, { recursive: true });
11
+
12
+
13
+ for(const filename of fs.readdirSync(smokedir)) {
14
+ const content = fs.readFileSync(path.join(smokedir, filename), 'utf-8')
15
+ const timeStart = performance.now()
16
+ const obfuscated = obfuscate(content, {
17
+ Minify: {active: false},
18
+ NumbersToExpressions: {active: false},
19
+ StringsToExpressions: {active: false}
20
+ })
21
+ const timeEnd = performance.now()
22
+ fs.writeFileSync(path.join(GENERATE_DIR, `${filename}`), obfuscated)
23
+ console.log(`obfuscating ${filename} took ${(timeEnd - timeStart) / 1000}s, length: ${content.length} -> ${obfuscated.length}`)
24
+ }
@@ -1,7 +1,8 @@
1
1
  import type { Program, TableField } from "luau-parser"
2
2
  import { transformExpressions } from "./walk"
3
3
  import {
4
- stringLiteral, numberLiteral, index as indexExpr, identifier, table, localStatement,
4
+ stringLiteral, numberLiteral, vmNumberLiteral, index as indexExpr, identifier, table, localStatement,
5
+ vmStructuralNumbers,
5
6
  } from "./nodeFactory"
6
7
 
7
8
  export interface ConstantArrayOptions {
@@ -41,7 +42,7 @@ export function runConstantArray(program: Program, options: ConstantArrayOptions
41
42
  seen.add(k)
42
43
  order.push({ kind: "string", value: expr.value })
43
44
  }
44
- } else if (expr.type === "NumberLiteral") {
45
+ } else if (expr.type === "NumberLiteral" && !vmStructuralNumbers.has(expr)) {
45
46
  const k = keyOf("number", expr.value)
46
47
  if (!seen.has(k)) {
47
48
  seen.add(k)
@@ -70,11 +71,11 @@ export function runConstantArray(program: Program, options: ConstantArrayOptions
70
71
  transformExpressions(program, (expr) => {
71
72
  if (expr.type === "StringLiteral") {
72
73
  const idx = indexOf.get(keyOf("string", expr.value))!
73
- return indexExpr(identifier(arrayName), numberLiteral(idx))
74
+ return indexExpr(identifier(arrayName), vmNumberLiteral(idx))
74
75
  }
75
- if (expr.type === "NumberLiteral") {
76
+ if (expr.type === "NumberLiteral" && !vmStructuralNumbers.has(expr)) {
76
77
  const idx = indexOf.get(keyOf("number", expr.value))!
77
- return indexExpr(identifier(arrayName), numberLiteral(idx))
78
+ return indexExpr(identifier(arrayName), vmNumberLiteral(idx))
78
79
  }
79
80
  return undefined
80
81
  })
@@ -3,7 +3,7 @@ import { transformExpressions } from "./walk"
3
3
  import {
4
4
  identifier, numberLiteral, call, member,
5
5
  localFunctionStatement, functionParam, functionBody, block, returnStatement,
6
- localStatement, binary,
6
+ localStatement, binary, vmStructuralNumbers,
7
7
  } from "./nodeFactory"
8
8
 
9
9
  export interface EncryptNumbersOptions {}
@@ -96,6 +96,7 @@ export function runEncryptNumbers(program: Program, _options: EncryptNumbersOpti
96
96
 
97
97
  transformExpressions(program, (expr: Expression) => {
98
98
  if (expr.type !== "NumberLiteral") return
99
+ if (vmStructuralNumbers.has(expr)) return
99
100
  if (!isEncryptable(expr.value)) return
100
101
 
101
102
  used = true
@@ -1,7 +1,7 @@
1
1
  import type { Program, Expression } from "luau-parser"
2
2
  import { transformExpressions } from "./walk"
3
3
  import {
4
- identifier, numberLiteral, call, member, index, table, positionalField,
4
+ identifier, vmNumberLiteral, call, member, index, table, positionalField,
5
5
  localFunctionStatement, functionParam, functionBody, block, localStatement,
6
6
  assignmentStatement, numericForStatement, returnStatement, unary, binary,
7
7
  } from "./nodeFactory"
@@ -57,15 +57,15 @@ function obfuscatedNumber(value: number): Expression {
57
57
  const variant = randomInt(0, 2)
58
58
  if (variant === 0) {
59
59
  const a = randomInt(0, value)
60
- return binary("+", numberLiteral(a), numberLiteral(value - a))
60
+ return binary("+", vmNumberLiteral(a), vmNumberLiteral(value - a))
61
61
  }
62
62
  if (variant === 1) {
63
63
  const a = randomInt(0, 0xFFFFFF)
64
- return binary("-", numberLiteral(value + a), numberLiteral(a))
64
+ return binary("-", vmNumberLiteral(value + a), vmNumberLiteral(a))
65
65
  }
66
66
  const a = randomInt(0, 0xFFFFFF)
67
67
  const b = (value ^ a) >>> 0
68
- return call(member(identifier("bit32"), "bxor"), [numberLiteral(a), numberLiteral(b)])
68
+ return call(member(identifier("bit32"), "bxor"), [vmNumberLiteral(a), vmNumberLiteral(b)])
69
69
  }
70
70
 
71
71
  /**
@@ -92,17 +92,17 @@ function buildDecoderStatement(name: string) {
92
92
 
93
93
  const idxExpr = binary(
94
94
  "+",
95
- binary("%", binary("-", identifier(iVar), numberLiteral(1)), identifier(nKeysVar)),
96
- numberLiteral(1),
95
+ binary("%", binary("-", identifier(iVar), vmNumberLiteral(1)), identifier(nKeysVar)),
96
+ vmNumberLiteral(1),
97
97
  )
98
98
  const keyByte = index(identifier(keysParam), idxExpr)
99
99
  const mixed = call(member(identifier("bit32"), "band"), [
100
- call(member(identifier("bit32"), "lrotate"), [keyByte, binary("%", identifier(iVar), numberLiteral(8))]),
101
- numberLiteral(0xFF),
100
+ call(member(identifier("bit32"), "lrotate"), [keyByte, binary("%", identifier(iVar), vmNumberLiteral(8))]),
101
+ vmNumberLiteral(0xFF),
102
102
  ])
103
103
  const posByte = call(member(identifier("bit32"), "band"), [
104
104
  binary("*", identifier(iVar), identifier(multParam)),
105
- numberLiteral(0xFF),
105
+ vmNumberLiteral(0xFF),
106
106
  ])
107
107
  const keystream = call(member(identifier("bit32"), "bxor"), [mixed, posByte])
108
108
  const finalByte = call(member(identifier("bit32"), "bxor"), [
@@ -115,7 +115,7 @@ function buildDecoderStatement(name: string) {
115
115
  localStatement(outVar, table([])),
116
116
  numericForStatement(
117
117
  iVar,
118
- numberLiteral(1),
118
+ vmNumberLiteral(1),
119
119
  unary("#", identifier(dataParam)),
120
120
  block([
121
121
  assignmentStatement(
@@ -150,7 +150,7 @@ export function runEncryptStrings(program: Program, _options: EncryptStringsOpti
150
150
  const plainBytes = toUtf8Bytes(expr.value)
151
151
  const cipherBytes = plainBytes.map((b, idx) => b ^ keystreamByte(keys, mult, idx + 1))
152
152
 
153
- const dataTable = table(cipherBytes.map((b) => positionalField(numberLiteral(b))))
153
+ const dataTable = table(cipherBytes.map((b) => positionalField(vmNumberLiteral(b))))
154
154
  const keysTable = table(keys.map((k) => positionalField(obfuscatedNumber(k))))
155
155
  const multExpr = obfuscatedNumber(mult)
156
156
 
@@ -4,7 +4,7 @@ import {
4
4
  numberLiteral, binary, paren, identifier, call, index, table, positionalField,
5
5
  localStatement, localFunctionStatement, functionParam, functionBody, functionExpression,
6
6
  block, assignmentStatement, numericForStatement, whileStatement, ifStatement, ifClause,
7
- returnStatement, unary,
7
+ returnStatement, unary, vmStructuralNumbers,
8
8
  } from "./nodeFactory"
9
9
 
10
10
  export interface NumbersToExpressionsOptions {
@@ -252,6 +252,9 @@ export function runNumbersToExpressions(program: Program, options: NumbersToExpr
252
252
 
253
253
  transformExpressions(program, (expr) => {
254
254
  if (expr.type !== "NumberLiteral") return
255
+ // Vmify가 만든 op/a/b/c 같은 내부 메타데이터는 건드리지 않는다
256
+ // (개수가 코드 크기에 비례해 폭증하고, 로직은 이미 Vmify가 구조적으로 숨긴 값).
257
+ if (vmStructuralNumbers.has(expr)) return
255
258
 
256
259
  // 루프/재귀 조립은 정수 배분(합이 정확히 일치)에 의존하므로 정수에만 적용하고,
257
260
  // 실수/범위를 벗어난 값은 기존 산술식 위장으로 처리한다.
@@ -53,13 +53,24 @@ function stringCharCall(bytes: number[]): Expression {
53
53
  return call(member(identifier("string"), "char"), bytes.map(numberLiteral))
54
54
  }
55
55
 
56
- /** 기존 방식: 청크를 소스에 보이는 순서 그대로 ".."로 이어붙임. */
56
+ /**
57
+ * 청크를 소스에 보이는 순서 그대로 ".."로 이어붙인다.
58
+ *
59
+ * 예전에는 왼쪽으로만 쌓는 선형 체인(`(((a..b)..c)..d)...`)이었는데, 이러면
60
+ * depth == chunks.length라서 큰 문자열 리터럴(수백만 바이트) 하나만 있어도
61
+ * 표현식 중첩 깊이가 수백만까지 치솟는다. 이 트랜스파일러 자체의 스택뿐 아니라
62
+ * 실제 Luau/Lua 컴파일러도 문법 중첩 레벨에 제한(대략 200선)이 있어서, 그 한계를
63
+ * 넘는 순간 우리 쪽이 아니라 Roblox/Luau 파서에서 컴파일이 실패한다.
64
+ * 그래서 균형 이진 트리로 묶어 depth를 O(log n)으로 낮춘다 — 결합 순서만 바뀔 뿐
65
+ * ".."는 결합법칙이 성립하므로 결과 문자열은 동일하다.
66
+ */
57
67
  function buildConcatChain(chunks: number[][]): Expression {
58
- let expr: Expression = stringCharCall(chunks[0])
59
- for (let i = 1; i < chunks.length; i++) {
60
- expr = binary("..", expr, stringCharCall(chunks[i]))
68
+ function build(lo: number, hi: number): Expression {
69
+ if (hi - lo === 1) return stringCharCall(chunks[lo])
70
+ const mid = lo + Math.floor((hi - lo) / 2)
71
+ return binary("..", build(lo, mid), build(mid, hi))
61
72
  }
62
- return expr
73
+ return build(0, chunks.length)
63
74
  }
64
75
 
65
76
  /** [0, n)의 무작위 순열 (Fisher-Yates) */
@@ -199,6 +210,14 @@ function pickControlFlowStrategy(): ControlFlowStrategy {
199
210
  return Math.random() < 0.5 ? "forLoop" : "recursive"
200
211
  }
201
212
 
213
+ // 바이트 단위로 쪼개면 청크 하나당 AST 노드(CallExpression+MemberExpression+인자들)가
214
+ // 하나씩 생긴다. 수 MB짜리 문자열 리터럴(예: 임베드된 데이터 블롭)을 5~10바이트 청크로
215
+ // 쪼개면 수십~수백만 개 노드가 나와서 이 트랜스파일러 자체가 OOM으로 죽는다.
216
+ // 그 크기의 문자열은 바이트 단위로 흩뿌려봐야 어차피 노출 표면이 커서 난독화 효과도
217
+ // 떨어지므로, 청크 개수가 이 상한을 넘으면 통째로 하나의 string.char(...)/원본 리터럴로
218
+ // 두고 건드리지 않는다 (EncryptStrings 등 다른 패스가 필요하면 별도로 암호화하면 됨).
219
+ const MAX_CHUNKS_PER_STRING = 20000
220
+
202
221
  export function runStringsToExpressions(program: Program, options: StringsToExpressionsOptions): void {
203
222
  const controlFlowProbability = options.controlFlowProbability ?? CONTROL_FLOW_PROBABILITY_DEFAULT
204
223
 
@@ -207,6 +226,11 @@ export function runStringsToExpressions(program: Program, options: StringsToExpr
207
226
  if (expr.value.length === 0) return
208
227
 
209
228
  const bytes = toUtf8Bytes(expr.value)
229
+
230
+ // 너무 큰 문자열은 청크 단위 폭발을 피하기 위해 손대지 않는다.
231
+ const estimatedChunks = Math.ceil(bytes.length / Math.max(1, options.min))
232
+ if (estimatedChunks > MAX_CHUNKS_PER_STRING) return
233
+
210
234
  const chunks = splitByteChunks(bytes, options.min, options.max)
211
235
 
212
236
  // 청크가 하나뿐이면 순서를 흩뜨려도 얻는 게 없으니 그냥 원래 방식대로.
@@ -1,4 +1,4 @@
1
- import type { Program, Statement } from "luau-parser"
1
+ import type { Program, Statement, Expression } from "luau-parser"
2
2
  import { luauparser } from "luau-parser"
3
3
  import { VmCompiler, DEFAULT_BUILTIN_GLOBALS } from "./vmify/compiler"
4
4
  import { serializeProto } from "./vmify/serialize"
@@ -7,7 +7,20 @@ import { createOpcodeMap } from "./vmify/opcodes"
7
7
  import { generateVmNames } from "./vmify/names"
8
8
  import {
9
9
  localStatement, identifier, call, table, namedField, returnStatement, vararg,
10
+ vmStructuralNumbers,
10
11
  } from "./nodeFactory"
12
+ import { transformExpressions } from "./walk"
13
+
14
+ /** 파싱된 서브트리(런타임 인터프리터 소스) 안의 모든 NumberLiteral을 vmStructuralNumbers로
15
+ * 표시한다 — 인터프리터 자신의 +1/-1/패딩 같은 구현 디테일 상수는 사용자 데이터가 아닌데,
16
+ * 수십 개 opcode 핸들러에 반복 등장해서 EncryptNumbers/NumbersToExpressions/ConstantArray가
17
+ * 건드리면 곱셈적으로 부풀어 오른다. */
18
+ function markRuntimeNumbersAsStructural(body: { statements: Statement[] }): void {
19
+ transformExpressions({ body } as Program, (expr) => {
20
+ if (expr.type === "NumberLiteral") vmStructuralNumbers.add(expr)
21
+ return undefined
22
+ })
23
+ }
11
24
 
12
25
  export interface VmifyOptions {
13
26
  /**
@@ -59,6 +72,7 @@ export function runVmify(program: Program, options: VmifyOptions): void {
59
72
  const runtimeSource = buildVmRuntimeSource(names, opcodeMap)
60
73
  const runtimeProgram = luauparser.parse(runtimeSource)
61
74
  const runtimeStatements: Statement[] = runtimeProgram.body.statements
75
+ markRuntimeNumbersAsStructural(runtimeProgram.body)
62
76
 
63
77
  const protoLiteral = serializeProto(topProto, names)
64
78
 
@@ -70,4 +84,4 @@ export function runVmify(program: Program, options: VmifyOptions): void {
70
84
  ]
71
85
 
72
86
  program.body.statements = newBody
73
- }
87
+ }
@@ -27,6 +27,25 @@ export function numberLiteral(value: number): NumberLiteral {
27
27
  return { type: "NumberLiteral", value, raw: String(value), line: POS, column: POS }
28
28
  }
29
29
 
30
+ /**
31
+ * Vmify가 인스트럭션 op/a/b/c, numParams, maxRegs 같은 "VM 구조 메타데이터"용으로
32
+ * 찍어내는 숫자는 이 표시를 달아 만든다. 이런 값은 원본 소스에 쓰인 상수가 아니라
33
+ * Vmify 자신이 만들어낸, 개수가 코드 크기에 비례해 수천~수만 개까지 불어날 수 있는
34
+ * 내부 값이고, 로직 자체는 이미 Vmify 컴파일로 구조적으로 숨겨져 있다.
35
+ * NumbersToExpressions/EncryptNumbers 같은 뒤쪽 패스가 이 값들까지 하나하나
36
+ * for-loop/재귀 함수/디코더 호출로 부풀리면, 두 패스가 곱셈적으로 상호작용해
37
+ * 출력이 기하급수적으로 커진다 (실측: 4400줄 샘플에서 Vmify+NumbersToExpressions만
38
+ * 켜면 0.9MB가 아니라 7.7MB가 나옴). 그래서 이 표시가 붙은 노드는 뒤쪽 숫자
39
+ * 난독화 패스들이 건드리지 않고 그대로 둔다.
40
+ */
41
+ export const vmStructuralNumbers = new WeakSet<NumberLiteral>()
42
+
43
+ export function vmNumberLiteral(value: number): NumberLiteral {
44
+ const node = numberLiteral(value)
45
+ vmStructuralNumbers.add(node)
46
+ return node
47
+ }
48
+
30
49
  export function binary(
31
50
  operator: BinaryExpression["operator"],
32
51
  left: Expression,
@@ -21,6 +21,8 @@ export interface Proto {
21
21
  maxRegs: number
22
22
  code: Instr[]
23
23
  consts: ConstValue[]
24
+ /** consts 배열과 별도로 유지하는 value -> index 조회용 인덱스 (interning을 O(1)로). */
25
+ constIndex: Map<ConstValue, number>
24
26
  /** 이 함수가 캡처하는 upvalue들이 "부모" 기준 어디서 오는지. */
25
27
  upvalDescs: UpvalDesc[]
26
28
  /** 중첩 함수 표현식들. CLOSURE 명령의 B는 이 배열의 인덱스. */
@@ -35,6 +37,7 @@ export function createProto(id: number): Proto {
35
37
  maxRegs: 0,
36
38
  code: [],
37
39
  consts: [],
40
+ constIndex: new Map(),
38
41
  upvalDescs: [],
39
42
  protos: [],
40
43
  }
@@ -42,9 +45,10 @@ export function createProto(id: number): Proto {
42
45
 
43
46
  /** 상수 풀에 값 추가(중복 제거) 후 인덱스 반환. */
44
47
  export function internConst(proto: Proto, value: ConstValue): number {
45
- for (let i = 0; i < proto.consts.length; i++) {
46
- if (proto.consts[i] === value) return i
47
- }
48
+ const existing = proto.constIndex.get(value)
49
+ if (existing !== undefined) return existing
48
50
  proto.consts.push(value)
49
- return proto.consts.length - 1
50
- }
51
+ const idx = proto.consts.length - 1
52
+ proto.constIndex.set(value, idx)
53
+ return idx
54
+ }
@@ -31,12 +31,7 @@ export interface VmNames {
31
31
  varargs: string
32
32
  nVarargs: string
33
33
  multiTop: string
34
- // Instr 테이블 필드 (serialize.ts가 찍고 runtime.ts가 읽음)
35
- op: string
36
- a: string
37
- b: string
38
- c: string
39
- // Proto 테이블 필드
34
+ // Proto 테이블 필드 (인스트럭션 자체는 이제 [op,a,b,c] 위치 기반 테이블이라 이름이 필요 없음)
40
35
  numParams: string
41
36
  hasVarargs: string
42
37
  maxRegs: string
@@ -71,8 +66,7 @@ export function generateVmNames(random: () => number = Math.random): VmNames {
71
66
  frame: id(),
72
67
  R: id(), K: id(), code: id(), protos: id(), upvals: id(), varargs: id(),
73
68
  nVarargs: id(), multiTop: id(),
74
- op: id(), a: id(), b: id(), c: id(),
75
69
  numParams: id(), hasVarargs: id(), maxRegs: id(), consts: id(), upvalDescs: id(),
76
70
  kind: id(), index: id(),
77
71
  }
78
- }
72
+ }