luarocks.org is the Lua package registry, and its most-downloaded package sits at 24 million downloads. A regular user account and a crafted upload were enough for root on the server. It was patched on 2026-09-26, and the writeup is candid about the cost of finding it: about a month of work, “99 failures before the 100th attempt worked.”
The surprise is that the sandbox is good. Uploading a package means uploading a rockspec, a Lua script describing name, version, source and build — so the site executes attacker-supplied Lua, and it defends properly:
loadstringcompiles the rockspec, thensetfenvhands it a completely empty environment with notype()and no globalsjit.offblocks JIT-spray attacks- A
debug.sethookline counter aborts past 2000 lines as DoS protection
The hole is underneath it. loadstring loads bytecode as well as source, and LuaJIT verifies nothing — its own FAQ says loading untrusted bytecode is not safe and there is no bytecode verification on purpose. Off-the-shelf LuaJIT exploits did not transfer (they lean on functions the empty environment denies, and the best-known one needs LJ_GC64=0 while OpenResty ships LJ_GC64=1), so the author wrote one, uploading patched bytecode that valid Lua syntax cannot express.
The chain, roughly:
- Out-of-bounds read.
KNUMloads a numeric constant fromKBASE+RD*8with no bounds check on the index operand. Patch the operand and it copies arbitrary heap bytes into a register. - Why that is useful. LuaJIT packs a type tag and a GC pointer into NaN-space floats, so 8 bytes grabbed out of bounds can be a table reference rather than a number.
KNUMdoes no type checking; it just moves 8 bytes. - Type checks without
type().if x == falsecompiles toISNEP, where the compared operand encodes false as1, function as8, table as11. Patching those operands turns every probe into “is this a table?”, so the payload can scan a large region of memory and silently ignore the rest. - Read forward, not backward.
KBASEonly reads forward, and the valuable objects sit behind it — two weeks lost there, since memory layout shifts with every package upload. The unlock was that TValues in front are enough:package.loaded, therequirecache holding every loaded module, is usually allocated ahead ofKBASE. - The last pivot.
package.loadedhas noloadstring, so:debug.getfenv(f)returns a function’s environment, a C function’s environment is the global one, anddebug.getfenvis itself a C function — sodebug.getfenv(debug.getfenv)hands you_G, and_G.loadstringruns anything. - The payload. Download
ttyd, serve a shell on port 7681, rewrite the site’s homepage view to an iframe of it, and detect success by flipping the package description.
The lesson is not “write a better sandbox”. The author’s own competing project uses a purpose-built interpreter, an empty rockspec environment, a separate container that only returns OK or ERR, and execution time limits — and he still concludes that you should not parse untrusted configuration in a general-purpose language at all.
What makes this worth reading is that review would never have caught it. The whitelist, the JIT disable and the debug hook are all correct; the escape does not happen in Lua semantics. It happens in a bytecode format with no verifier, reached through a function whose documentation mentions none of this.