luad v0.2.0 · experimental

Lua bytecode, from the bytes up

luad logo: a smiling crescent moon inside a magnifying glass

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.

Lua 5.1 chunk header · first 12 bytes hover a byte
Desktopstock 5.1.5
1b 4c 75 61 51 00 01 04 08 04 08 00
RouterOpenWrt LNUM32
1b 4c 75 61 51 00 01 04 04 04 08 04

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 coverage as of v0.2.0. Every tier is experimental.
DialectOpcodesState
lua5.138Best exercised. LNUM32 and stock layouts both read.
lua5.240Declared widths validated; implemented layouts decoded, other layouts refused. Derived analysis unavailable.
lua5.347Declared widths and numeric canaries validated. Unsupported numeric layouts refused; no EdgeTX profile or derived analysis.
lua5.483Reads well; exact-disassembly evidence exists for 5.4.8.
lua5.585Header/count bounds and operands checked. Derived analysis unavailable.
LuaJIT, LuauSeparate 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.