Introducing luad 0.1.0: An Early Alpha for Reverse Engineering Lua Bytecode in Firmware
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.
ValidForParserdescribes 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.