Global environment leakage from restricted setfenv environment tables
0 reputation · 14 Aug 2023, 20:58 UTC
0 reputation · 14 Aug 2023, 20:58 UTC
In Lua 5.1, replacing a function's environment with a restricted table via setfenv is a common least-privilege pattern for blocking io and os. Lua 5.2 removed setfenv in favor of _ENV upvalues, shifting environment switching to compile time, yet the isolation guarantee is expected to remain equivalent for pure Lua code.
An unresolved concern is whether a sandboxed function can recover the true global environment through metatable manipulation—for example, by following an __index chain or capturing a reference before the environment is swapped—or through C-API calls that bypass the Lua-level environment table. If such a path exists, the restricted table is not a security boundary.
Assuming a Lua 5.1 interpreter without hostile C extensions, what is the authoritative behavior when a function's environment is replaced with a table whose metatable lacks a __index fallback to _G? Can a pure Lua function inside that environment still reach the original global table, and if so, through which documented mechanism? How should this be verified across Lua 5.1 and 5.2+ without relying on version-specific error strings?
33775 reputation · 15 Aug 2023, 00:34 UTC
When a Lua 5.1 function is given an environment table that has no __index pointing to the original _G, pure Lua code cannot recover the true global environment. The same isolation holds in Lua 5.2+ when the function’s _ENV upvalue is set to such a table. No documented Lua‑level mechanism lets the function reach _G unless that reference is explicitly passed, stored in an upvalue, or the sandbox has access to the debug library to inspect other functions’ upvalues.
setfenv(f, env) replaces the function’s environment table with env.f first consult env. If a key is missing and env has no __index, the lookup fails and returns nil._G is itself a key in the original environment; if env does not contain a reference to _G, the name _G is unresolved._ENV_ENV upvalue._ENV to a restricted table behaves analogously to setfenv.__index to the original _G, lookups for global names return nil and the name _G is not found._G if the sandbox has access to the debug library.lua_getglobal or lua_getfield can bypass the environment table entirely, but this is outside the scope of a vanilla Lua interpreter without hostile extensions.__index:
local env = {print = print} -- only a safe function
local function test()
return foo -- 'foo' is a global that does not exist in env
end
setfenv(test, env) -- Lua 5.1
test._ENV = env -- Lua 5.2+ (or use _ENV in the function body)
print(test()) -- should print nil, not the global 'foo'
_G directly:
print(_G) -- should be nil, confirming no reference
If the sandboxed environment can load modules or access the debug library, the isolation guarantee weakens. In that case, the recommendation changes: you must either disable debug or ensure that no function in the sandbox holds an upvalue to the original _G. So, does the sandbox have access to the debug library?
Under the stated premises—no hostile C extensions and no debug access—a Lua 5.1 function set with setfenv to a table lacking an __index cannot escape to the original global environment. The same holds for Lua 5.2+ with _ENV. Verification is straightforward by attempting to read a known global and checking that the result is nil and that _G is unresolved.
Use comments to ask for clarification. Post a solution as an answer.
33,775 reputation · 15 Aug 2023, 03:57 UTC
When implementing least-privilege sandboxes in Lua 5.1, developers often focus on restricting setfenv by replacing a function's environment with a table that lacks dangerous modules like io or os. However, a fundamental flaw exists: setfenv only controls where the function looks for global variables, but it does not prevent access to the actual global environment itself.
The key mechanism is getfenv(0), which returns the global environment of the current thread regardless of any setfenv modifications. This means that even in a heavily restricted environment, a function can always recover the true global table by executing:
local globals = getfenv(0)
Verification Method: To confirm this behavior across Lua versions without relying on error strings, capture a reference to the original _G before sandbox initialization, then use rawequal to compare it with the result of getfenv(0) from within the sandboxed function. If they match, the environment restriction is purely logical, not a security boundary.
In Lua 5.2+, the equivalent leakage path exists through debug.getregistry() or direct access to _G if it hasn't been nilled. The lesson remains: restricting only the function's environment table is insufficient for true sandboxing.