Introducing luad 0.1.0: An Early Alpha for Reverse Engineering Lua Bytecode in Firmware

5 min read
reverse engineeringluafirmwarerustsecurityopen source

Written by Claude Opus 5; GPT-6 (revision and fact-checking) from prompts, source material, and direction provided by David E. Weekly, who reviewed it before publication. The tool, investigation, and technical direction are David’s. The prose was drafted by Claude and revised and fact-checked with GPT-6. Model: Claude Opus 5; GPT-6 (revision and fact-checking) (Anthropic; OpenAI). Disclosure level: ai-generated.

luad 0.1.0 is an early-alpha Rust CLI and library for reverse engineering compiled Lua extracted from firmware. It identifies bytecode profiles, disassembles instructions, reports constants and byte offsets, and exports structured facts for analysis pipelines.

The release is experimental. Lua 5.1 and 5.4 are the better-exercised starting points; no dialect has reached the project’s supported tier. luad does not extract firmware containers, reconstruct Lua source, or execute the input bytecode.

Bytecode layouts

Embedded Lua builds can use different type widths, byte orders, and vendor extensions. Matching the Lua version alone is insufficient to establish binary compatibility.

In stock Lua 5.1, the twelve-byte header records the version, format, byte order, C type widths, and an integral-number flag. The official loader compares that header with the one expected by its own build. A mismatch produces bad header in precompiled chunk.

Two public fixtures illustrate the difference:

stock 5.1   1b 4c 75 61  51 00 01 04  08  04 08  00
LNUM32      1b 4c 75 61  51 00 01 04  04  04 08  04

At offset 8, the string-length width changes from eight bytes to four. The final byte changes from a stock integral-number flag to the LNUM integer width: four bytes. The OpenWrt-derived LNUM32 profile uses separate integer constants and eight-byte doubles.

Asko Kauppi’s LNUM patch provides the integer extension. OpenWrt’s architecture-independent bytecode patch serializes string lengths as unsigned int rather than size_t. Consequently, four-byte lengths do not establish that the compiler ran on a 32-bit machine. Header fields describe the encoding, not a unique CPU or compiler provenance.

This header structure is specific to Lua 5.1; later versions use different formats.

Inspection and disassembly

The following examples were verified against the published 0.1.0 binary and the repository’s LNUM32 fixture:

luad inspect tests/fixtures/precompiled/lua51_lnum32/hello.luac

Selected output fields:

Dialect:           lua5.1-lnum32
Profile:           lua5.1-lnum32
Selection Mode:    Detected
Layout:            int=4,sizet=4,inst=4,num=8,endian=1,integral_flag=4
Verdict:           ValidForParser

For this profile, luad validates the declared layout and uses it to parse the body. integral_flag=4 preserves the raw header value; its interpretation depends on the selected profile.

luad disasm tests/fixtures/precompiled/lua51_lnum32/hello.luac

The first three instructions, with resolved constant annotations:

   0  GETGLOBAL    R(0) K(0) ; "print"
   1  LOADK        R(1) K(1) ; "Hello, luad!"
   2  CALL         R(0) 2 1

explain connects an instruction to its encoded bytes:

luad explain tests/fixtures/precompiled/lua51_lnum32/hello.luac \
  'proto:0:pc:0' --format json

Selected JSON fields:

{
  "mnemonic": "GETGLOBAL",
  "source": {
    "byte_offset": 58,
    "byte_length": 4,
    "raw_hex": "05000000"
  }
}

Offsets are zero-based in the original file. IDs such as proto:0:pc:0 are local to the artifact and interpretation. The Lua 5.1 representation also preserves closure-binding words and their owning CLOSURE, separately from executable instructions.

Structured export

export emits JSONL with selectable fact types:

luad export tests/fixtures/precompiled/lua51_lnum32/hello.luac \
  --format jsonl --facts constant > constants.jsonl

This fixture produces four constant records, plus file and stream records. Constants carry the input hash, path, profile, and artifact-local ID. --input-list accepts a list of input paths for batch processing; parse failures and skipped inputs remain visible.

Consumers must check file_end and the final export_end, including failure and truncation fields. A successful process exit can include skipped inputs. See the 0.1 machine-interface contract for record definitions and exit behavior. Compatibility across future releases is not guaranteed.

Known limitations

  • Lua 5.2 and 5.3 parsing can substitute an assumed layout for the declared layout. Lua 5.5 header validation is also incomplete.
  • Validators for Lua 5.2, 5.3, and 5.5 miss operand-bound errors. ValidForParser describes implemented parser checks, not complete VM validation or safety to execute.
  • Some failures report incorrect locations, including offset zero. The recorded EdgeTX comparison includes Lua 5.3 chunks with four-byte numbers that other tools parsed and luad did not.
  • An early-closing output pipe can panic. Redirect long output to a file before opening it in a pager.

The workspace forbids Rust unsafe code and includes resource limits and bounded fuzz smoke tests. These measures do not establish complete validation or bounded behavior on every path. Version 0.2 work targets layout handling, validation, error locations, output pipes, and resource bounds; those changes are outside the 0.1.0 release.

Installation and references

Release downloads include Linux x86-64 and macOS arm64 binaries, checksums, and a CycloneDX source-dependency SBOM. The code is dual-licensed MIT/Apache-2.0.

After downloading and verifying a binary, place luad on your path. Obtain the matching fixtures with:

git clone --branch v0.1.0 --depth 1 https://github.com/dweekly/luad.git
cd luad

The checkout includes compiled fixtures; reproducing the examples requires no Lua compiler. Alternatively, build and install from source with cargo install --path crates/luad-cli --locked.

Related work includes Kein-Hong Man’s ChunkSpy and Lua 5.1 instruction guide, the source-recovery tools LuaDec, unluac, and unluac-rs, and the Rizin reverse-engineering framework.

Bug reports should include a small, redistributable reproducer, the command and tool version, and the expected result.