Environments, sandboxing and bytecode
Control what a chunk can see with _ENV and load, restrict globals for untrusted code, and treat precompiled bytecode as dangerous input.
_ENV and load
-- in 5.2+ every chunk sees globals through an upvalue called _ENV
print(_ENV == _G) --> true in the main chunk
-- assigning _ENV redirects every global access in the chunk
do
local _ENV = { print = print, x = 10 }
print(x) --> 10
-- print(y) would be an error: y is not in this environment
end
-- load can set an environment for a whole chunk
local chunk = load("return value * 2", "config", "t", { value = 21 })
print(chunk()) --> 42
-- load with a custom environment is the basis of every sandbox
local function sandboxed(source, env)
local fn, err = load(source, "sandbox", "t", env)
if not fn then return nil, err end
local ok, result = pcall(fn)
if not ok then return nil, result end
return result
end
local safeEnv = {
math = math,
string = string,
table = table,
print = print,
}
print(sandboxed("return math.max(1, 5)", safeEnv)) --> 5
-- 5.1 uses setfenv instead
-- local fn = loadstring(source)
-- setfenv(fn, safeEnv)| Item | Lua 5.1 | Lua 5.2+ |
|---|---|---|
| Chunk environment | setfenv / getfenv | The _ENV upvalue |
| Load a string | loadstring | load with a string |
| Load a function | load (function only) | load accepts a string or a function |
| Environment at load | Not supported | Fourth argument to load |
| Global writes | setfenv on the function | Assign _ENV inside the chunk |
_ENV is an ordinary local variable. Declaring local _ENV = {...} inside a function affects only the code lexically after it, which makes environment changes easy to scope precisely.
Building a sandbox
-- a read-only environment with a metatable guard behind it
local function makeSandbox(extra)
local base = {
math = math,
string = string,
table = table,
ipairs = ipairs,
pairs = pairs,
tostring = tostring,
tonumber = tonumber,
error = error,
assert = assert,
}
for k, v in pairs(extra or {}) do base[k] = v end
-- an empty table behind __index means unknown names resolve to nil,
-- and the sandbox cannot walk up to the real globals
return setmetatable({}, {
__index = base,
__newindex = function(_, k, v)
error("attempt to write global '" .. tostring(k) .. "'", 2)
end,
__metatable = false, -- hide the metatable from the sandbox
})
end
local env = makeSandbox({ version = "1.0" })
local fn = assert(load([==[
local total = 0
for _, v in ipairs({ 1, 2, 3 }) do total = total + v end
return total, version
]==], "user-script", "t", env))
print(fn()) --> 6 1.0
-- what a sandbox must block if the script is untrusted:
-- os.execute, os.remove, os.exit, os.getenv
-- io.* and the debug library in particular
-- load and require, which can pull anything back in
-- setmetatable and getmetatable on host values
-- string.dump is harmless to produce, but the RESULT is dangerous to load
-- a resource limit: count instructions and abort a runaway script
local function runWithLimit(source, env, limit)
local budget = limit or 1000000
debug.sethook(function()
budget = budget - 1
if budget <= 0 then error("script exceeded its instruction budget") end
end, "", 1000) -- every 1000 instructions
local ok, result = pcall(load(source, "limited", "t", env))
debug.sethook()
return ok, result
end- Removing a name from the environment is not enough if a reachable value can produce it again.
stringexposes nothing dangerous, butdebugexposes the registry, and any host function you pass in becomes an escape route. - The
debuglibrary can read and write locals and upvalues of any function. Never expose it to untrusted code. string.dumpproduces bytecode that can be edited offline and then loaded, so it is not a form of protection.- Set an instruction limit with
debug.sethookso a script cannot spin forever. A hook that counts is the only reliable way to bound execution time in pure Lua. - Run untrusted code in a separate process or interpreter when the stakes are high. An in-process sandbox in Lua is good but not a security boundary by itself.
Bytecode and what it is not
-- produce a binary chunk
local f = assert(load("return 1 + 1"))
local bytes = string.dump(f)
print(#bytes > 0) --> true
-- loading a binary chunk works the same way
local back = assert(load(bytes))
print(back()) --> 2
-- refuse binary chunks in a sandbox: the mode argument
print(load(bytes, "no-binary", "t")) --> nil, "attempt to load a binary chunk"
print(load("return 1", "no-binary", "t")) --> function
-- the three modes are "b" (binary), "t" (text) and "bt" (both)
-- always pass "t" for untrusted source
-- luac produces a file the interpreter can run
-- luac -o app.luac app.lua
-- lua app.luac
-- the file is portable only to the same Lua version and word size⚠️
A precompiled chunk is not verified data. Malformed bytecode can crash the interpreter or escape a sandbox, because the verifier trusts the structure it reads. Always pass the text-only mode when loading untrusted input, and never accept bytecode over a network or from a file a user can edit.
FAQ
Is a Lua sandbox safe for untrusted code?
Reasonable for scripts from a semi-trusted source such as a configuration author, with the debug library removed, an instruction limit set and a restricted environment. For genuinely hostile input, isolate at the process or container level as well.
How do I run code from a string safely?
Use
load(source, name, "t", env), check the two return values, then run it under xpcall with a traceback handler and an instruction budget.Related
Modules, require and package.path Embedding Lua in a host program
Last refreshed 2026-09-18.