A Lua bytecode reader for firmware
luad.
Pull apart a router's firmware and you'll find compiled Lua chunks the official tools refuse to open. luad reads them and tells you which layout each one declares, so you can pick a decompiler that handles it.
Both are Lua 5.1 with the same opcodes, but two bytes differ. The official loader rejects the router file, and it's right to: it would read the rest of the file at the wrong sizes. Those two bytes are why your firmware chunk won't open.
Lua 5.1 isn't one file format
The header of a compiled Lua chunk records the sizes of a few C types and the byte order of the Lua build that wrote it. Those are the real sizes from that build, not something the version number implies.
When Lua loads a chunk, it builds the same twelve header bytes for itself and compares them. So a chunk loads only in a Lua built the same way as the one that wrote it. The header doesn't name a target platform; Lua has no such idea.
It's tempting to read the header as describing the machine that compiled the chunk,
but that's wrong. OpenWrt carries
a patch
that makes even the x86-64 build host's luac write the router's layout.
The LNUM32 test file 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, has been in OpenWrt since Lua 5.1.3. It adds
a native integer type and reuses the header's last byte for
sizeof(lua_Integer).
Stock Lua refuses the file, and Lua 5.1 won't even say which field is wrong:
$ luac5.1 -l -l config.luac luac5.1: config.luac: bad header in precompiled chunk
That's the right behavior for a runtime, and no help when you're reading someone else's firmware. Before you can ask what the code does, you need to know what kind of file this is.
What luad tells you
luad answers that first:
$ 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
The Layout line comes straight from the file's header. luad doesn't
guess it or fill in a default. Once you know it, 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
- Sort an extracted firmware tree by what each file contains, whatever its name.
That catches compiled chunks named
.lua, source named.luacand broken files, and every file gets a result. - Learn a chunk's exact layout before you decompile it, so you can pick a decompiler that reads it correctly.
- When a decompiler prints something surprising, check the instruction and the bytes it came from.
- Feed your own scripts. JSON and JSONL output come with published schemas, stable IDs and a list of diagnostic codes, so you don't have to parse text.
luad doesn't decompile, extract firmware, or judge whether code is dangerous. The roadmap suggests other tools for those jobs.
Who calls this, and with what?
In a firmware investigation you usually want to know who calls a function, and with
what arguments. luad callers answers that for Lua 5.1, including stripped
builds. Here it is on LuCI's luci.sys, compiled without debug
information, asking about init_action, the helper that runs
/etc/init.d scripts:
$ luad callers sys.luac --proto 0/28 Callers of proto:0/28 sys.luac proto:0/29:pc:3 [call_relation:closure-value] ("enabled", param#0) sys.luac proto:0/30:pc:3 [call_relation:closure-value] ("enable", param#0) sys.luac proto:0/31:pc:3 [call_relation:closure-value] ("disable", param#0) sys.luac proto:0/32:pc:3 [call_relation:closure-value] ("start", param#0) sys.luac proto:0/33:pc:3 [call_relation:closure-value] ("stop", param#0) sys.luac proto:0/34:pc:3 [call_relation:closure-value] ("restart", param#0) sys.luac proto:0/35:pc:3 [call_relation:closure-value] ("reload", param#0) Coverage: sys.luac analyzed: 168 call sites, 7 caller, 0 conflicting, 0 degraded, 26 elsewhere, 0 unmapped label, 135 unattributed Summary: 7 caller(s); coverage complete; attribution not degraded
The coverage line puts every other call site in exactly one category, so you can see
which ones might still reach the target. An empty list means luad found no caller, not
that the function is unreachable. To search other modules, add their files and a
--link-convention. JSON output shows where each argument came from.
There are two conventions. luci-module-setglobal assumes nothing about
what a module does while it loads, so it only accepts modules whose top level just
defines functions. That rules out 41 of the 55 modules in public LuCI, mostly because
they require other modules first. luci-module-trusted-init
accepts all 55. It assumes that code run while a module loads doesn't replace or
remove any module's functions, and it labels every answer that depends on that
assumption with assumptions: ["trusted-init"]. For the LuCI files kept in
the repository, tests check each cross-file caller it reports against the source.
Other tools can answer this too. On stock Lua 5.1, the unluac-rs decompiler, or the
official luac -l -l plus a script, gets the same answers. luad's advantage
is that one binary reads both stock and OpenWrt LNUM32 builds, and its output is
structured data. The
comparison
is dated and shows how it was run.
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.4.1-downloads && cd luad-0.4.1-downloads $ gh release download v0.4.1 --repo dweekly/luad $ shasum -a 256 -c SHA256SUMS $ tar xzf luad-0.4.1-macos-aarch64.tar.gz $ ./luad-0.4.1-macos-aarch64/luad --help
The example is for macOS arm64. On Linux x86-64, use linux-x86_64 in
place of macos-aarch64. The release also includes an evidence index and
a CycloneDX source SBOM. To check that the archive was built by this repository's CI,
use the GitHub CLI, adding --source-digest with the source revision named
in the release notes:
$ gh attestation verify luad-0.4.1-macos-aarch64.tar.gz \
--repo dweekly/luad \
--signer-workflow dweekly/luad/.github/workflows/ci.yml
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
The latest release is 0.4.1, and luad is experimental. No Lua version is officially supported yet, and commands and output formats may change between releases.
| Dialect | Opcodes | State |
|---|---|---|
| lua5.1 | 38 | Best tested. Reads both stock and LNUM32 layouts. |
| lua5.2 | 40 | File structure through inspect and export, plus an unchecked text listing. No structured disassembly, effects or explain. |
| lua5.3 | 47 | File structure through inspect and export, plus an unchecked text listing. No EdgeTX layout, structured disassembly or explain. |
| lua5.4 | 83 | Reads well. Disassembly is checked against official Lua 5.4.8. |
| lua5.5 | 85 | File structure through inspect and export, plus an unchecked text listing. No structured disassembly, effects or explain. |
| LuaJIT, Luau | — | Different bytecode formats. luad doesn't read them. |
For Lua 5.2, 5.3 and 5.5, luad reads raw instruction words and how functions nest,
through inspect and export --facts prototype,constant,upvalue.
You can get an unchecked text listing (without --effects), but structured
disassembly, effects, explain and the other export facts refuse these
versions with an error. Passing the layout and malformed-file tests doesn't mean
every instruction is interpreted correctly, or that every vendor layout works.
The public firmware walkthrough goes from a mixed folder of files to layouts, constants and raw disassembly, and then to choosing a decompiler. You don't need a decompiler to build or test luad. The release notes and evidence say exactly what this release covers.
Cross-file callers depend on which LuCI link convention you pick. Under the strict
one, most real LuCI libraries come back marked degraded rather than
guessed at. trusted-init answers for them, but it doesn't check its
assumption. If a module rewrote another module's functions while loading, you'd get a
wrong answer, though it would still carry the trusted-init label.
"Valid" from luad validate means the file fits the selected format and
passes the listed checks. It doesn't mean the code is safe to run, or tell you where it
came from.
Prior art
Most of this has been done before, in pieces, and luad leans on that work.
ChunkSpy
Kein-Hong Man's chunk inspector showed offsets, raw bytes and named layouts for Lua 5.0 and 5.1. It's luad's most direct ancestor. His No-Frills Introduction is still the clearest explanation of the 5.1 instruction encoding.
unluac & unluac-rs
These two decompilers use the header's declared sizes throughout, so they read 32-bit embedded layouts correctly. They also handle EdgeTX chunks, which luad can't yet.
rizin
Has a real luac architecture, with instruction tables for each Lua
version from 5.0 to 5.5 plus LuaJIT. luad should feed it data rather than compete
with it.
LuaDec
Descends from Hisham Muhammad's original. Its OpenWrt and TP-Link forks show people have been stuck on these same files for years.
luac-parser-rs
Compiles custom parsers to WASM and loads them at run time to handle vendor dialects. A neat way around the problem.
Opcode shuffling
Unshuffling TP-Link's Lua opcodes and Luo et al. (IEEE TIFS, 2023) both found vendors reordering the opcode table, so you can't assume the standard one.
Before any of this, you need to get the filesystem out of the firmware image with Unblob or Binwalk.
Why a separate reader
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." In other words, Lua leaves checking the code inside a chunk to
someone else.
Samuel Groß's load exploit
shows what an attacker can do with that. The
Factorio bytecode RCE
shows a homemade verifier failing: it missed that JMP targets are offset
by one, so data in the constant pool became reachable code.
A file from unknown firmware is exactly what you shouldn't feed to a C loader. luad
has no unsafe code, caps how much memory it allocates and how deep it
recurses, and runs a fuzz suite in CI. In testing, one established tool ran forever on
a malformed chunk, so finishing at all isn't something to take for granted.