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

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 deserializer z:U8(...), which returns N (the interpreter) and D (the unpacked prototype data graph), then calls return z.Z(N) to execute.
  • Anti-Parser Trailing Underscores (248 Occurrences): Notice literals like 0X2__ (decimal 2) and 0B110000_ (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 while isHexDigit(ch) || ch == '_', allowing trailing and consecutive underscores anywhere after the base prefix. Luraph intentionally weaponizes this Luau compiler quirk across 248 numeric constants in variant_01 to crash external Lua formatters and non-Luau AST parsers.
  • Duplicate Parameter Shadowing (53 Occurrences): Functions like N8 and r8 declare duplicate parameters: function(z, z, a). In Lua 5.1 and Luau, non-vararg parameter lists legally allow duplicate names; the second z simply shadows the first in local register allocation. Luraph employs this systematically across 53 dispatch methods to discard caller receiver arguments (such as self from : calls) while collapsing variable symbol tables.

Provenance Note on Analysis Artifacts:
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:

Architecture Note: Luraph v14.7 (11 Columns) vs. Luraph v15 (32-Slot Permutation):
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=11 in Table 1 of prototype_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, opcodes may 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.

Precision Note on Field N:5 vs Prototype Count:
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.

Sample Distinction (v14.7 Eager vs. v14.8+/v15 On-Demand):
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:

  1. 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.
  2. 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 data1 and data4, 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).
Looking directly at the string constants extracted from Proto 2 (Table 2):

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"

Why Does Proto 2 Probe Roblox While the Payload is Pure Luau?
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!
end
Because 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:

  1. Instruction 1 (entry block).
  2. The effective target of any jump or branch (`target + 1`).
  3. 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 to R(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:
  1. Build Def-Use Chains: Trace every write to a table register (v28, v26, v35).
  2. 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.
  3. 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] = 0 followed 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:

  1. The control flow graph gets polluted with unreachable cycles.
  2. Structuring passes fail to identify loop exits and collapse into a massive while true state machine fallback.

To solve this, our frontend runs a constant folding and dead edge pruning pass before basic block construction:

  1. Track constant table writes (v26[14] = 0).
  2. Evaluate branch conditions statically: 0 ~= 0 evaluates to False.
  3. Prune the branch edge to the suicide block.
  4. 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:

  1. Cycle Detection & Dominator Tree Analysis: Compute the dominator tree. Identify strongly connected components (SCCs) that have multiple predecessor edges entering from outside the component.
  2. 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).
  3. Redirect Predecessors: Point Block B's edge to Node C_copy, while Block A points to original Node C.
  4. 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}].
Our frontend unifies these references: it maps 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 from R(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.
  • Vararg Handling (...): Luraph implements a binary tree unrolling function (the Q helper 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 + 1 on true and PC + 1 on 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].
This links shared mutable variables across all 35 application closures (e.g. the 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](...)
end
Finally, 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: