> ## Documentation Index
> Fetch the complete documentation index at: https://starcryptdocs.starsolenterprise.com/lua-docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Lua Bytecode Manipulation

> Compile Lua scripts to bytecode and apply direct manipulation techniques to obscure the original source and resist decompilation.

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:

| Offset | Size | Field | Description |
| - | - | - | - |
| 0 | 4 bytes | Magic | `0x1B 0x4C 0x75 0x61` (the string "\x1BLua") |
| 4 | 1 byte | Version | Lua version: `0x51` for 5.1, `0x52` for 5.2, `0x53` for 5.3, `0x54` for 5.4 |
| 5 | 1 byte | Format | Official format is always `0` |
| 6 | 6 bytes | Endianness, int size, size\_t size, Instruction size, lua\_Number size, integral flag | Platform-dependent |

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 theme={"theme":{"light":"ayu-dark","dark":"night-owl"}}
-- Detect Lua bytecode version from a file
local function detect_version(filename)
    local f = io.open(filename, "rb")
    local magic = f:read(4)
    local version = f:read(1)
    f:close()

    local versions = {
        [0x51] = "Lua 5.1",
        [0x52] = "Lua 5.2",
        [0x53] = "Lua 5.3",
        [0x54] = "Lua 5.4",
    }

    return versions[string.byte(version)] or "Unknown"
end
```

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.

```bash theme={"theme":{"light":"ayu-dark","dark":"night-owl"}}
# Compile to LuaJIT bytecode
luajit -b input.lua output.ljc

# View a human-readable bytecode listing
luajit -bl input.lua
```

### 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:

```bash theme={"theme":{"light":"ayu-dark","dark":"night-owl"}}
# Compile a Lua script to bytecode
luac -o output.luac input.lua

# Compile with debug info stripped (see next section)
luac -s -o output.luac input.lua
```

The output file contains the magic bytes `0x1B 0x4C 0x75 0x61` at offset 0. You can verify this with `xxd` or a hex editor:

```bash theme={"theme":{"light":"ayu-dark","dark":"night-owl"}}
xxd output.luac | head -1
# Output: 00000000: 1b4c 7561 5100 0000 0000 0000 0000 0000  .LuaQ..........
```

### Runtime Compilation with string.dump

Inside a running Lua script, `string.dump(f)` returns the bytecode of a function `f` as a binary string:

```lua theme={"theme":{"light":"ayu-dark","dark":"night-owl"}}
-- Dump a loaded file to bytecode
local bytecode = string.dump(loadfile("script.lua"))

-- Dump a function defined in the current script
local function my_function()
    print("Hello from bytecode")
end

local bytecode = string.dump(my_function)
```

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):

```lua theme={"theme":{"light":"ayu-dark","dark":"night-owl"}}
-- Load bytecode from a string
local bytecode = "\x1bLua\x51..." -- your bytecode string

local fn = load(bytecode)
if fn then
    fn()  -- execute the loaded function
end
```

### 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

```lua theme={"theme":{"light":"ayu-dark","dark":"night-owl"}}
local f = io.open("script.luac", "rb")
local bytecode = f:read("*a")
f:close()

-- Encode as a Lua string literal with \xNN escapes
local escaped = string.format("%q", bytecode)
print("local bytecode = " .. escaped)
```

#### Base64 Encoding

```lua theme={"theme":{"light":"ayu-dark","dark":"night-owl"}}
-- Simple base64 encoder (or use a library)
local b64 = require("base64")

local f = io.open("script.luac", "rb")
local bytecode = f:read("*a")
f:close()

local encoded = b64.encode(bytecode)
print("local bytecode = \"" .. encoded .. "\"")
```

### 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:

```lua theme={"theme":{"light":"ayu-dark","dark":"night-owl"}}
local function generate_loader(input_file, output_file)
    -- Read and compile the source file
    local source_f = io.open(input_file, "r")
    local source = source_f:read("*a")
    source_f:close()

    local fn, err = load(source, "@" .. input_file)
    if not fn then
        error("Compilation failed: " .. err)
    end

    -- Dump to bytecode
    local bytecode = string.dump(fn)

    -- Simple base64 encoding
    local b64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
    local function base64_encode(data)
        local result = {}
        local padding = ""
        local len = #data

        for i = 1, len, 3 do
            local a, b, c = string.byte(data, i, i + 2)
            local pad = 0
            if not b then b, pad = 0, pad + 1 end
            if not c then c, pad = 0, pad + 1 end

            local n = a * 65536 + b * 256 + c
            table.insert(result, string.sub(b64chars, math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1))
            table.insert(result, string.sub(b64chars, math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1))
            table.insert(result, string.sub(b64chars, math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1))
            table.insert(result, string.sub(b64chars, n % 64 + 1, n % 64 + 1))

            padding = pad
        end

        local out = table.concat(result)
        if padding > 0 then
            out = string.sub(out, 1, -1 - padding) .. string.rep("=", padding)
        end
        return out
    end

    local encoded = base64_encode(bytecode)

    -- Write the loader
    local loader = [[
local encoded = "]] .. encoded .. [["

local b64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local function base64_decode(data)
    local result = {}
    data = string.gsub(data, "[^" .. b64chars .. "=]", "")
    for i = 1, #data, 4 do
        local a = string.find(b64chars, string.sub(data, i, i)) or 0
        local b = string.find(b64chars, string.sub(data, i + 1, i + 1)) or 0
        local c = string.find(b64chars, string.sub(data, i + 2, i + 2)) or 0
        local d = string.find(b64chars, string.sub(data, i + 3, i + 3)) or 0
        local n = a * 262144 + b * 4096 + c * 64 + d
        table.insert(result, string.char(math.floor(n / 65536) % 256))
        if c > 0 then table.insert(result, string.char(math.floor(n / 256) % 256)) end
        if d > 0 then table.insert(result, string.char(n % 256)) end
    end
    return table.concat(result)
end

local bytecode = base64_decode(encoded)
local fn = load(bytecode)
if fn then fn() end
]]

    local out_f = io.open(output_file, "w")
    out_f:write(loader)
    out_f:close()
end

-- Usage
generate_loader("my_script.lua", "my_script_loader.lua")
```

## 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:

```bash theme={"theme":{"light":"ayu-dark","dark":"night-owl"}}
luac -s -o stripped.luac original.lua
```

### Stripping at Runtime

Pass `true` as the second argument to `string.dump`:

```lua theme={"theme":{"light":"ayu-dark","dark":"night-owl"}}
local bytecode_stripped = string.dump(my_function, true)
```

### 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:

```lua theme={"theme":{"light":"ayu-dark","dark":"night-owl"}}
-- Before stripping
local function calculate_sum(a, b)
    local result = a + b
    return result
end

-- After decompiling stripped bytecode
local function f(l_1_0, l_1_1)
    local l_1_2 = l_1_0 + l_1_1
    return l_1_2
end
```

### 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:

```lua theme={"theme":{"light":"ayu-dark","dark":"night-owl"}}
-- Pseudocode for constant table scrambling
local function scramble_constants(bytecode)
    -- Parse the bytecode to extract the constants table
    local constants = extract_constants(bytecode)

    -- Create a random permutation of indices
    local permutation = shuffle_indices(#constants)

    -- Reorder constants according to permutation
    local new_constants = {}
    for old_idx, new_idx in pairs(permutation) do
        new_constants[new_idx] = constants[old_idx]
    end

    -- Update all LOADK instructions to use new indices
    local new_bytecode = update_loadk_operands(bytecode, permutation)

    return new_bytecode
end
```

### 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:

```text theme={"theme":{"light":"ayu-dark","dark":"night-owl"}}
OP_MOVE A B    -- if A == B, this is a no-op
```

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:

```lua theme={"theme":{"light":"ayu-dark","dark":"night-owl"}}
-- Before compiling, rename locals to confuse analysts
local _if = calculate_sum
local _end = validate_input
local _do = encrypt_data
```

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:

```text theme={"theme":{"light":"ayu-dark","dark":"night-owl"}}
-- Standard OP_MOVE encoding: A=5, B=3, C=0
-- Obfuscated: A=5, B=3, C=random(0, 511)
```

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()`:

```lua theme={"theme":{"light":"ayu-dark","dark":"night-owl"}}
local _a = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local _b = "your_base64_encoded_bytecode_here"
local _c = 0x42  -- XOR key

local function _d(s)
    local _e = {}
    for _f = 1, #s, 4 do
        local _g = string.find(_a, string.sub(s, _f, _f), 1, true) or 0
        local _h = string.find(_a, string.sub(s, _f + 1, _f + 1), 1, true) or 0
        local _i = string.find(_a, string.sub(s, _f + 2, _f + 2), 1, true) or 0
        local _j = string.find(_a, string.sub(s, _f + 3, _f + 3), 1, true) or 0
        local _k = _g * 262144 + _h * 4096 + _i * 64 + _j
        table.insert(_e, string.char(bit32.bxor(math.floor(_k / 65536) % 256, _c)))
        if _i > 0 then table.insert(_e, string.char(bit32.bxor(math.floor(_k / 256) % 256, _c))) end
        if _j > 0 then table.insert(_e, string.char(bit32.bxor(_k % 256, _c))) end
    end
    return table.concat(_e)
end

local _l = _d(_b)
local _m = load(_l)
if _m then _m() end
```

> [!WARNING]
>
> Modifying bytecode instruction operands without full understanding of the VM specification causes crashes. Always test mutated bytecode on all target Lua versions before deploying.

> [!NOTE]
>
> 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.

> **What tools can disassemble Lua bytecode?**
>
> 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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.