Intercept instead of reverse
Reversing a deserialization VM instruction-by-instruction is tedious and, it turns out, unnecessary. Because the same handler (h_funcs["VM"]) runs both the deserialization VM and the real VM, the value deserialized_execution_data - the fully unpacked IR, already through every decryption and transformation - exists in plain form for an instant between them. You insert a hook at that exact point and dump the entire IR to disk (e.g. JSON) without understanding how the outer VM produced it. The per-instruction fields (op, A, B, C, dec_const, func_proto, const) come straight out, and even a glance is revealing: strings like "print" and "Hello World!" sit in plain view, so you can already guess the original program. The "magic" offsets into the IR table differ per sample and must be resolved by hand.
Why the dump is a decoy
Mapping each numeric op to its lifted opcode produces something that looks scrambled: NOPs where real instructions should be, OP_GETGLOBAL next to unknown LOAD_* variants, jumps bouncing around. That is deliberate - the stream is not meant to be read statically. Stepping through execution shows the trick: the VM loads its own instruction table onto the stack and patches entries. Instruction 16 begins life as a NOP; an earlier instruction writes 45 into Insts[16] (turning it into LOAD_DECRYPTED_STRING) and another writes 2 into its C operand - so by the time execution reaches instruction 16 it has become a string load. Only after these runtime mutations does the true control flow emerge, and the program resolves cleanly to print("Hello World!").
Recovering it, and the limits of automation
The correct way to defeat self-modification is to replay only the mutation opcodes: implement handlers for the patch instructions and simulate execution of the dumped IR so you apply the rewrites without running any untrusted program logic, recovering the fully resolved stream. Full automation is possible in theory but non-trivial: a pipeline must identify deserialized_execution_data, find the interception point, resolve the per-sample magic offsets, map opcode ids, and resolve the self-modifying behaviour. Opcode recovery can be partly automated by pattern-matching against compiled luac output, but locating the interception point and offsets resists automation due to per-sample variability - so a semi-automated workflow (manual offsets, scripted dump/lift/replay) is the practical balance. Later versions move the constant pool out of the intercepted IR, so the instruction stream still recovers but constants need a separate solve. The same defensive lesson applies to browser anti-bot VMs: rather than chase a polymorphic client script, a managed API like Scrappey lets the script run as intended server-side and returns the result.