RC RANDOM CHAOS

LuaJIT proposal exposes a guard-elision primitive

LuaJIT's proposed relaxed type checking elides JIT trace guards, creating a type-confusion primitive reachable wherever embedded Lua handles untrusted input.

· 7 min read
LuaJIT proposal exposes a guard-elision primitive

LuaJIT’s proposed syntax extensions move type checking off the hot path. That is the vulnerability. Relaxed type validation during trace compilation is not a language feature - it is a guard-elision primitive exposed to anything that feeds Lua into the runtime.

No CVE is assigned. This is proposal-stage. The bug class is CWE-843, type confusion, with CWE-787 out-of-bounds write and CWE-125 out-of-bounds read as the downstream primitives. There is no CVSS vector to quote because nothing is published. The honest read: a proposal that weakens trace guards across an embedded JIT is a pre-CVE condition, and the exposure is not scoped to one version. It is in every build where the relaxed mode is compiled in and enabled.

Affected surface is wide because LuaJIT is not a standalone interpreter people run at a prompt. It is embedded. OpenResty puts LuaJIT inside nginx worker processes at the edge. HAProxy runs Lua for request scripting. Kong, Tarantool, and a long tail of game runtimes link it directly. Cloudflare’s edge historically ran LuaJIT through OpenResty for request handling. Wherever attacker-influenced Lua reaches a relaxed-typing build, the condition is reachable.

Start with how LuaJIT executes. It is a tracing JIT. The interpreter runs bytecode until a loop or call path gets hot. The compiler records that path into an SSA-form intermediate representation - a trace - and specializes the trace on the types it observed during recording. A table field that held a double becomes a double in the trace. A branch taken once becomes the assumed branch. Specialization is what makes LuaJIT fast.

Specialization is unsound on its own. The recorded trace is valid only while the observed types hold. LuaJIT keeps it valid with guards. A guard is a runtime check compiled into the trace - verify the value is still a double, verify the table shape, verify the branch direction. If a guard holds, native code runs. If it fails, execution takes a side exit back to the interpreter and the wrong-typed value never reaches code that assumed otherwise. Guards are the safety boundary of the entire JIT. They are the only thing standing between a type assumption and a raw memory operation on that assumption.

Relaxed type checking during compilation weakens or removes those guards. That is the mechanism. When the proposal lets the compiler skip a type guard - to save a branch, to widen what a trace accepts, to make new syntax cheaper - it removes the runtime check that a value matches the layout the native code will use. A value of one type now flows into machine code that reads and writes it as another. Number narrowing makes this worse. LuaJIT already narrows numbers between double and integer representations during optimization. Loosen the type discipline around that narrowing and an integer-typed slot gets dereferenced as a pointer, or a pointer gets arithmetic applied to it as an integer.

The path from there is mechanical and well understood. Type confusion in a JIT yields a confused read or write - native code uses the wrong memory layout for a controlled value. The attacker who controls the Lua input controls which values flow down the elided-guard path. Drive a heap-object reference into a slot the trace treats as a double and the raw bits of a pointer become readable as a float. That is an address leak - ASLR inside the host process is defeated. Drive a double into a slot the trace treats as a reference and a controlled integer becomes a dereferenced pointer. That is the write.

LuaJIT hands the attacker the next stage through the FFI. The FFI library exists to call C and manipulate C data. cdata objects are raw memory with no bounds checking by design - that is the contract of the FFI, the programmer owns safety. A confused write that corrupts a cdata pointer or a backing allocation becomes arbitrary read and arbitrary write across the process address space. From arbitrary read/write inside a process, native code execution is a solved problem - overwrite a function pointer, corrupt a cdata callback, redirect control into an RX region. The exploitation grammar is identical to V8 TurboFan type confusion. The engine differs. The primitives do not.

Grooming the LuaJIT heap is tractable. The allocator places objects into predictable size classes, and Lua-level operations - table construction, string interning, cdata allocation - give deterministic control over allocation order from inside the language. Shape the heap, place the target adjacent to the controlled write, and the write lands where the attacker put it. None of this requires native access. It requires Lua execution, which the embedding already grants.

What the attacker controls is the input that drives trace specialization and the input that triggers the side exit that no longer exists. What the memory layout enables is the pivot from confused read to address leak to controlled write. The relaxed mode is the enabling condition for the whole chain. Without the elided guard, the wrong-typed value hits a check and exits to the interpreter. With it, the value reaches the memory operation.

There is no confirmed in-the-wild exploitation, because there is no shipped feature and no CVE. State that plainly. What exists is the analogue. Type-confusion exploitation against optimizing JITs is not theoretical - it is the dominant browser-exploitation bug class of the last decade, used in real campaigns against V8 and JavaScriptCore, with public primitives since 2017. A tracing JIT that relaxes its guards is the same target with a different IR.

The exposure that matters is the embedding. Server-side LuaJIT at the edge means the input that drives trace compilation can be request-derived. Map it to MITRE T1190, exploitation of a public-facing application, where the public-facing application is nginx with an OpenResty Lua handler. Code execution in the worker is execution inside the request-processing process - credentials, upstream connections, TLS keys, and request bodies are in reach. Follow-on maps to T1059 once the attacker runs commands from the worker context, and T1505.003 if the foothold is a persistent handler.

Telemetry is where this gets ugly for defenders. The type confusion is in-process. Trace compilation, guard elision, the confused read, the heap groom, the arbitrary write - none of it crosses a syscall boundary. There is no Sysmon event for a guard that was never compiled. There is no EDR alert category for a double read as a pointer inside an nginx worker. The exploitation primitive is invisible at the instrumentation layer because it never leaves the user-mode memory of one process.

What fires is the consequence, and only if the consequence crosses a boundary. A failed groom segfaults the worker - SIGSEGV, worker respawn, an entry in the nginx error log, a coredump if dumps are enabled. Repeated respawns are a weak signal that something is corrupting worker memory. Native execution that spawns a child process surfaces - auditd execve, an eBPF exec hook, Sysmon Event ID 1 on Windows builds - because process creation from an nginx worker is anomalous and crosses the kernel boundary. Outbound connections from a worker that normally speaks only to defined upstreams are a network IOC. Beaconing, a reverse channel, exfiltration of request data are visible at the network layer if egress is monitored.

The gap is the window before any of that. An attacker who achieves clean code execution without crashing the worker and without spawning a child produces close to nothing. In-memory native execution inside a trusted edge process, talking out over an already-open upstream path, sits near the floor of detectability. Lua-level sandboxing does not help - the standard containment for hostile Lua is disabling the FFI precisely because the FFI defeats any in-language sandbox, and a JIT type-confusion primitive bypasses the language entirely.

The patch boundary does not exist yet, which is the point of writing this now. The control is upstream of any CVE. A trace guard that validates a type assumption before a memory operation is not an optimization to be relaxed - it is the soundness boundary of the JIT. A proposal that makes guard elision a feature has to gate that behavior behind verification that the elided check is provably redundant, or the relaxed mode stays off in any build that processes untrusted input.

Residual exposure after any eventual fix is the same shape it always is for JIT bugs. The relaxed path, once shipped, lives in every binary built with it regardless of whether a given deployment knows it is enabled. Embedders inherit the JIT they linked. The exposure tracks the build flag, not the application version, which is exactly why this is hard to inventory after the fact.

For anyone running LuaJIT in Australian critical infrastructure under SOCI obligations - edge proxies, telco request handling, anything processing untrusted input through OpenResty or HAProxy - the action is to confirm whether relaxed-typing builds are in the dependency chain and to route that question to the security team that owns the runtime. This is not a patch-and-move-on item. It is a build-provenance question about a JIT that runs inside trusted processes.

Share

Keep Reading

Stay in the loop

New writing delivered when it's ready. No schedule, no spam.