Lua bytecode, from the bytes up
luad.
Pull apart a router's firmware and you'll find compiled Lua chunks the official tools refuse to open. luad reads them, tells you exactly which layout the file declares, and hands the facts to whichever decompiler can use them.
Same Lua version. Same opcodes. Two bytes different, and the official loader refuses the second one — correctly, since it is about to read structures out of it. Those two bytes are why your firmware chunk will not open.
The format is not one format
A Lua binary chunk records the C type widths and byte order that the Lua which wrote it was built with — not a version number implying them, but the actual widths, measured inside the compiler binary and written into the header.
The detail that matters: the loader calls the same function to build its own twelve bytes, then compares. So the header is a compatibility fingerprint of the build that wrote the chunk, and the chunk loads only where that fingerprint matches exactly. It is not a declaration of an intended target — Lua has no notion of a target.
Calling it "the machine that compiled it" is tempting and wrong. OpenWrt carries
a patch
that makes even the x86-64 build-host luac write the router's layout: the
LNUM32 fixture in this repo was compiled on a 64-bit machine and declares a 4-byte
size_t. The
LNUM patch
— Asko Kauppi's integer patch for Lua 5.1, carried by OpenWrt since the 5.1.3 days —
gives Lua a native integer representation and repurposes the header's last byte to
hold sizeof(lua_Integer).
The loader refuses, and Lua 5.1 will not even tell you which field is wrong:
$ luac5.1 -l -l config.luac luac5.1: config.luac: bad header in precompiled chunk
That's the right call for a runtime. It's useless when the job is reading somebody else's firmware. So the first question about an unknown chunk isn't what the code does — it's what exactly is this file?
What luad tells you
It answers that question first, and shows its work.
$ luad inspect config.luac === Chunk Overview === SHA-256: 8376be37ec3042d3b0a87aa39db7d7396fb54ae390abe346d885e1527d23e353 Byte Length: 249 bytes 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
That Layout line is the point. It isn't a guess or a default — it's what
the file says about itself, reported back. Then you can read the code:
$ luad disasm config.luac ; proto:0 (source: @config.lua, lines 0-0, stack: 4) ; Constants: ; k[0] = "print" ; k[1] = "Hello, luad!" 0 GETGLOBAL R(0) K(0) ; "print" 1 LOADK R(1) K(1) ; "Hello, luad!" 2 CALL R(0) 2 1
What it's for
- Triage an extracted tree. Inventory by content, not extension —
compiled chunks named
.lua, source named.luac, and the malformed ones — with an outcome for every file. - Identify before you decompile. Get the exact profile, then pick a decompiler that handles it instead of guessing and misreading.
- Cross-check a disputed detail. When a decompiler prints something surprising, go back to the instruction and the bytes behind it.
- Feed a pipeline. JSON and JSONL with published schemas, stable IDs, and a diagnostic catalog, so nothing has to scrape columns.
luad does not decompile, extract firmware, or judge whether something is dangerous. Those are other tools' jobs, and the roadmap names which ones to reach for.
Install
Prebuilt archives for Linux x86-64 and macOS arm64 are attached to the release. Download all five release assets into an empty directory with the GitHub CLI, then verify the checksums and extract the archive for your platform:
$ mkdir luad-0.2.0-downloads && cd luad-0.2.0-downloads $ gh release download v0.2.0 --repo dweekly/luad $ shasum -a 256 -c SHA256SUMS $ tar xzf luad-0.2.0-macos-aarch64.tar.gz $ ./luad-0.2.0-macos-aarch64/luad --help
The example uses macOS arm64; on Linux x86-64, substitute
linux-x86_64 for macos-aarch64. The release also includes
an evidence index and CycloneDX source SBOM. Verify the archive's build provenance
with the GitHub CLI:
$ gh attestation verify luad-0.2.0-macos-aarch64.tar.gz \
--repo dweekly/luad \
--signer-workflow dweekly/luad/.github/workflows/ci.yml \
--source-digest cfadac0b896cc14969a0771dc9bb63f11e0b5281
Or build from source with Rust 1.85 or newer:
$ git clone https://github.com/dweekly/luad.git $ cd luad && cargo install --path crates/luad-cli --locked
What doesn't work yet
luad is version 0.2.0 and experimental. No dialect is promoted to a supported tier, the supported set is empty, and no interface carries a compatibility promise.
| Dialect | Opcodes | State |
|---|---|---|
| lua5.1 | 38 | Best exercised. LNUM32 and stock layouts both read. |
| lua5.2 | 40 | Declared widths validated; implemented layouts decoded, other layouts refused. Derived analysis unavailable. |
| lua5.3 | 47 | Declared widths and numeric canaries validated. Unsupported numeric layouts refused; no EdgeTX profile or derived analysis. |
| lua5.4 | 83 | Reads well; exact-disassembly evidence exists for 5.4.8. |
| lua5.5 | 85 | Header/count bounds and operands checked. Derived analysis unavailable. |
| LuaJIT, Luau | — | Separate bytecode systems. Out of scope. |
Structural facts and analysis have different boundaries. Lua 5.2, 5.3, and 5.5 provide inspection and validation; derived analysis commands refuse these dialects. Layout and malformed-input regression coverage does not establish complete semantic correctness or support for every vendor layout.
The public firmware walkthrough covers mixed-input inventory, layout inspection, constants, raw disassembly, and profile-aware handoff. No external decompiler is required to build or test luad. See the release notes and evidence for the exact release scope.
"Valid" from luad validate means consistent with the selected format and
the named checks. It does not mean safe to execute, or of known origin.
Standing on other people's shoulders
This is not a new idea, and the prior art deserves naming.
ChunkSpy
Kein-Hong Man's chunk inspector — offsets, raw bytes, named profiles — did this for the Lua 5.0/5.1 era. The direct ancestor. His No-Frills Introduction is still the clearest writeup of the instruction encoding anywhere.
unluac & unluac-rs
The two decompiler lineages that honour declared header widths end to end, so they read 32-bit embedded layouts correctly. On EdgeTX chunks they succeed where luad currently does not.
rizin
A real luac architecture with per-version ISA tables for Lua 5.0–5.5
and LuaJIT. The platform luad should feed, not fight.
LuaDec
Descends from Hisham Muhammad's original. Its OpenWrt and TP-Link forks show how long people have been hitting this exact wall.
luac-parser-rs
Compiles custom parsers to WASM and hot-loads them for unofficial dialects — a clever answer to the vendor-dialect problem.
Opcode shuffling
Unshuffling TP-Link's Lua opcodes and Luo et al. (IEEE TIFS, 2023) both confirm the same thing: in the field, the opcode table is not a constant.
And before any of it, you need the filesystem out of the image: Unblob or Binwalk.
Why bother
The Lua 5.4 manual says of
load: "It is safe to load malformed binary chunks; load
signals an appropriate error. However, Lua does not check the consistency of the code
inside binary chunks; running maliciously crafted bytecode can crash the
interpreter." Structural validation of the code is explicitly somebody else's job.
Samuel Groß's load exploit
shows what that buys an attacker, and the
Factorio bytecode RCE
is the best case study of a hand-rolled verifier failing — it missed that
JMP targets are offset by one, so constant-pool data became reachable code.
A file out of unknown firmware is exactly the input class where a C loader is the wrong
tool. luad forbids unsafe across the workspace, bounds allocation and
recursion, and runs a fuzz suite in CI. In testing, one established tool simply never
terminated on a malformed chunk. Bounded behaviour is not a given here.