Reverse engineering
How Lua VM obfuscators work, and how MoonSec comes apart
MoonSec does not hide your Lua script, it replaces it with a virtual machine. Here is why that is the strongest commercial obfuscation available, the structural weakness every VM obfuscator shares, and the four-stage method for recovering readable source from v1, v2 and v3.
Stijn van den Dool · 14 min read · 1 September 2026
An obfuscator is not trying to make your code unreadable. It is trying to make reading it cost more than whatever the code is protecting. That distinction matters, because it tells you what success looks like from both sides. The vendor wins by raising the price. The reverse engineer wins by finding the one structural fact that collapses it.
MoonSec is the most widely used commercial obfuscator in the Lua ecosystem, and it is genuinely good. It does not rename your variables or encrypt your strings and hand the result back. It throws your program away and gives you a different one.
What MoonSec actually does
Your script goes in. What comes out is a table of integers and an interpreter that consumes them. The integers are bytecode for an instruction set that MoonSec invented, and the interpreter is a Lua function that walks that bytecode and performs the corresponding operations on a stack.
MoonSec compiles your script into a custom virtual machine: constants encrypted, instructions shuffled into an opaque dispatch loop, control flow rebuilt so nothing maps back to the source. v3 hardened all of it and added anti-tamper. There was no tool for the last one, so it was done by reading the interpreter until the interpreter explained itself.
Three separate mechanisms are stacked here, and it is worth naming them apart because they fail apart. The constants are encrypted with a key derived per build. The instruction encoding is shuffled, so the numeric value of an opcode carries no meaning across builds. And the control flow of the original program has been dissolved into a dispatch loop, so there is no branch in the output that corresponds to a branch in the input.
Why a virtual machine is the strong move
Almost all obfuscation is a renaming exercise wearing a hat. Identifiers become v0 and v1. Strings get encrypted and decoded at use. Control flow is flattened into a state machine. Every one of those is individually reversible, and the reason is the same in each case: the structure of your program survives the transformation. Undo the transformation and your program is back.
A virtual machine does not transform the structure. It deletes it. There is no function named validateLicence that has been renamed to v7, because there are no functions. There is one loop and a table of numbers. A decompiler pointed at the output recovers the interpreter perfectly and learns nothing, because the interpreter is not the program you were looking for.
This is well established rather than novel. Rolf Rolles set out the general approach to attacking virtualisation obfuscators in Unpacking Virtualization Obfuscators at WOOT in 2009, and the shape of the problem has not changed since. What changes per target is the amount of work, and Lua adds a specific wrinkle worth understanding.
Lua is already a register-based virtual machine. Its own design is documented in The Implementation of Lua 5.0, and the reference for the dialect most of this work targets is the Lua 5.1 manual. So a Lua VM obfuscator is a virtual machine implemented on top of a virtual machine, in the interpreted language that virtual machine executes. That nesting is what makes the output so unpleasant to read, and it is also why the whole thing has to be shipped in the clear.
The weakness every VM obfuscator shares
The interpreter cannot be protected. Not because MoonSec did something careless, but because of what an interpreter is. It has to run. If it were encrypted, something would have to decrypt it, and that something would have to run. The recursion terminates in plaintext code, every time, on the attacker's machine.
So every semantic in the protected program is described, exactly and completely, by code you are holding. The bytecode is meaningless without the interpreter, and the interpreter is a full specification of what the bytecode means. You have been given the dictionary and told the book is in a language nobody speaks.
Everything below follows from that single fact. It is also why no future version of MoonSec, or of any competitor, changes the outcome. A new version changes the price.
The four stages
Every generation came apart in the same order. Not because the tool repeated itself, but because this is the dependency order. Each stage produces exactly the thing the next stage needs, and attempting them out of order is how people conclude it cannot be done.
- 01
Unwrap the loader
Strip the outer packer to reach the bytecode blob and the VM that consumes it.
- 02
Recover the instruction set
Watch the dispatch loop long enough to name every opcode by what it does to the stack.
- 03
Decrypt the constant pool
Strings and numbers are keyed per build, so the pool comes back only once the VM's own decoder is understood.
- 04
Rebuild control flow
Turn the flattened dispatch back into loops and branches, then into Lua a person can read and reason about.
The first pass through those four stages, for a given version, is expensive. The second is fast, and the tenth is close to mechanical, because the instruction mapping you built the first time applies to every subsequent build the same version produces. This is precisely why obfuscator vendors ship new major versions rather than new keys. Keys protect one build. A new instruction set protects the whole product, until somebody sits down with it.
Reading the dispatch loop
Here is the shape, reduced to something short enough to read on a page. This is an illustrative sketch written for this article rather than a real payload. A genuine one runs to thousands of lines of exactly this:
local v0=("\120\112\99\97\108\108"):sub(1,6)
local v1={[0]=0x4C,0x75,0x61,0x51,0x00,0x01,0x04}
local v2=function(a,b) return(a+b)%256 end
local v3,v4=1,{}
while true do
local op=v1[v3]
if op==nil then break end
if op==0x4C then v4[#v4+1]=v2(op,v3)
elseif op==0x75 then v3=v3+1
elseif op==0x61 then v4[#v4]=v4[#v4] ~ 0x37
else v3=v3+1 end
v3=v3+1
end
return (loadstring or load)(string.char(unpack(v4)))()Four things are already legible before anything has been decrypted. There is a numeric table, so the program is data. There is a while over an opcode with one branch per value, so it is a dispatch loop and each branch is one instruction. There is an arithmetic transform applied on the way in, so the constants are keyed rather than stored plainly. And the tail hands a reconstructed string to loadstring, so the final act is to compile and run recovered source.
That is enough to begin naming the instruction set, and the naming is the part people get wrong. You are not trying to recover MoonSec's names for its opcodes. Those are irrelevant. You are assigning your own names based on observed effect: this branch pops two values and pushes their sum, that one moves the instruction pointer conditionally. An opcode is fully specified by what it does to the machine state, and once you have a consistent name for each one you have a disassembler.
At that point the bytecode stops being a table of integers and becomes a listing. It is still not readable Lua. It is now a program in an assembly language you designed, which is a completely different category of problem.
The constant pool is the payoff
Strings and numbers are encrypted per build. This sounds like the hard part and is actually the easiest, for the same reason as everything else: the key has to be present. The virtual machine decrypts at runtime with nothing available to it except what it shipped with, so the decoder is sitting in the interpreter you have just finished reading.
The pool therefore does not get attacked, it gets asked. You run the same routine the VM runs and every constant in the build comes back. It is worth being precise about why this matters so much. Opcodes give you the grammar of the program. Constants give you its vocabulary, and vocabulary is where intent lives. A recovered error message, endpoint, or field name is frequently the entire answer to what a script was for, before you have reconstructed a single line of control flow.
In practice this is the moment the work stops feeling like puzzle solving and starts feeling like reading. The listing has names in it.
v3, and why anti-tamper is a hint
v3 hardened all of the above and added anti-tamper: checks that the interpreter has not been modified, that it is not being traced, that the environment is what it expects. There was no existing tool for that generation, so it was done by reading the interpreter until the interpreter explained itself.
The useful reframe is that anti-tamper is a signal rather than an obstacle. Every check is a statement about what the author was afraid of, and the author knew exactly where their design was weak. Code that verifies its own bytecode is telling you that patching the bytecode works. Code that looks for a debugger is telling you that stepping the dispatch loop is productive. Code that checks a timing threshold is telling you the alternative is instrumentation.
The defence documents the attack it was built to stop, and it has to do so in code you can read. When I got stuck on v3, the checks were the map.
What this taught me about anti-cheat
This is not a piracy service and it is not a party trick. The reason to take a commercial obfuscator apart is that it teaches you what client-side protection is genuinely worth, and you have to have an honest answer to that before you build any.
Every layer in MoonSec falls to one structural fact: the thing that interprets the secret cannot itself be secret. Generalise it and you get the rule that matters for game security. Any check running on hardware the adversary owns is a claim made by software they control. It can be found. It can be removed. Obfuscation changes how long that takes and nothing else.
Which is not an argument against obfuscation. Raising the cost is real value, and most attackers are not patient. It is an argument against building a design that depends on the client keeping a secret, because that design has already failed and is only waiting to find out. Put authority on a server the player cannot modify, and treat everything the client says as a claim to be checked rather than a fact to be trusted.
Reading MoonSec is how you stop finding that surprising, and it is directly why the anti-cheat work I do now looks the way it does.
Common questions
- Can MoonSec be deobfuscated?
- Yes. Every generation of MoonSec can be recovered to readable Lua, because the interpreter that executes the protected program has to ship in plaintext alongside it. The obfuscation raises the cost of reading the code, it does not make reading it impossible. MoonSec v3 raises that cost the most by hardening the constant encryption and adding anti-tamper checks, and it still comes apart in the same four stages as v1 and v2.
- What is virtual machine obfuscation?
- Virtual machine obfuscation compiles a program into bytecode for a custom instruction set, then ships that bytecode together with an interpreter that executes it. Instead of transforming your code into a harder-to-read version of itself, it replaces your code with a different program that computes the same result. The control flow a reader sees belongs to the interpreter rather than to the original program, so ordinary decompilation recovers the virtual machine and tells you nothing about what it is running.
- Why is a virtual machine stronger than renaming and string encryption?
- Renaming, string encryption and control-flow flattening all preserve the structure of the original program, so each one is individually reversible. Rename the identifiers back and the program is legible again. A virtual machine removes the structure entirely: there is no renamed function to rename back, only a dispatch loop and a table of integers. Recovery therefore requires reconstructing the semantics of an instruction set rather than undoing a transformation.
- How long does it take to reverse a Lua VM obfuscator?
- The first build of a given obfuscator version is the expensive one, because the instruction set has to be named from scratch by observing what each dispatch branch does to the stack. Once that mapping exists it applies to every future build produced by the same version, and recovery becomes close to mechanical. This is why obfuscator vendors ship new major versions rather than new keys.
- Is client-side obfuscation useful for anti-cheat?
- It buys time, not security. Any check that runs on hardware the adversary controls is a claim made by software they own, and obfuscation only changes how long it takes them to find and remove it. Obfuscation is worth using to raise the cost of trivial attacks, but a design that depends on the client keeping a secret has already failed. Authoritative decisions belong on a server the player cannot modify.
Sources
- The Implementation of Lua 5.0 · Roberto Ierusalimschy, Luiz Henrique de Figueiredo, Waldemar Celes
- Lua 5.1 Reference Manual · Roberto Ierusalimschy, Luiz Henrique de Figueiredo, Waldemar Celes
- Unpacking Virtualization Obfuscators (WOOT '09) · Rolf Rolles
Written by Stijn van den Dool, who works on anti-cheat and game security. The interactive version of this, with a scrubber between protected input and recovered output, is on the home page.