Lua
disrobe decompiles compiled Lua chunks across every common dialect, peels the major Lua obfuscators, and devirtualizes custom-VM wrappers back to runnable Lua source.
At a glance
| Layer | Coverage |
|---|---|
| Dialects | Lua 5.1 / 5.2 / 5.3 / 5.4, LuaJIT 2.0 / 2.1, Luau, GLua |
| Luau opcode coverage | 86 of 88 opcodes in disrobe's declared table are lifted, with BREAK debugger instrumentation and NEWCLASSMEMBER decoded and reported rather than lifted; child-proto linking |
| Decompile output | Lua source plus a manifest recording the detected dialect, fidelity grade (Lossless / Lossy / BestEffort), and any warnings |
| Obfuscators (14) | IronBrew2, Prometheus, MoonSec V1 / V2 / V3, AztupBrew, DarkSec, Boronide, PSU, WeAreDevs, luaobfuscator.com, SLua, Hercules, Luraph |
| Chain catalog | 16 entries: the 14 obfuscator families above plus the Luau and GLua dialect detectors |
Peelers (--family) | prometheus, moonsec-v1, moonsec-v2, moonsec-v3, ironbrew2, wearedevs, slua; default auto detects first |
| VM devirtualization | IronBrew2 2.7.0 reversed on real committed output, graded by real-Lua execution differential (hello / arith / control / tables / edge in standard and MAX mode); MoonSec-shape recovery is pending a real sample |
Prometheus Vmify | Container dispatch tree lifted back to structured Lua, including a closure that captures a variable; up to four stacked Vmify layers unwrapped in one call; graded by real-Lua 5.1 execution differential against the original source |
Commands
disrobe lua decompile script.luac --out script.lua
disrobe lua detect script.luac
disrobe lua deobfuscate obfuscated.lua --out clean.lua
disrobe lua deobfuscate vmified.lua --family prometheus --out clean.lua
disrobe lua deobfuscate dumped.lua --family moonsec-v3 --i-have-authorization
disrobe lua opcode-map --canonical vanilla.luau --client client.luau --build-id 2026-08-24 --out 2026-08-24.json
disrobe lua decompile client.luau --opcode-map 2026-08-24.json --build-id 2026-08-24 --out recovered.lua
Build-keyed Luau opcode maps
lua opcode-map imports only observations proven by a supplied canonical/client pair. The pair must use the same supported bytecode version, have the same prototype tree and instruction counts, and retain every non-opcode and auxiliary instruction word. The map records the explicit client build identifier and bytecode version. lua decompile --opcode-map selects it only when both values match.
The importer accepts a conflict-free bijection for the opcode bytes it observes. It does not infer the remaining permutation. A missing entry causes decompilation to refuse at the instruction location; an incompatible, stale, or malformed map is not substituted with another build's map.
Opcode availability is validated against Luau's bytecode history at 367f9d83: version 3 adds FORGPREP and JUMPXEQK*, version 4 adds IDIV/IDIVK, version 5 adds SUBRK/DIVRK, version 6 adds FASTCALL3, version 9 adds userdata field access, version 10 adds NEWCLASSMEMBER, and version 11 adds CALLFB.
decompile writes the recovered source (default ./out/<stem>.lua) and a manifest.json recording the format, fidelity grade, and warnings. detect reports the dialect and header field summary (constant, proto, and code counts) without writing output. MoonSec v3 and IronBrew2 are commercial-tier wrappers; their peelers require the explicit --i-have-authorization flag.
Recovered Lua 5.x source wraps the main chunk in local function _main(...) and ends with return _main(...), so the file runs unchanged. Short-circuit conditions such as while a and (b or c) do, c and x or y value selection, elseif ladders with compound arms, and local x declarations that Lua 5.1 compiles to nothing are rebuilt as source. The re-execution grade compiles self-authored programs with luac -s (no debug names), decompiles the stripped bytecode, and requires the recovered file to print exactly what the original prints under the same interpreter and to create no global the original does not create (tests/reexec_diff_oracle.rs, Lua 5.1, 5.3 and 5.4 lanes). tests/generated_differential.rs applies the same check to seeded, generated programs on Lua 5.1 and 5.4.
Stripped bytecode carries no local names, so recovered locals are named from their register (v3) or role: numeric loop variables become i, j, k and generic loop variables k and v, unless a global or upvalue of that name is visible anywhere in the function, in which case the register name is kept. A temporary reused after its block ends is declared again with local rather than assigned as a global. A value that is read after its register is overwritten is kept in a local before the overwrite. while true do ... end loops left by break, and loops whose head runs statements before the test, are rebuilt as structured loops. Values selected by and/or chains, including (a or b) and c and chains whose operands are calls, are rebuilt as one expression or as a local assigned on every path; for a chain whose operands are calls, the Lua 5.1 and 5.3 output still contains a goto, which the Lua 5.1 parser rejects, so corpus/lua/behaviour/values.lua is graded on the Lua 5.4 lane only.
--family pins one peeler instead of letting auto choose. Auto-detection tries Prometheus first, so a Vmify container needs no flag. Pass --family prometheus when you want a file carrying no Prometheus signature to fail rather than fall through to another family.
Output shapes below are illustrative.
lua decompile: OK
input: script.luac
format: Lua54
fidelity: Lossless
warnings: 0
wrote: ./out/script.lua
manifest: ./out/script.manifest.json
lua deobfuscate: OK
family: Auto
detected: Prometheus (confidence=0.97)
passes run: 3
- string_decode
- bytecode_unwrap
- emit
recovered: 12 string(s)
fully peeled: true
residual: 0
wrote: ./out/obfuscated.peeled.lua
The deobfuscate report lists every pass that ran, recovered string constants, a fully peeled verdict, and any residual markers.
Coverage and fidelity
IronBrew2 and MoonSec ship their payload behind a custom register-VM: a permuted opcode-handler table and an embedded constant pool that a stock decompiler cannot read. The permutation is not stored in the loader; it is computed at load time inside the obfuscated bootstrap, then used to dispatch handlers and key the constant decryptor. disrobe reconstructs it the same way the loader does.
For IronBrew2 2.7.0, the devirtualizer parses the bootstrap's dispatch chain to derive the encoded -> canonical permutation and the XOR key, decodes the constant pool, and lifts the VM bytecode back to runnable Lua. The committed corpus/lua/ironbrew2 set carries real obfuscated bootstraps for five programs in both standard and MAX mode; each must produce byte-identical output to the original under a real lua binary (tests/ironbrew2_real_oracle.rs).
MAX mode adds three layers on top of standard: a control-flow-flattened dispatch (a nested binary search over the opcode enum, which the same handler walker un-flattens), comparison-polarity number-mutation (the EQ handler tests ~= and jumps on equality, captured as the literal operator plus jump direction), and fused super-operator handlers (one VM step covering several real ops, whose hoisted scratch locals are stripped after classification).
Prometheus Vmify
The Prometheus Vmify step rewrites a chunk into a register machine held inside one container function. Every original function becomes a numbered entry point in a shared dispatch tree, and every call becomes a jump through that tree. disrobe reads the container, rebuilds a control-flow graph for each entry point, and re-emits structured Lua. The pass appears as prometheus-vmify-container-devirt in the passes run list. Vmify applied over its own output is unwrapped layer by layer, up to four layers in one call. A chunk that needed more than one layer also carries a prometheus-vmify-nested-devirt-<n>-layers entry naming the layer count.
Vmify does not keep a captured variable as a Lua upvalue. It moves the variable into a store table shared by the whole chunk, hands each closure a list of slot indices into that store, and uses a reference count to decide when a slot is cleared. disrobe fingerprints the allocator and the release helper by their statement shape, which is how it tells the capture store apart from any other table in the chunk. It then gives each allocation one Lua local, named __vu0, __vu1, and so on in allocation order, and declares it once in the scope that allocates it. Every read and write of that slot is rewritten to the same local, both in the allocating function and in each closure that receives the slot, so the recovered closure captures the variable by reference the way the original source did.
The committed corpus/lua/prometheus/vmify_upvalue pair holds real Prometheus Vmify output for a counter factory whose inner function captures a local. The gate first requires that no Prometheus layer is left in the recovered source, then runs it under a real lua 5.1 binary and requires the output the clean file prints (tests/reexec_diff_oracle.rs). The obfuscated file itself is never run.
The Prometheus Weak preset (Vmify, ConstantArray and WrapInFunction) is graded on the Lua megafile. All 171 functions in its dispatch tree come back structured with no dispatch fallback, and 630 of the 630 reached handlers are lifted. The recovered program then runs under real Lua 5.1 and 5.4 against a driver that calls every export, and it must print the same 116 lines as the clean source (tests/real_prometheus_megafile.rs). A mutation that drops trailing nil results from calls turns that grade red.
Set DISROBE_DEBUG=lua to trace the capture analysis. It emits prometheus_vmify.box_model, naming the registers the capture helpers resolved to, and prometheus_vmify.captured_variables, the number of captured variables bound in the run.
The Hercules real-output grader covers 15 pinned outputs from Hercules 2.0.1. Fourteen recover to source that prints the same bytes as the authored programs under Lua 5.4. The remaining branches.maximum fixture reports its unrecovered VMGenerator layer. Each recovered case also has a changed-literal control that must differ from the original.
Limits
- Where
fully peeledisfalsethe report carries the residual marker names and the reason (runtime key, anti-tamper variant, unmodeled VM tier, or a refused Vmify recovery). - When a MoonSec v3 constant-pool key is available only at runtime,
disrobereturnsfully_recovered: falsewith aruntime keysresidual marker. - The MoonSec
emulate_perm_buildertests use a purpose-built bootstrap whose permutation is derived at runtime. These tests cover the table-builder algorithm; the corpus contains no captured MoonSec dump to establish end-to-end recovery.
Prometheus Vmify refusals
Vmify recovery refuses unsupported shapes instead of substituting placeholder functions for missing bodies. A refusal names its cause, and where the cause is a specific expression it names the byte offset of that expression in the text the layer was reading. The execution comparison below covers the committed Lua 5.1 Vmify fixtures.
A refusal takes one of two shapes.
- The whole container is refused.
fully peeledisfalse,prometheus-vmify-container-devirtis absent frompasses run, the output file holds the earlier peel stage rather than a devirtualized body, and a residual marker readsa Vmify container was found but recovery was refused rather than emitted partly wrong, followed by the reason. - Some layers recovered and a further layer is refused. The recovered source is written,
fully peeledisfalse, and a residual marker records how many layers recovered before the refusal, followed by the reason.
disrobe refuses these captured-variable shapes.
- A capture allocated on a cycle in the recovered control-flow graph, which is what a capture written inside a source loop becomes. Each iteration of such a loop captures a fresh variable, and one declaration at function scope would alias them into a single shared variable. The committed
corpus/lua/prometheus/vmify_loop_capturepair is a loop that builds three closures over three different values, anddisroberefuses it. The cycle search runs under a step budget, anddisroberefuses when the search exceeds it. - A chunk that creates a closure with a non-empty capture list, but whose reference-counted capture helpers do not match the shape
disrobefingerprints. The fingerprint accepts exactly one allocator and exactly one store bound to that allocator's reference-count table, so a chunk carrying more than one of either yields no model and no captured variable can be resolved to a real Lua variable. A chunk that captures nothing needs no model and is unaffected. - A register that receives a second capture inside one recovered function, because the register cannot then carry one stable name.
- A capture allocator referenced anywhere other than a plain allocation assignment.
- A store slot read or written through anything other than a register the same function allocated a capture into, or an entry of the closure's own capture list indexed by a positive whole-number literal. An index past the end of the supplied capture list is also refused.
- A closure-creation call whose capture list is not a table constructor, or whose capture list carries a keyed entry. A keyed entry has no stable position to match against the closure body.
- A closure-creation call whose entry point is not a literal number, because the closure body cannot then be located in the dispatch tree.
- One dispatch leaf shared by two closures with different captured variables, which would bind one expression to two different recovered values.
- A recovered body that still names the container's own capture machinery, meaning the capture table, the store, the allocator, or a slot register. At least one access went unresolved in that case, so the body is refused rather than emitted.
Recovery is graded against Prometheus output built for Lua 5.1 with Vmify as the only step. Other target dialects and other step combinations are not graded.