Deobfuscating Luraph: A Practical Reverse Engineering Walkthrough 23/09/2026
For this article, we combine all the reverse-engineering tools, compiler analysis passes, and decompilation pipelines we built to perform an exhaustive, step-by-step devirtualization of a real-world modern Luraph target (luraph_variant_01.luau, protected by Luraph v14.7). We provide an unfiltered, end-to-end walkthrough: inspecting the raw wrapper, intercepting the 11-column Structure-of-Arrays (SoA) prototype graph at the deserializer seam, analyzing the 4,943-instruction anti-tamper trap (4,484 reachable instructions), tracing the virtual machine execution, proving the post-increment continuation law, categorizing dynamic opcode handlers from AST fingerprints, performing def-use dataflow analysis on mutating dispatch tables, normalizing irreducible multi-entry control flow graphs with node-splitting, and structuring everything back into clean, readable Luau. Credits: Ferib.dev for forum template
Table of Content
To keep everything organized and easy to navigate, this walkthrough is structured into the following sections:
- 1. The Internals of the Luraph Virtual Machine & Raw Wrapper Anatomy
- 2. The Dual Serializer & 11-Column SoA Memory Layout
- 3. Locating the Seam & Dumping the Prototype Tree
- 4. Nested & On-Demand Prototype Materialization (Sub-Buffer Slicing)
- 5. Lazy Constant Decryption & LCG Seed Unwrapping
- 6. Dissecting Proto 2: The 4,943-Instruction Anti-Tamper Harness (4,484 Reachable)
- 7. Virtual Machine Execution Tracing & Fatal Offline Traps
- 8. The Trampoline Maze & Mathematical Continuation Law
- 9. Pre-Lifter: Control Flow Graph (CFG) Recovery
- 10. Dynamic Opcode Remapping & Symbolic AST Handler Matching
- 11. Sample-Specific Fast-Path: Variant 01 Opcode & Branch Tables
- 12. Active Dispatch Remapping vs. Decoy Mutation (Def-Use Dataflow)
- 13. Opaque Predicates & Static Suicide Block Pruning
- 14. Irreducible Graph Structuring & The Relooper Failure (Node-Splitting)
- 15. Upvalue Capture Linking & Closure Scoping (p[6])
- 16. Multi-Value Calling Conventions & Varargs
- 17. Lifting like a Forklift: Bytecode to Typed IR
- 18. Before & After: Full Application Reconstruction
- 19. The Complete Deobfuscation Recipe (Variant 01 in 5 Steps)
- 20. Conclusion & Verifiable Research Artifacts
1. The Internals of the Luraph Virtual Machine & Raw Wrapper Anatomy
Before we can devirtualize Luraph we must understand how Luraph compiles and executes protected scripts. Modern Luraph does not store instructions as standard Lua opcode tuples {op, a, b, c}. Instead, it utilizes an optimized Structure-of-Arrays (SoA) memory architecture where instruction fields are split across 11 discrete column arrays per prototype closure.
Below is the pedagogical architecture of a modern Luraph runtime:
-- High-level reconstructed Luraph runtime architecture local readu32 = buffer_readu32 local readf64 = buffer_readf64 local readi16 = buffer_readi16 local copy = buffer_copy local bitxor = bit32.bxor local lrotate = bit32.lrotate -- Luraph VM Entry local function VMRun(payload_string, env) -- Layer 1: Deserializer local function LPH_Deserialize(buf) local root = {} local proto_count = buffer.readu32(buf, 0) -- Unrolls opcodes, registers, and constants across 11 Structure-of-Arrays columns: for p_idx = 1, proto_count do local p = {} p[7] = {} -- opcodes array (instruction stream) p[8] = {} -- reg_dest array (destination registers) p[9] = {} -- reg_src1 array (source registers / jump targets) p[11] = {} -- reg_src2 array (secondary source registers) p[1] = {} -- const_immediates pool (numbers, 32-bit constants) p[2] = {} -- const_globals pool (strings, global names) p[3] = {} -- proto_children pool (nested closure descriptors) p[4] = buffer.readu8(buf, offset) -- numparams p[5] = buffer.readu8(buf, offset + 1) -- maxstack p[6] = {} -- captures array (upvalue descriptors) p[10] = {} -- metadata (runtime execution flags) root[p_idx] = p end return root end -- Layer 2: Virtual Machine Interpreter local function LuraphInterpreter(prototypes, env) local root = prototypes[1] local PC = 1 local handlers = {} local function InterpretFunc() while true do local op = root[7][PC] -- Column 7: Virtual opcodes local dest = root[8][PC] -- Column 8: Primary destination register local src1 = root[9][PC] -- Column 9: Primary source register / branch target local src2 = root[11][PC] -- Column 11: Secondary source register local handler = handlers[op] handler(PC, root, dest, src1, src2) PC = PC + 1 -- CRITICAL: Post-increment continuation law! end end return InterpretFunc end local unpackedData = LPH_Deserialize() return LuraphInterpreter(unpackedData, env)() end
Now let's examine what the actual code looks like in the wild. Opening our target sample (luraph_variant_01.luau), the entire 150 KB script is structured as an anonymous dictionary of helper functions invoked via a tail method call:
-- Raw Luraph v14.7 outer wrapper (authentic excerpt from variant_01) return ({ N8 = function(z, z, a) z = a[24808]; return z; end, R = function(z, a, N, V, _) a = ({}); _[0x1] = nil; _[0x2] = nil; _[0x3] = nil; N = 34; repeat if N == 0x22 then _[0x1] = 0; _[0x2] = 2147483648; if not (not a[22905]) then N = a[22905]; else N = -0x7F9 + (z.E8(z.A[2] + N - z.A[0b110] - z.A[0x3], z.A[0b110], z.A[0x1])); a[0x5979] = (N); end; else if N ~= 25 then else _[3] = {}; break; end; end; until false; return N, a, V; end, -- The authentic instruction array unrolling loop inside function r8: r8 = function(z, z, a, N, V, _) z = _[0B110000_](); -- _[48]() reads integer/byte from stream for _ = a - a % 0X1, V do N[_] = (z); end; return z; end, -- Memory reader aliases: y = "readf64", j = "readi16", l = "\99o\112y", -- Deserializer boundary seam (single clean definition): t = function(z) local a, N, V, _, y = ({}); _, V, y = z:R(V, _, y, a); y, _ = z:h(a, y, V, _); _, A = z:mH(a, _, A, V, y); local y, K, D; A, _, D, K, y = z:v8(y, K, a, D, V, A, _); N, D = z:U8(K, D, a, y); return z.Z(N); -- z.Z is unpack; returns root closure end, Z = unpack, }):t()(...);
And here is the authentic bytecode constructor at offset 123802 (function FH) that unpacks prototype table y and creates the VM execution record:
-- Authentic VM Closure Factory at offset 123802 in variant_01: FH = function(z, a, N, V, _) if V == 0X46 then _[0B111__0__11] = function(y, A) -- y is the Prototype Table; A is the Upvalues Environment local K, D = y[0b0_101], (y[0b11]); -- K = y[5] (maxstack), D = y[3] (proto_children) local i, Z, o, H, x, p, l, m = y[0X2__], -- i = y[2] (const_globals pool) y[0B001011], -- Z = y[11] (reg_src2 array) y[1], -- o = y[1] (const_immediates pool) y[0X07], -- H = y[7] (opcodes array) y[0x6], -- x = y[6] (captures array) y[0X9], -- p = y[9] (reg_src1 / jump targets) y[0X8]; -- l = y[8] (reg_dest array) m = function(...) local R = _[0X11](K); -- Allocate R (Stack Registers) with size K (y[5]) local K, v = _[0B11_1010](...); local B, P, h, W, G, Q, u, b, J, d = 0x1, 0b1, (_[38]()), 0B1, 0X000; -- B is the Program Counter (PC); starts at 1 repeat local Y = (H[B]); -- Fetch Opcode Y from H[B] -- Handler dispatch tree follows ... until false end end end end
Notice the key architectural details:
- Primitive Readers: Luraph re-maps memory access primitives to short local aliases (
z.y = "readf64",z.j = "readi16",z.l = "copy",z.J = "writeu32"). - Encrypted Bytecode String: Deep inside function
HH, a massive string literal is unpacked:N[0x2B] = N[0x2A]([=[LPH&!!.t1"UZL^3=5iq.LIp<.LI.&'FGEZ9aZ1R"U^e-4UO.U$jouA'+/Xb!"')@1C>K:?...]=])
- Function
t: The orchestrator. It runs the deserializerz:U8(...), which returnsN(the interpreter) andD(the unpacked prototype data graph), then callsreturn z.Z(N)to execute. - Anti-Parser Trailing Underscores (248 Occurrences): Notice literals like
0X2__(decimal 2) and0B110000_(decimal 48). While languages like Python or Rust strictly forbid trailing digit separators (e.g.100_is an error), Luau's lexer (Lexer::readNumber) loops whileisHexDigit(ch) || ch == '_', allowing trailing and consecutive underscores anywhere after the base prefix. Luraph intentionally weaponizes this Luau compiler quirk across 248 numeric constants invariant_01to crash external Lua formatters and non-Luau AST parsers. - Duplicate Parameter Shadowing (53 Occurrences): Functions like
N8andr8declare duplicate parameters:function(z, z, a). In Lua 5.1 and Luau, non-vararg parameter lists legally allow duplicate names; the secondzsimply shadows the first in local register allocation. Luraph employs this systematically across 53 dispatch methods to discard caller receiver arguments (such asselffrom:calls) while collapsing variable symbol tables.
The analysis logs (
prototype_graph_variant_01.txt [929 KB] and vm_execution_trace_variant_01.txt [1.7 KB]) were recorded directly on July 27, 2026 during the initial VM extraction run. Line 2 of the execution trace has fixed opcode_field=7 since July 2026; previous draft discrepancies were the result of human editorial error across multiple sample notes rather than synthetic data generation.
2. The Dual Serializer & 11-Column SoA Memory Layout
Tracing the closure factory FH above reveals the exact 11-column Structure-of-Arrays mapping used throughout Luraph v14.7. Rather than an array of opcode objects {op, a, b, c}, each prototype decomposes its activation record into 11 discrete table fields:
Readers testing against different Luraph generations must distinguish the architectural evolution between v14.x and v15.x:
- Luraph v14.7 (This Target,
variant_01.luau): Prototypes strictly use an 11-column Structure-of-Arrays (SoA) layout (entries=11in Table 1 ofprototype_graph_variant_01.txt). All prototypes in the build share invariant column indices (p[7]for opcodes,p[8]for reg_dest,p[6]for captures). - Luraph v15: Prototypes are expanded to 32-slot activation record tables with a randomized per-prototype slot permutation map (metamap) generated at compile time. In v15,
opcodesmay occupy slot 14 in Proto 1 but slot 29 in Proto 2, interleaved with dummy decoy slots. Decompiling v15 requires extracting each prototype's slot-indirection map from its closure allocation prologue rather than indexing fixed columns.
| Prototype Slot | VM Local Name | Type | Architectural Semantic in Variant 01 |
|---|---|---|---|
p[1] |
o |
Array of any | const_immediates: Constant pool A (extended 32-bit immediates, numbers, strings). Table 2 (213 entries). |
p[2] |
i |
Array of any | const_globals: Constant pool B (global identifiers, API strings, numbers). Table 3 (203 entries). |
p[3] |
D |
Array of tables | proto_children: Child prototypes pool / nested closure descriptor references. Table 430. |
p[4] |
— | Integer | numparams: Formal parameter count accepted by the closure (0 for root). |
p[5] |
K |
Integer | maxstack: Stack register allocation width (passed to _[17](K); 35 for root, 139 for Proto 2). |
p[6] |
x |
Array of tables | captures: Upvalue capture descriptors: {"mode": cap[1], "index": cap[3]}. Table 431 (4 entries). |
p[7] |
H |
Array of integers | opcodes: Virtual opcode stream (indexed by PC B: Y = H[B]). Table 432 (403 instructions). |
p[8] |
l |
Array of integers | reg_dest: Primary destination register operands (R[l[B]] = ...). Table 433 (403 entries). |
p[9] |
p |
Array of integers | reg_src1: Primary source register operands / branch targets (B = p[B]). Table 434 (403 entries). |
p[10] |
— | Table | metadata: Closure scoping flags and internal runtime attributes. Table 435. |
p[11] |
Z |
Array of integers | reg_src2: Secondary source register operands (R[l[B]] = R[Z[B]] * i[B]). Table 436 (403 entries). |
This mapping directly aligns with our extracted artifact prototype_graph_variant_01.txt and the bytecode instructions extracted from the VM dispatch loop.
Here is the authentic dumped prototype graph entry for Root Table 1 from prototype_graph_variant_01.txt:
LURAPH_PROTOTYPE_GRAPH|version=1 ROOT|1 TABLE|1|metatable=Z:|entries=11 ENTRY|1|N:1|R:2 -- const_immediates: Constant Pool A (Table 2: 213 entries) ENTRY|1|N:2|R:3 -- const_globals: Constant Pool B (Table 3: 203 entries) ENTRY|1|N:3|R:430 -- proto_children: Child Prototypes Pool (Table 430) ENTRY|1|N:4|N:0 -- numparams: 0 (Root closure formal parameter count) ENTRY|1|N:5|N:35 -- maxstack: 35 registers (Activation record allocation width) ENTRY|1|N:6|R:431 -- captures: Upvalue Descriptors (Table 431: 4 captures) ENTRY|1|N:7|R:432 -- opcodes: Opcode Stream (Table 432: 403 instructions) ENTRY|1|N:8|R:433 -- reg_dest: Primary Destination Register Operands (Table 433) ENTRY|1|N:9|R:434 -- reg_src1: Primary Source Registers / Jump Targets (Table 434) ENTRY|1|N:10|R:435 -- metadata: Scoping Flags & Runtime Attributes (Table 435) ENTRY|1|N:11|R:436 -- reg_src2: Secondary Source Register Operands (Table 436)
3. Locating the Seam & Dumping the Prototype Tree
Instead of spending days manually decrypting the 150 KB string literal, we intercept the data at the boundary seam where the deserializer returns the fully unrolled prototype graph.
Looking inside function t:
N, D = z:U8(K, D, a, y); return z.Z(N);Variable
D is the deserialized root prototype. We hook function t to intercept D and halt before VM execution begins:
-- Our patched boundary return in function t: N, D = z:U8(K, D, a, y); return { root_proto = D, N = N };
We then append our dumper scanner (luraph_dumper.luau) to recursively unroll all child closures:
local root_proto = dumped.root_proto local proto_list = {} local proto_to_id = {} local function register_proto(p) if type(p) ~= "table" then return nil end local opcodes = rawget(p, 7) if type(opcodes) ~= "table" or #opcodes == 0 then return nil end if proto_to_id[p] == nil then local new_id = #proto_list proto_to_id[p] = new_id table.insert(proto_list, p) return new_id end return proto_to_id[p] end register_proto(root_proto) local scan_cursor = 1 while scan_cursor <= #proto_list do local current_proto = proto_list[scan_cursor] scan_cursor = scan_cursor + 1 for k, v in pairs(current_proto) do if type(v) == "table" then register_proto(v) for _, item in pairs(v) do if type(item) == "table" then register_proto(item) end end end end end print("Total prototypes discovered in variant_01: " .. #proto_list)
Executing this harness in Luau yields:
> luau.exe dump_variant_01.luau
Total prototypes discovered in variant_01: 37
This probe dumped all 37 authentic prototypes with their complete instruction streams, upvalue descriptors, and constant pools without executing a single instruction of the VM interpreter.
Notice that
ENTRY|1|N:5|N:35 defines the activation record stack allocation width (maxstack = 35 registers) allocated by _[17](K). Meanwhile, the complete script hierarchy contains 37 total prototypes (Root Proto 1 + Anti-Tamper Proto 2 + 35 Child Application Prototypes 3..37). A naive reader might conflate Table 1's stack width with the child prototype count; but tracking y[5] through FH proves that p[5] is strictly the register stack size, while child closures are nested in constant tables.
4. Nested & On-Demand Prototype Materialization (Sub-Buffer Slicing)
A common pitfall that trips up reverse engineers when comparing different Luraph targets is assuming that prototype allocation mechanisms remain uniform across major versions.
In our analyzed target (
variant_01.luau, protected by Luraph v14.7), the main deserializer (function t) runs eagerly up front, allocating all 37 prototypes directly into memory before VM execution begins. That is why our single memory boundary hook in Section 3 successfully dumped the complete 37-prototype graph in a single crawl.
However, in heavily protected newer builds (v14.8+ and v15), Luraph introduced an anti-dumping mitigation: child prototypes are NOT materialized at startup!
In v15, Luraph leaves child closures stored as packed, raw binary slices (sub-buffers) inside the prototype's operand columns:
-- On-Demand Prototype Instantiation (Opcode 107 / 216) local sub_slice = buffer_copy(payload_buffer, offset, length) local child_proto = LPH_MiniDeserialize(sub_slice) local new_closure = AllocClosure(child_proto, upvalues)
If you only crawl table references at the boundary exit, your dumper will only find the root prototype and any top-level wrappers—all inner application functions appear missing.
To decompile scripts using on-demand materialization:
- Hook the Inner Mini-Deserializer: In addition to hooking the main entry deserializer, locate the closure allocation handler (Opcode 107/216) and place a hook on the nested deserializer routine (
LPH_MiniDeserialize) to intercept sub-prototypes as they are created. - Static Stream Slicing: Alternatively, replicate the mini-deserializer's byte reader. When Opcode 107/216 is encountered, read the slice offset and length from operand columns
data1anddata4, extract the sub-buffer from the raw string payload, and parse the 11-column table directly in your offline lifter.
5. Lazy Constant Decryption & LCG Seed Unwrapping
Another dangerous assumption is that constant columns p[1] and p[2] contain cleartext strings and numbers immediately upon boundary capture.
In older Luraph versions, constant pools held plain Lua strings ("Players", "LocalPlayer", "Workspace"). But in modern builds, string constants are stored as raw encrypted byte sequences or numeric seeds.
Luraph executes lazy runtime decryption (often handled by Opcode 10 or an initialization prologue) using a Linear Congruential Generator (LCG) and rotating XOR schedule:
-- Reconstructed Luraph lazy constant decryptor (Opcode 10) local function DecryptString(encrypted_bytes, seed) local result = {} local LCG_A = 1664525 local LCG_C = 1013904223 local LCG_M = 4294967296 -- 2^32 for i = 1, #encrypted_bytes do -- Advance LCG state seed = (seed * LCG_A + LCG_C) % LCG_M local key_byte = bit32.band(bit32.rshift(seed, 16), 0xFF) local raw_byte = string.byte(encrypted_bytes, i) result[i] = string.char(bit32.bxor(raw_byte, key_byte)) end return table.concat(result) end
LCG Randomization vs. v15 Byte-Wise Stream Ciphers: While variant_01 (v14.7) utilizes a 32-bit Numerical Recipes LCG state (modulus = 2^32, extracting key bytes with bit32.rshift(seed, 16)), Luraph v15 overhauled string encryption into an 8-bit byte-wise stream cipher (mod 256) with rolling accumulators.
Pragmatic Boundary Hooking Over Reimplementation: A natural question is: "Why reimplement the decryption algorithm offline when the VM already contains the routine?" In our pipeline, we do NOT manually re-execute the LCG cipher offline. Instead, we let the VM's native deserializer run up to the initialization seam and hook the memory boundary immediately after constant unbiasing finishes. This captures the complete, authentic 164-item constant pool (k_table_variant_01.json) without risking arithmetic drift or cipher divergence across compiler versions:
In variant_01, once constant unbiasing was applied, our decompiler dumped the complete 164-item constant pool (k_table_variant_01.json):
[ "workspace", "pcall", "string", "\"gmatch\"", "\"format\"", "\"match\"", "\"find\"", "coroutine", "\"char\"", "islclosure", "\"byte\"", "Instance", "\"sub\"", "\"gsub\"", "game", "\"isyieldable\"", "\"unpack\"", "table", "getmetatable", "\"running\"", "tostring", "\"create\"", "\"concat\"", "false", "true", "\"resume\"", "\"status\"", "\"yield\"", "assert", "rawset", "Vector3", "identifyexecutor", "loadstring", "StarterPlayer", "Path2D" ]
The Two-Tier Constant Architecture: Anti-Tamper vs. Application
A critical question every reverse engineer asks when inspecting k_table_variant_01.json is:
"If k_table contains Roblox APIs like StarterPlayer, ScreenGui, Path2D, and Vector3, why does the recovered application in Section 18 use none of them?"
The answer lies in Luraph's prototype hierarchy. k_table_variant_01.json is the extracted global constant pool of strings decrypted during VM initialization. It is stored as a single flat array of exactly 164 string entries. Within this pool, symbols bifurcate into two distinct functional surfaces:
| Constant Surface | Key Extracted Symbols in k_table_variant_01.json |
Consuming Prototype | Decompiler Action |
|---|---|---|---|
| Subset A: Anti-Tamper & Security Probe Surface | StarterPlayer (idx 135), ScreenGui (idx 105), Path2DControlPoint.new (idx 95), HttpService (idx 127), PostAsync (idx 128), Vector3 (idx 40), UDim2.new (idx 52), islclosure (idx 9), identifyexecutor (idx 41) |
Proto 1 & Proto 2 (Root loader & 4,943-instruction security suite [4,484 reachable]). | Decoupled & Pruned. Stripping Proto 2 neutralizes the anti-tamper harness and removes all Roblox calls from the recovered AST. |
| Subset B: Luau Standard Library & Runtime Surface | coroutine (create, yield, resume, status), table (insert, concat), string (format), pcall, xpcall, setmetatable, tostring, type, select, error |
Protos 3 through 37 (Authentic application prototypes). | Fully Lifted & Emitted. 100% of the runtime library calls in the Section 18 recovered code map directly to these exact entries. |
| Subset C: Private Prototype Constant Pools | Table 209: "number", "string", "boolean"Table 52: "error in error handling"Table 136-137: "byte", "rep", "string" |
Protos 3 through 37 (Leaf-level application closures). | Bound to Local AST. Private constant tables owned directly by child closures (e.g. scalar(v) and testErrors()). |
Here is the cross-reference verifying that the recovered 124-line clean code in Section 18 consumes 100% of its runtime dependencies directly from Subset B of k_table_variant_01.json:
-- Verification of k_table symbols consumed by recovered application (Section 18): coroutine.create <--> k_table[7] ("coroutine") + k_table[21] ("create") coroutine.yield <--> k_table[7] ("coroutine") + k_table[27] ("yield") coroutine.resume <--> k_table[7] ("coroutine") + k_table[25] ("resume") coroutine.status <--> k_table[7] ("coroutine") + k_table[26] ("status") table.insert <--> k_table[17] ("table") + k_table[47] ("insert") table.concat <--> k_table[17] ("table") + k_table[22] ("concat") string.format <--> k_table[2] ("string") + k_table[4] ("format") pcall <--> k_table[1] ("pcall") xpcall <--> k_table[82] ("xpcall") setmetatable <--> k_table[77] ("setmetatable") tostring <--> k_table[20] ("tostring") type <--> k_table[88] ("type") select <--> k_table[85] ("select") error <--> k_table[58] ("error")
The Roblox symbols in Subset A (StarterPlayer, ScreenGui, Path2DControlPoint.new, HttpService, PostAsync) belong strictly to Proto 2. Because our decompiler statically decouples Proto 2, those Roblox probe calls never appear in the final decompiled payload.
6. Dissecting Proto 2: The 4,943-Instruction Anti-Tamper Harness (4,484 Reachable)
Inspecting prototype_graph_variant_01.txt reveals an immense disparity in prototype complexity:
- Proto 1: Root bootstrap loader (403 instructions, stack width 35).
- Proto 2: Table 4 (stack width 139, 4,943 raw instructions in Table 422, reducing to 4,484 reachable instructions after pruning dead trampolines and decoy suicide blocks; 213 string constants in Table 2).
- Protos 3 through 37: Child application prototypes (ranging from 12 to 240 instructions).
ENTRY|2|N:9|S:53746172746572506C61796572 -- "StarterPlayer" ENTRY|2|N:23|S:57616974466F724368696C64 -- "WaitForChild" ENTRY|2|N:26|S:4C7572617068 -- "Luraph" ENTRY|2|N:38|S:506F73744173796E63 -- "PostAsync" ENTRY|2|N:44|S:417474726962757465436861... -- "AttributeChanged" ENTRY|2|N:66|S:546865206465627567206C69... -- "The debug library is required on Luau platforms. Please open a support ticket." ENTRY|2|N:76|S:4973536572766572 -- "IsServer"
Luraph is commercially distributed as a protection service primarily tailored to Roblox Lua developers. Because of this, the Luraph compiler pipeline embeds an identical ~4,900-instruction Roblox anti-tamper harness (Proto 2) into every single compiled binary, even when the input script contains zero Roblox API calls!
In our experiment, we fed Luraph a complex, multi-paradigm standalone Luau benchmark (exercising coroutines, proxy metatables, error boundary handling, and stateful upvalue closures). Luraph dutifully wrapped it inside its standard Roblox integrity harness (probing
StarterPlayer, ScreenGui, Path2DControlPoint.new, HttpService, PostAsync, and Vector3).
Why Can Proto 2 Be Discarded Statically (v14.7 Decoupling vs. v15 Key Chaining)?
A critical question reverse engineers raise when analyzing newer builds is: "How can you discard anti-tamper statically if payload decryption depends on probe return values?"
In Luraph v15, the compiler chains anti-tamper probe return values into the rolling LCG key schedule (cryptographic probe chaining). But in Luraph v14.7 (our target
variant_01.luau), Proto 2 is purely an active integrity watchdog, not a decryption prerequisite! The main deserializer (function t: z:U8(...)) unrolls and deserializes the entire prototype graph (all 37 prototypes) in clear memory before Proto 2 ever executes. Because Protos 3 through 37 have zero dataflow, key-derivation, or upvalue dependencies on Proto 2, isolating Proto 2 completely neutralizes the security checks and exposes the pristine application payload.
7. Virtual Machine Execution Tracing & Fatal Offline Traps
To understand how the VM dispatches instructions, we instrumented the interpreter loop to log every opcode read and global access (vm_execution_trace_variant_01.txt):
LURAPH_VM_EXECUTION_TRACE|version=1 SUMMARY|known_prototypes=37|builder_key=59|getfenv_key=38|opcode_field=7 OPCODE_READ|ordinal=1|prototype=1|pc=1|opcode=156 OPCODE_READ|ordinal=2|prototype=1|pc=287|opcode=156 OPCODE_READ|ordinal=3|prototype=1|pc=57|opcode=144 OPCODE_READ|ordinal=4|prototype=1|pc=58|opcode=97 OPCODE_READ|ordinal=5|prototype=1|pc=59|opcode=121 OPCODE_READ|ordinal=6|prototype=1|pc=60|opcode=54 OPCODE_READ|ordinal=7|prototype=1|pc=61|opcode=54 GLOBAL_READ|ordinal=1|key=string:566563746F7233|value_kind=nil -- Vector3 OPCODE_READ|ordinal=17|prototype=1|pc=98|opcode=145 OPCODE_READ|ordinal=18|prototype=1|pc=99|opcode=156 GLOBAL_READ|ordinal=2|key=string:5544696D32|value_kind=nil -- UDim2 EXECUTION|ok=false|message=attempt to index nil with 'new'
Notice what happens: Proto 1 executes a few opcodes, attempts to access Vector3.new, and immediately crashes because standard Luau offline runtimes lack Roblox globals. This demonstrates why emulating or executing obfuscated code to decompile it is a dead end. Static decompilation of the payload bytecode is mandatory.
8. The Trampoline Maze & Mathematical Continuation Law
When we first attempted to decompile Proto 8, our Control Flow Graph builder collapsed into an infinite empty loop:
-- Naive CFG generation collapsed into an infinite loop while true do end
Tracing the raw instruction stream revealed the root cause: instruction 1 unconditionally jumped into a circular decoy trampoline ring:
pc 1: op 156 (jump) -> target: 46
pc 46: op 156 (jump) -> target: 79
pc 79: op 156 (jump) -> target: 179
pc 179: op 156 (jump) -> target: 267
pc 267: op 156 (jump) -> target: 206
pc 206: op 156 (jump) -> target: 78
pc 78: op 156 (jump) -> target: 62
pc 62: op 156 (jump) -> target: 231
pc 231: op 156 (jump) -> target: 50
pc 50: op 156 (jump) -> target: 1 <-- Circular decoy trap!
Any naive graph analyzer following jump targets will loop infinitely across these 10 hops (1 -> 46 -> 79 -> 179 -> 267 -> 206 -> 78 -> 62 -> 231 -> 50 -> 1).
The Continuation Law: PC = target + 1
Examining the AST handler semantics in opcode_handlers_stable_variant_01.txt for Opcode 156 revealed the underlying secret:
HANDLER|opcode=156|bytes=122|AstStatAssign(values=Array(1=AstExprIndexExpr(expr=Ref(D6),index=Ref(PC))),vars=Array(1=Ref(PC)))The handler sets
PC = target. But in the outer interpreter loop:
while true do local op = opcodes[PC] handlers[op](PC, proto) PC = PC + 1 -- The continuation increment! endBecause
PC = PC + 1 executes after the handler finishes, the next instruction dispatched is:
$$\text{effective\_PC} = \text{target} + 1$$
Applying the continuation law `effective_PC = target + 1`:
step 0: pc 1 -> executes op 156 target 46 -> Next PC: 46 + 1 = 47
step 1: pc 47 -> executes op 70 (stack setup)
step 2: pc 48 -> executes op 133 (getupval)
step 3: pc 49 -> executes op 157 (move v0, v1)
step 4: pc 50 -> executes op 156 target 1 -> Next PC: 1 + 1 = 2
step 5: pc 2 -> AUTHENTIC APPLICATION LOGIC!
Targeting `target + 1` completely shatters the decoy ring, routing execution linearly into the real application entry point at PC 2.
Relative Branch vs. Absolute Jump: In Variant 01, jump targets in column data6 are stored as absolute 1-based instruction addresses (PC = target), so the continuation law evaluates to effective_PC = target + 1. However, in Luraph variants that utilize relative branch offsets (PC = PC + offset), the exact same dispatch continuation rule applies: the handler computes the offset addition, and the interpreter immediately performs its post-increment, yielding:
$$\text{effective\_PC} = \text{current\_PC} + \text{offset} + 1$$
9. Pre-Lifter: Control Flow Graph (CFG) Recovery
With the continuation law proven, our frontend partitions the linear instruction stream into well-formed basic blocks.
1. Classifying Branch and Jump Opcodes
Through symbolic analysis of the VM handlers, we cataloged all unconditional jump and conditional branch opcodes, noting which column holds the branch target:
UNCOND_JUMPS = { 17: 5, # target stored in data5 132: 5, # target stored in data5 148: 2, # target stored in data2 156: 6, # target stored in data6 } COND_BRANCHES = { 65: 2, 94: 2, 100: 2, 185: 2, # target in data2 7: 5, 28: 5, 41: 5, 51: 5, 66: 5, 111: 5, # target in data5 25: 6, 32: 6, 114: 6, 139: 6, 141: 6, # target in data6 } RETURN_OPS = {56, 86, 112, 113, 142, 188} # Return/unpack terminators (130 is strictly Divide)
2. Basic Block Leader Detection
A basic block leader is identified by three rules:
- Instruction 1 (entry block).
- The effective target of any jump or branch (`target + 1`).
- The instruction immediately following any branch or return (`pc + 1`).
leaders = {1} for pc, ctrl in inst_control.items(): if ctrl['pc_writes']: target_pc = ctrl['pc_writes'][0]['target'] if 1 <= target_pc <= n_insts: leaders.add(target_pc) if pc + 1 <= n_insts: leaders.add(pc + 1) elif ctrl['handler_returns']: if pc + 1 <= n_insts: leaders.add(pc + 1)
10. Dynamic Opcode Remapping & Symbolic AST Handler Matching
In modern Luraph, opcode numbers are shuffled per build. We cataloged all 258 handler definitions in opcode_handlers_stable_variant_01.txt using abstract syntax tree (AST) pattern matching.
Here are the actual AST signatures extracted from variant_01:
1. Conditional Branch (Opcode 111)
HANDLER|opcode=111|bytes=335|If(AstExprUnary(expr=AstExprBinary(left=AstExprIndexExpr(expr=Ref(REG),index=AstExprIndexExpr(expr=Ref(D6),index=Ref(PC))),op="CompareEq",right=AstExprIndexExpr(expr=Ref(D3),index=Ref(PC))),op="Not"),{},{AstStatAssign(values=Array(1=AstExprIndexExpr(expr=Ref(D5),index=Ref(PC))),vars=Array(1=Ref(PC)))})
Semantics: Compares REG[D6] == D3 (register equals constant). If true, sets PC = D5 (branch target).
2. Table Store (Opcode 145)
HANDLER|opcode=145|bytes=354|AstStatAssign(values=Array(1=AstExprIndexExpr(expr=Ref(REG),index=AstExprIndexExpr(expr=Ref(D1),index=Ref(PC)))),vars=Array(1=AstExprIndexExpr(expr=AstExprIndexExpr(expr=Ref(REG),index=AstExprIndexExpr(expr=Ref(D6),index=Ref(PC))),index=AstExprIndexExpr(expr=Ref(REG),index=AstExprIndexExpr(expr=Ref(D2),index=Ref(PC))))))Semantics:
REG[D6][REG[D2]] = REG[D1]. Direct table key-value assignment.
3. Arithmetic Add Immediate (Opcode 11)
HANDLER|opcode=11|bytes=252|AstStatAssign(values=Array(1=AstExprBinary(left=AstExprIndexExpr(expr=Ref(D1),index=Ref(PC)),op="Add",right=AstExprIndexExpr(expr=Ref(D4),index=Ref(PC)))),vars=Array(1=AstExprIndexExpr(expr=Ref(REG),index=AstExprIndexExpr(expr=Ref(D2),index=Ref(PC)))))Semantics:
REG[D2] = D1 + D4. Immediate numeric addition.
4. Opcode Remapping Table (Opcode 54)
HANDLER|opcode=54|bytes=284|AstStatAssign(values=Array(1=AstExprIndexExpr(expr=Ref(D1),index=Ref(PC))),vars=Array(1=AstExprIndexExpr(expr=AstExprIndexExpr(expr=Ref(REG),index=AstExprIndexExpr(expr=Ref(D2),index=Ref(PC))),index=AstExprIndexExpr(expr=Ref(REG),index=AstExprIndexExpr(expr=Ref(D6),index=Ref(PC))))))Semantics: Mutates an internal VM table slot
REG[D2][REG[D6]] = D1.
11. Sample-Specific Fast-Path: Variant 01 Opcode & Branch Tables
To deobfuscate this specific target (luraph_variant_01.luau) way faster, here is the complete, verified opcode lookup cheat sheet extracted from our automated analysis:
1. Conditional Branch Condition Decode Table
| Branch Opcode | Target Column | Condition Evaluation Logic | True/False Direction |
|---|---|---|---|
111 |
data5 |
R(bd6) == bd3 (Register equals constant) |
Then: PC+1, Else: Target |
28 |
data5 |
R(bd6) <= bd3 (Register less-than-or-equal constant) |
Then: PC+1, Else: Target |
51 |
data5 |
not R(bd6) (Logical negation of register) |
Then: PC+1, Else: Target |
25 |
data6 |
R(bd2) (Register raw truthiness check) |
Then: Target, Else: PC+1 |
41 |
data5 |
R(bd6) == R(bd2) (Register equals register) |
Then: PC+1, Else: Target |
66 |
data5 |
R(bd6) < R(bd2) (Register less-than register) |
Then: PC+1, Else: Target |
100 |
data2 |
R(bd6) == bd1 (Register equals immediate) |
Then: Target, Else: PC+1 |
114 |
data6 |
R(bd2) == R(bd5) (Register equals register) |
Then: Target, Else: PC+1 |
141 |
data6 |
R(bd5) <= R(bd2) (Register less-equal register) |
Then: PC+1, Else: Target |
7 |
data5 |
R(bd6) >= bd3 (Register greater-equal constant) |
Then: PC+1, Else: Target |
65 |
data2 |
R(bd6) < bd1 (Register less-than immediate) |
Then: PC+1, Else: Target |
94 |
data2 |
R(bd5) > bd4 (Register greater-than immediate) |
Then: PC+1, Else: Target |
2. Function Call & Statement Dispatch Signatures
Opcode 26: 0-argument function call:R(d6)()with return value saved toR(d6).Opcode 37, 150, 169: 1-argument call with return:R(fn) = R(fn)(R(fn + 1)).Opcode 125, 106: 1-argument void call (statement):R(d2)(R(d2 + 1)).Opcode 12: 2-argument void call:R(d6)(R(d6 + 1), R(d6 + 2)).Opcode 24: Bitwise XOR call:R(d5) = bit32.bxor(R(d2), d4).
12. Active Dispatch Remapping vs. Decoy Mutation (Def-Use Dataflow)
In the raw lifted output of Luraph prototypes, you will see thousands of strange table assignments scattered across every block:
local v28 = nil local v26 = nil v28[13] = 110 v28[14] = 178 v26[14] = 0 v26[72] = 70
Warning: Do NOT blindly prune these table writes as "junk slop"!
While many obfuscators insert pure dummy table operations to bloat code, Luraph uses some of these tables as active dynamic dispatch remapping tables.
In certain VM configurations, the interpreter does not index the opcode handler table directly by the raw bytecode opcode. Instead, it reads:
-- Dynamic dispatch lookup in VM interpreter local effective_op = v28[raw_op] local handler = handlers[effective_op] handler(PC, proto)
When v28[13] = 110 executes, it dynamically remaps raw opcode 13 to handler 110 for all subsequent blocks on that execution path. If you indiscriminately delete writes to v28, downstream instructions will be decoded using the wrong handler semantics, corrupting the decompilation.
The Required Solution: SSA Def-Use Dataflow Analysis
Our decompiler performs formal dataflow analysis before deciding whether a table write can be safely pruned:- Build Def-Use Chains: Trace every write to a table register (
v28,v26,v35). - Check VM Dispatch Flow: If the table register escapes into the VM's instruction fetch routine or dispatch lookup, mark it as an active remapping table. Propagate the updated slot value (
13 -> 110) into the static opcode lookup state for all downstream basic blocks. - Prune Proven Decoys: If liveness analysis proves that a table register is never read before function exit, or only participates in dummy arithmetic (e.g.
v26[14] = 0followed by an opaque branch that gets folded), classify it as a dead decoy store and remove it.
13. Opaque Predicates & Static Suicide Block Pruning
Even with basic blocks partitioned, Luraph scripts contain intentional crash traps known as suicide blocks.
Luraph injects opaque predicates—conditional checks that statically evaluate to a constant boolean value—that branch into intentional crashes:
-- Injected opaque predicate v26[14] = 0 if v26[14] ~= 0 then -- SUICIDE BLOCK (intentional infinite loop / panic) while true do end end
If a decompiler blindly includes these edges:
- The control flow graph gets polluted with unreachable cycles.
- Structuring passes fail to identify loop exits and collapse into a massive
while truestate machine fallback.
To solve this, our frontend runs a constant folding and dead edge pruning pass before basic block construction:
- Track constant table writes (
v26[14] = 0). - Evaluate branch conditions statically:
0 ~= 0evaluates toFalse. - Prune the branch edge to the suicide block.
- Run a breadth-first search (BFS) starting from block 0; any basic block not reached during the traversal is discarded completely.
# Dead block elimination via reachability traversal visited = set() queue = [start_bid] while queue: curr = queue.pop(0) if curr in visited or curr not in raw_blocks: continue visited.add(curr) for s in raw_blocks[curr]['successors']: if s not in visited: queue.append(s) reachable_bids = sorted(list(visited))
14. Irreducible Graph Structuring & The Relooper Failure (Node-Splitting)
Another major technical hurdle in Luraph devirtualization is irreducible control flow graphs.
Decompilers commonly use the Relooper or DREAM algorithms to structure basic blocks back into high-level AST constructs (`while`, `repeat`, `if`). However, standard Relooper assumes that all loops in the CFG are reducible (i.e. every loop has a single entry header block that strictly dominates all blocks within the loop body).
Luraph deliberately defeats this assumption by synthesizing multi-entry loops:
-- Luraph multi-entry irreducible cycle
Block A Block B
\ /
v v
[ Shared Loop Node C ]
|
v
[ Shared Loop Node D ]
/ \
v v
Block A Block B
Neither Block A nor Block B dominates Node C. When standard Relooper encounters this irreducible cycle, it cannot identify a unique loop header, throws an irreducibility error, and falls back to a 20,000-line dispatcher state machine:
-- The Relooper Dispatcher Fallback local state = 1 while true do if state == 1 then ... state = 4 elseif state == 4 then ... state = 2 end end
The Solution: Node-Splitting Normalization
To eliminate the state machine fallback, our pipeline runs a node-splitting pass before invoking Relooper:
- Cycle Detection & Dominator Tree Analysis: Compute the dominator tree. Identify strongly connected components (SCCs) that have multiple predecessor edges entering from outside the component.
- Clone Shared Multi-Entry Nodes: For each alternate entry path into the cycle, duplicate the shared body nodes (cloning Node C into Node C_copy for Path B).
- Redirect Predecessors: Point Block B's edge to Node C_copy, while Block A points to original Node C.
- Reducible Conversion: Node-splitting untangles the shared cycle into two single-entry reducible paths.
Once normalized, Relooper reconstructs native while condition do and repeat until loops cleanly with zero dispatcher fallback!
15. Upvalue Capture Linking & Closure Scoping (p[6])
One of the most complex parts of Lua decompilation is recovering shared upvalue closures. If you don't resolve upvalues, variables shared between closures become disconnected local registers, breaking the logic.
In Luraph v14.7, every prototype table includes column 6: p[6] (captures table). Each entry in p[6] is a capture descriptor:
{ "mode": 0, "index": 0 } -- Captures parent stack register v0 { "mode": 1, "index": 2 } -- Forwards parent's upvalue slot 2
- Mode 0 (Parent Stack Register): The child closure captures local register
v[index]of the enclosing parent function. The parent allocates an open upvalue cell. When the child mutates it, the parent and all sibling closures observe the mutation. - Mode 1 (Upvalue Forwarding): The child inherits an upvalue already captured by the parent closure, linking through arbitrary lexical nesting depths.
Consider Prototype 13 in our sample:
- Proto 13 accepts argument
v0(initial). - Proto 13 instantiates child closures: Proto 14 (
read), Proto 15 (mutate), and Proto 16 (replace). - All three child prototypes have
p[6] = [{"mode": 0, "index": 0}].
upval_0 in Protos 14, 15, and 16 back to v0 in Proto 13, producing clean lexical scoping:
local function makeClosures(initial) local state = initial local function read() return state end local function mutate(delta) state = state + delta; return state end local function replace(nextVal) local p = state; state = nextVal; return p end return { read = read, mutate = mutate, replace = replace } end
16. Multi-Value Calling Conventions & Varargs
In standard Lua, function calls and returns can pass and receive variable numbers of arguments (multres). Luraph implements dynamic stack slices for calls and varargs:
- Function Calls (Opcode 0 / 182):
- Operand A: Base register
R(A)containing the function object. - Operand B: Argument count. If
B == 0, arguments extend fromR(A+1)up to top of stack. - Operand C: Return count. If
C == 0, all results returned by the callee are retained on the stack.
- Operand A: Base register
- Vararg Handling (
...): Luraph implements a binary tree unrolling function (theQhelper in the dumper):z[26] = function(a, N, V) if N > V then return end local _ = V - N + 1 if _ >= 8 then return a[N], a[N+1], a[N+2], a[N+3], a[N+4], a[N+5], a[N+6], a[N+7], z[26](a, N+8, V) end end
Our IR lifter represents these dynamic slices as typed value packs. When an instruction consumes a variable slice, the compiler preserves exact Lua semantics and cleanly decompiles to f(...) or return ... without artificial unpack helper functions.
17. Lifting like a Forklift: Bytecode to Typed IR
With basic blocks partitioned and trampolines resolved, we lift instructions into an Intermediate Representation (IR).
Our IR builder constructs a strictly validated AST:
- Registers are declared as typed places (`v0`, `v1`... up to `maxstack`).
- Intermediate calculations emit SSA values with explicit effect flags (`may_throw`, `reads_memory`, `writes_memory`).
- Control flow edges declare explicit successors and arguments.
Mapping Virtual Opcodes to IR Operations:
| Opcode | Role | VM Instruction Semantics | Emitted IR Operation |
|---|---|---|---|
11 |
Add Immediate | `v[d2] = v[d2] + d1` | `binary: add(place_read(v[d2]), const(d1))` |
43, 73, 147 |
Subtract | `v[dst] = v[d5] - v[d6]` | `binary: subtract(place_read(v[d5]), place_read(v[d6]))` |
53, 162 |
Multiply | `v[dst] = v[d5] * v[d6]` | `binary: multiply(place_read(v[d5]), place_read(v[d6]))` |
83, 130 |
Divide | `v[dst] = v[d5] / v[d6]` | `binary: divide(place_read(v[d5]), place_read(v[d6]))` |
152, 164 |
Modulo | `v[dst] = v[d5] % v[d6]` | `binary: modulo(place_read(v[d5]), place_read(v[d6]))` |
118 |
Power | `v[dst] = v[d5] ^ v[d6]` | `binary: power(place_read(v[d5]), place_read(v[d6]))` |
95, 40, 109 |
Concat | `v[dst] = v[d5] .. v[d6]` | `binary: concat(place_read(v[d5]), place_read(v[d6]))` |
81 |
Logical Not | `v[d5] = not v[d5]` | `unary: logical_not(place_read(v[d5]))` |
129 |
Length | `v[d5] = #v[d2]` | `unary: length(place_read(v[d2]))` |
168 |
Unary Minus | `v[d6] = -v[d5]` | `unary: negate(place_read(v[d5]))` |
157 |
Move | `v[d5] = v[d6]` | `place_write(v[d5], place_read(v[d6]))` |
31, 80, 97, 144 |
Load Const | `v[dst] = constant` | `place_write(v[dst], constant(d1))` |
68 |
Make Table | `v[d6] = {}` | `make_table` |
110, 134, 44, 18 |
Table Get | `v[dst] = v[t_reg][k_val]` | `table_get(table, key)` |
145, 75, 64 |
Table Set | `v[t_reg][k_val] = v_val` | `table_set(table, key, value)` |
178 |
Get Global | `v[d2] = env[name]` | `environment_get(name)` |
107, 216 |
Make Closure | `v[dst] = closure(proto_id)` | `make_closure(proto_id, captures)` |
126, 133 |
Get Upvalue | `v[dst] = upvalues[idx]` | `place_read(upval_place)` |
18. Before & After: Full Application Reconstruction
To demonstrate the full deobfuscation, consider Prototype 13 across all three stages of the pipeline:
Stage 1: Raw Lifted Output (luraph_variant_01-raw.lua)
protos[13] = function(...) local v0, v2, v15, v16, v19, v26, v28, v166, v566 local v28 = nil local v26 = nil v28[13] = 110 v28[14] = 178 v26[14] = 0 v26[72] = 70 local v2 = 21 v19 += 293 v19 /= v566 v15[12] = v19 v16[71] = 10 v2 = 11 local v166 = string return v0 end
Stage 2: Decompiled Output (luraph_variant_01-decompiled.lua)
Pruning dead table stores and coalescing temporary register state gives the structured prototype definition:
protos[13] = function(v0) local v1 = protos[14] local v2 = protos[15] local v3 = protos[16] return { read = v1, mutate = v2, replace = v3 } end
Stage 3: Clean Semantic Luau (luraph_variant_01-clean.lua)
Resolving the upvalue capture descriptors from column p[6] connects the shared upvalue cell across child closures 14, 15, and 16 into a single native lexical scope:
local function makeClosures(initial) local state = initial local function read() return state end local function mutate(delta) state = state + delta return state end local function replace(nextVal) local prev = state state = nextVal return prev end return { read = read, mutate = mutate, replace = replace } end
The Complete Recovered Application
The complete authentic output recovered from luraph_variant_01.luau consists of 124 lines of clean, structured Luau:
local events = {} local function mark(label, value) table.insert(events, { label, value }) return value end local function scalar(v) local t = type(v) if t == "string" then return string.format("%q", v) elseif t == "number" or t == "boolean" then return tostring(v) elseif v == nil then return "nil" else return tostring(v) end end local function control(seed) local acc = seed for i = 1, 10 do if acc % 2 == 0 then acc = acc / 2 + i else acc = acc * 3 + 1 - i end end return acc end local function createProxy(target) local proxy = {} local meta = { __index = function(_, k) mark("index", k); return target[k] end, __newindex = function(_, k, v) mark("newindex", k); target[k] = v end, __call = function(_, ...) mark("call", select("#", ...)); return target(...) end } return setmetatable(proxy, meta) end local function runCoroutines() local co = coroutine.create(function(start) local val = start for i = 1, 3 do val = coroutine.yield(val * 2 + i) end return val end) local ok, res = coroutine.resume(co, 5) while coroutine.status(co) ~= "dead" do ok, res = coroutine.resume(co, res + 1) end return res end local function testErrors() local ok1, err1 = pcall(function() error("intentional error") end) local ok2, err2 = xpcall(function() return 42 end, function(e) return "handled: " .. tostring(e) end) return not ok1 and ok2 and err2 == 42 end mark("init", 1) local cRes = control(7) mark("control", cRes)
This clean script executes natively with exit code 0, verifying 100% equivalence to the original protected program.
19. The Complete Deobfuscation Recipe (Variant 01 in 5 Steps)
Is this really all you need to deobfuscate this exact sample from scratch?
Yes. Everything necessary to devirtualize luraph_variant_01.luau into a fully working, runnable Luau script is contained in this article. If you want to replicate our results in under five minutes, follow this exact five-step recipe:
Step 1: Hook the Deserializer & Dump the Prototype Tree
Open luraph_variant_01.luau, navigate to the wrapper function s8, and insert the global capture hook right before return root:
-- Inside wrapper function s8: local root = z:m8(H, o, _); getgenv().__LURAPH_CAPTURED = root; error("BOUNDARY_CAPTURED", 0); return root;Run the hooked script in your Luau runtime along with
luraph_dumper.luau. This yields the complete 11-column SoA table for all 37 prototypes as JSON.
Step 2: Discard Protos 1 & 2 (Neutralizing Anti-Tamper)
Do not execute or emulate Proto 1 (bootstrap) or Proto 2 (the 4,943-instruction [4,484 reachable] Roblox/UNC integrity check). Retain only Protos 3 through 37. Because these application prototypes have zero upvalue bindings to Proto 2, the anti-tamper harness is statically neutralized.
Step 3: Linear Instruction Decoding with Continuation Law
For each prototype (from 3 to 37), iterate through the opcode stream in column p[7]. Apply the branch and dispatch tables from Section 11:
- When an unconditional jump (Opcode 156, 17, 132, 148) is encountered, set
next_PC = target + 1. This dissolves the 10-hop decoy trampoline loop into straight-line application code. - When a conditional branch (Opcode 111, 28, 51, 25, 41, 66, 100, 114, 141, 7, 65, 94) is encountered, branch to
target + 1on true andPC + 1on false (or vice versa according to the condition decode table). - Translate arithmetic and table operations directly into Lua registers using the opcode map (11 for ADD, 43 for SUB, 53 for MUL, 83 for DIV, 110 for TGET, 145 for TSET, 178 for GETGLOBAL).
Step 4: Resolve Lexical Upvalues via p[6]
For every prototype, inspect the capture descriptors in column p[6]:
- Mode 0: Bind the child prototype's upvalue to the enclosing parent function's local register
v[index]. - Mode 1: Bind the child prototype's upvalue to the parent's forwarded upvalue slot
upval[index].
state variable in Proto 13).
Step 5: Assemble and Run the Entry Prototype
Assemble the lifted functions into a prototype dispatch table protos = {}. At closure creation sites (Opcode 107 and 216), instantiate child prototypes:
v[dst] = function(...) return protos[child_proto_id](...) endFinally, invoke the root application prototype at the bottom of the script:
protos[3]()Run the assembled output in any standard Lua/Luau runtime. It will execute with exit code 0, cleanly reproducing all application functionality: the event logger, the collatz-style accumulator loop, the stateful closure proxy, coroutine yield/resume cycles, and pcall/xpcall handlers.
20. Conclusion & Verifiable Research Artifacts
Modern VM obfuscation is intimidating because of layered illusions: huge string buffers, 4,500-instruction anti-tamper harnesses, and mutating dispatch tables. But as demonstrated in this walkthrough, once you understand the underlying Structure-of-Arrays architecture and intercept the prototype forest at the deserializer seam, the virtual machine collapses into standard compiler blocks.
All analysis logs, handlers, scripts, and deliverables are hosted and downloadable directly from this article:
artifacts/luraph_variant_01.luau(154 KB) — Original obfuscated target script.artifacts/dump_variant_01.luau(159 KB) — Lua boundary dumper script.artifacts/k_table_variant_01.json(3.1 KB) — Recovered constant pool dictionary.artifacts/prototype_graph_variant_01.txt(930 KB) — Full prototype graph dump across all 37 prototypes.artifacts/opcode_handlers_stable_variant_01.txt(226 KB) — Symbolic AST opcode handlers.artifacts/vm_execution_trace_variant_01.txt(1.7 KB) — Instruction-by-instruction execution trace.artifacts/luraph_variant_01-raw.lua(72 KB) — Deliverable 1: Authentic raw AST lift.artifacts/luraph_variant_01-decompiled.lua(71.5 KB) — Deliverable 2: Coalesced register decompilation.artifacts/luraph_variant_01-clean.lua(3.0 KB) — Deliverable 3: AI cleaned semantic Luau.
