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:

  • loadstring compiles the rockspec, then setfenv hands it a completely empty environment with no type() and no globals
  • jit.off blocks JIT-spray attacks
  • A debug.sethook line 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. KNUM loads a numeric constant from KBASE+RD*8 with 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. KNUM does no type checking; it just moves 8 bytes.
  • Type checks without type(). if x == false compiles to ISNEP, where the compared operand encodes false as 1, function as 8, table as 11. 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. KBASE only 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, the require cache holding every loaded module, is usually allocated ahead of KBASE.
  • The last pivot. package.loaded has no loadstring, so: debug.getfenv(f) returns a function’s environment, a C function’s environment is the global one, and debug.getfenv is itself a C function — so debug.getfenv(debug.getfenv) hands you _G, and _G.loadstring runs 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.