Skip to main content
Lua source code is always compiled to bytecode before execution. The Lua compiler (luac) and the runtime load() function both produce bytecode, which is a compact binary representation of your script. Because bytecode skips the source-level analysis step entirely, it is a natural target for obfuscation. An analyst who obtains your bytecode must first disassemble or decompile it before they can read the logic, and you can make that process significantly harder by stripping debug information and mutating the bytecode structure itself. This page covers the Lua bytecode format, how to compile and load bytecode at runtime, how to strip debug metadata, and how to apply bytecode-level mutations that confuse standard disassemblers.

The Lua Bytecode Format

Standard Lua bytecode files (.luac) begin with a fixed header followed by one or more function prototypes. The header identifies the file as Lua bytecode and specifies the Lua version that produced it.

Standard Lua Bytecode Header

The header is always present at offset 0 and contains the following fields: After the header comes the top-level function prototype, which contains:
  • Instruction list: the actual VM instructions (32-bit fixed-size in most versions)
  • Constants table: numeric, string, and boolean literals used by the code
  • Upvalue list: upvalue descriptors
  • Debug information: line number table, local variable names, upvalue names
  • Nested function prototypes: each function in the file is a prototype

Lua Version Differences

Lua 5.1, 5.2, 5.3, and 5.4 each use slightly different bytecode formats. The version byte in the header is the primary discriminator:
Lua 5.2 introduced a new instruction format with different opcode assignments. Lua 5.3 added integer subtypes and new instructions for integer operations. Lua 5.4 expanded the opcode set to 83 opcodes and changed the way constants are loaded. Cross-version bytecode is never compatible.

LuaJIT Bytecode

LuaJIT uses a completely different bytecode format (.ljc files). The header begins with 0x1B 0x4C 0x4A 0x02 (“\x1BLJ\x02”), and the instruction encoding is entirely different from standard Lua. LuaJIT bytecode is also not portable across architectures (32-bit vs 64-bit) or endianness.

Luau Bytecode

Luau (Roblox’s fork of Lua 5.1) uses a proprietary bytecode format that is not publicly documented. Roblox compiles Luau scripts server-side before sending them to clients. As a Roblox developer, you cannot access or deliver raw Luau bytecode to clients. The bytecode is internal to the Roblox engine and never exposed to developers.

Compiling to Bytecode

You can produce bytecode using the command-line compiler or at runtime from within a running Lua script.

Command-Line Compilation

The standard Lua compiler luac produces bytecode files:
The output file contains the magic bytes 0x1B 0x4C 0x75 0x61 at offset 0. You can verify this with xxd or a hex editor:

Runtime Compilation with string.dump

Inside a running Lua script, string.dump(f) returns the bytecode of a function f as a binary string:
The second argument to string.dump controls debug stripping (see the Stripping Debug Information section).

Loading Bytecode at Runtime

Once you have bytecode as a string, you can load and execute it at runtime using load (Lua 5.2+) or loadstring (Lua 5.1 compatibility):

Embedding Bytecode as a String Literal

Raw bytecode contains non-printable bytes, so you need to encode it before embedding it in a Lua source file. The two common approaches are escape encoding and base64 encoding.

Escape Encoding with string.format

Base64 Encoding

Complete Loader Example

Here is a complete example that reads a Lua file, compiles it to bytecode, base64-encodes it, and generates a self-contained loader script:

Stripping Debug Information

Debug information embedded in bytecode is a goldmine for reverse engineers. It includes local variable names, upvalue names, and line number mappings. Stripping this information makes decompiled output far less readable.

What Debug Information Contains

  • Line number table: maps each instruction back to a source line number
  • Local variable list: names and scopes of all local variables
  • Upvalue names: names of upvalues referenced by the function

Stripping with luac

Use the -s flag to strip debug information at compile time:

Stripping at Runtime

Pass true as the second argument to string.dump:

What Gets Removed vs. What Remains

When you strip debug information:
  • Removed: local variable names (replaced with ”?” in most decompilers), upvalue names, line number table
  • Remains: instruction opcodes, constants table, function structure, upvalue count
The bytecode is still fully executable and still analyzable, but the analyst loses the semantic names that make decompiled code readable. A decompiler output might look like this after stripping:

Hex Dump Comparison

In an unstripped bytecode file, the debug section contains the actual local names and line numbers. In a stripped file, these sections are present in the structure but contain zero-length or default values. A hex dump of the stripped file shows the debug section with empty strings and zeroed line number entries.

Bytecode Mutation Techniques

Beyond stripping, you can mutate the bytecode itself to confuse disassemblers that rely on standard patterns.

Constant Table Scrambling

The constants table stores all literals used by the code. You can reorder the constants and update every LOADK instruction to reference the new indices:

NOP Instruction Insertion

Insert no-op instructions between real instructions. A safe NOP in Lua 5.1 is OP_MOVE where the source and destination registers are the same:
Disassemblers that rely on instruction counting or pattern matching will be confused by the extra instructions, though a good disassembler will simply show them as redundant moves.

Fake Debug Names

Before stripping debug information, replace local and upvalue names with misleading values:
When a debugger or partial decompiler shows these names, they appear to be control flow keywords, causing confusion.

Instruction Operand Obfuscation

Many Lua instructions have unused operand fields. For example, in Lua 5.1, OP_MOVE only uses the A and B fields; the C field is unused. You can fill unused fields with random values:
Standard VMs ignore the unused bits, but disassemblers may display them and analysts may waste time analyzing phantom operands.

Protecting the Bytecode Loader

The loader script that decodes and executes bytecode is itself a target for analysis. If an analyst can find the load() call, they can hook it to intercept the decoded bytecode.

Minimal Obfuscated Loader

Here is a loader that decodes base64, applies a simple XOR, and then calls load():
Modifying bytecode instruction operands without full understanding of the VM specification causes crashes. Always test mutated bytecode on all target Lua versions before deploying.
Luau (Roblox) developers cannot access or deliver raw bytecode to clients. Roblox compiles Luau server-side, and the bytecode is internal to the engine. The techniques on this page apply to standalone Lua and LuaJIT deployments.
Several tools can reverse standard Lua bytecode, which is why bytecode alone is insufficient protection:
  • ChunkSpy: A Lua 5.1 bytecode disassembler with detailed annotation
  • luadec: A Lua 5.1 and 5.2 decompiler that reconstructs source code from bytecode
  • unluac: A decompiler supporting all Lua versions from 5.0 through 5.4
  • LuaJIT -bl: The built-in bytecode listing tool for LuaJIT
Because these tools exist, bytecode obfuscation should always be combined with other techniques such as VM obfuscation or opcode remapping.