Back Vhyrro.Neorg Conquering the Moon (luarocks.org remote code execution exploit) | Vhyrro's Digital Garden
Today I am going to do a technical writeup like I’ve never done before: 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.
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 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.
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 the package: its name, its version, how to build it, etc. Here’s an example:
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:
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?
Let’s have a look at their source code. luarocks.org is itself written in Lua. Here it is:
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.
Ladies and gentlemen, we got em.
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.
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. […]
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.
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 it.
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 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.
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 debug table).
Use the functions from debug to 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.
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:
It gets translated into the following bytecode:
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 ):
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.
The reason KNUM works is because of TValue s.
A TValue (Tagged Value) is an incredibly clever way that Luajit represents data. It works by storing complex data inside of the following format:
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 themselves 1 .
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 :)
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.
Here’s the first payload we’ll deploy. Please read the for proper explanations:
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 :
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 !
Now that we have access to a table full of functions, it’s time we do something 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.loaded 2 , so we need one more pivot to escape. We’re going to exploit the behaviour of debug.getfenv() to achieve this.
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!") .
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:
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:
After running this through our bytecode patcher and uploading it to a local docker of luarocks-site, we get the following legendary result:
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!💜
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! ↩
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 :) ↩
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 :) ↩
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
