Security and sandboxing
LuaEnv does not sandbox Lua code. A script that runs in a default LuaEnv
can do anything the game process can do. Treat every Lua file you load into a
LuaEnv as native code, especially hotfix patches downloaded at runtime. For
scripts you do not trust, use LuaScript, which is a sandbox
with limits (below).
What a default LuaEnv exposes
Section titled “What a default LuaEnv exposes”- All of .NET. The
CSglobal reaches any public C# type through reflection, for exampleCS.System.IO.FileorCS.System.Diagnostics.Process.xlua.private_accessiblealso opens private members. - The Lua standard libraries.
luaL_openlibsopensio,os,package,debug,loadanddofile.io.popenruns shell commands andpackage.loadlibloads native libraries on Windows, macOS, Linux and Android. xLua removesos.executefrom all bundled PUC Lua versions, but that alone is not a sandbox. The minimal preset’s library has noio, noosbeyondtime,clock,dateanddifftime, nopackage.loadlibor C modules and nodofileorloadfile, andLuaOptions.Librarieslets aLuaEnvleave libraries out (XLua_API_EN.md); while scripts reachCS, that is not a sandbox either. - Network sockets. LuaSocket is compiled in by default and registers
itself in
package.preloadassocketandsocket.core(withmimeandmime.core) on every platform except Switch and WebGL. Build the native library with-DXLUA_LUASOCKET=OFFto leave it out;require "socket"then fails (build/README.md). - Binary chunks. The loader accepts precompiled bytecode. PUC Lua does not verify bytecode, so a crafted binary chunk can corrupt memory.
Protecting downloaded scripts
Section titled “Protecting downloaded scripts”Load scripts only from sources you control. To detect tampering in transit,
sign scripts and wrap your loader with SignatureLoader; see
Script signatures. A signature proves who produced a file. It
does not limit what the file can do.
Running less-trusted scripts
Section titled “Running less-trusted scripts”Lua has no built-in sandbox, but you can narrow what a chunk sees. Pass a
restricted environment table to DoString or LoadString. The chunk then
resolves globals only from that table:
LuaTable sandbox = luaenv.NewTable();foreach (var name in new[] { "assert", "error", "ipairs", "next", "pairs", "pcall", "select", "tonumber", "tostring", "type", "xpcall" }){ sandbox.Set(name, luaenv.Global.Get<object>(name));}sandbox.Set("math", luaenv.Global.Get<LuaTable>("math"));sandbox.Set("string", luaenv.Global.Get<LuaTable>("string"));sandbox.Set("table", luaenv.Global.Get<LuaTable>("table"));// Expose only the game API you want scripts to use, as Lua functions built by// trusted code that return plain Lua values: a C# object would give the// script reflection through its GetType().LuaTable api = (LuaTable)luaenv.DoString(@" local ModApi = CS.MyGame.ModApi return { score = function() return ModApi.Score() end }")[0];sandbox.Set("api", api);
luaenv.DoString(untrustedSource, "mod", sandbox);Keep these limits in mind:
- Do not put
CS,xlua,require,load,dofile,io,os,package,debugorsocketin the table. Any one of them escapes the sandbox. - Pass source text, not bytecode. Reject chunks whose first byte is
0x1B. - Shared tables such as
stringare the real library tables. A script can change them for every other script in the sameLuaEnv. Use a separateLuaEnvfor untrusted code, or give it copies. - An environment table does not limit CPU or memory. xLua has no built-in
time limit, so an endless loop blocks the calling thread. You can watch
memory with
LuaEnv.Memroy(the property name keeps upstream’s spelling). - C# objects you expose keep their full API, and every object’s
GetType()leads to reflection over all of .NET (see below). Expose Lua functions that call C#, not C# objects. - By default a script can read and change the metatables of C# objects. For
example,
getmetatable(obj).__gc(obj)releases the C# handle of an object Lua still uses. DefineXLUA_LOCK_METATABLESto lock them; see Macros. Leave outdebugandxluaas well, becausedebug.getmetatable,debug.setmetatableandxlua.metatable_operationbypass the lock.
What a LuaEnv guarantees
Section titled “What a LuaEnv guarantees”A LuaEnv trusts its scripts. It keeps the bridge consistent for scripts
that use it as documented, and it does not defend against a script that
tries to break it: that is what LuaScript is for. In every build:
- a value that is not a full userdata, such as a light userdata (a C#
IntPtr) or a table, is never read as a C# object or struct; - the object pool is bounds-checked, so a wrong handle gives the wrong C# object or none, not a native fault in C#.
The checks below catch script mistakes during development and leave release players as fast as upstream.
Bridge checks in debug builds
Section titled “Bridge checks in debug builds”In the Editor, in Development Builds and outside Unity (XLUA_GENERAL), the
bridge checks that a userdata is one of xLua’s before it uses it as a C#
object or struct (XLUA_BRIDGE_CHECKS, in every preset). They catch
mistakes such as obj.Method(file) where obj:Method() was meant, which
in a release player can name another C# object, and they make a script’s
writes into the userdata cache (through debug.getregistry) harmless.
Release players leave them out: a correct script behaves the same with and
without them, and an instance call costs 10 to 13 ns less without them.
Define XLUA_BRIDGE_CHECKS to keep them in release players, or
XLUA_NO_BRIDGE_CHECKS to leave them out everywhere; see
Macros.
| Check | What a script can no longer do where it runs |
|---|---|
| Objects | Pass a foreign userdata (a file, a socket) as self or as an object argument and reach another C# object |
| Struct accessors | Read or write a foreign userdata with the xlua.genaccessor accessors |
| Userdata cache | Plant a value in the userdata cache (through debug.getregistry) that C# then returns for an object |
Opt-in bridge hardening
Section titled “Opt-in bridge hardening”These defines change what scripts can see or reach, so a legacy script may depend on the behaviour they remove: they are off by default and on in the minimal preset, in every build type. They cost little (measured in bridge-audit.md). See Macros for details.
| Define | What a script can no longer do |
|---|---|
XLUA_LOCK_METATABLES |
Read or replace bridge metatables with getmetatable/setmetatable |
XLUA_RESTRICT_GETMETATABLE |
Reach __gc through xlua.getmetatable and release a live object |
XLUA_LOCK_XLUA_TABLE |
Assign to xlua fields, or change how xlua.hotfix and the CS lookup work by replacing xlua.access or rawset. rawset(xlua, ...) still works, so leave rawset out of a sandbox |
XLUA_RESTRICT_PRIVATE_ACCESS |
Use xlua.access and xlua.private_accessible outside hotfix, or wrap non-public methods with xlua.tofunction and xlua.get_generic_method |
XLUA_NO_LOAD_ASSEMBLY |
Load assemblies with xlua.load_assembly |
None of them limits what CS reaches. A script that holds any C# object
can call its GetType() and use reflection to read private fields and call
any method of any type, for example
obj:GetType():GetDeclaredField("secret"):GetValue(obj); typeof(CS.Foo)
does the same. A type allowlist is planned for the minimal preset. Until
then, give a sandbox no C# objects at all: expose Lua functions, defined in
trusted code, that call C# for it.
A sandbox built this way reduces risk. It is not a security boundary you should rely on against a determined attacker. For that, run untrusted code in a separate process.
LuaScript
Section titled “LuaScript”LuaScript is the sandbox of xlua-ng. Unlike everything
above, it is meant to run code you do not trust. It is a preview: what this
section promises covers what is implemented today (loading and calling, C#
functions registered for Lua, the buffer library and the limits); table
handles and C#-driven coroutines will come under the same terms.
Scope. LuaScript running Lua 5.4 or 5.5 source text, on the core
library (XLUA_PRESET=core) or on a bridge library built with
XLUA_LUASCRIPT=ON in a project that also uses LuaEnv. LuaEnv itself is
not a sandbox and is out of scope; see the sections above.
We treat these as security bugs. Please report them privately, not in a
public issue. A script loaded as text through LuaScript, under any
LuaOptions, that:
- corrupts memory, crashes the process or triggers undefined behaviour in native code;
- reads or writes files, the network, other processes or memory outside its Lua state;
- calls a C# method the host did not register, or reaches a C# object;
- makes the state hold more than
MemoryLimitplus 16 KB, plus what one host callback allocates; - keeps running more than 4,096 instruction-equivalents past
InstructionLimit, or pastTimeLimitby more than the watchdog latency (a few milliseconds) and one of the C paths listed below; - makes a C# call that hit an instruction, time or interrupt limit return normally;
- changes what another environment of the same
LuaScriptsees: the frozen base, another environment’s tables, or the string metatable; - loads or produces bytecode.
Known issues: none open. Fixed on 2026-10-18 (each was a bug under the list above):
- With an
InstructionLimitset,TimeLimitandInterrupt()took effect up to about five seconds late, at the instruction budget’s next internal check (item 5). They now stop a call within a few milliseconds, with or without an instruction limit (core-stop-latency.md). Interrupt()stopped a call only while the script had an instruction or time limit; it now stops any running call (unless the script turns that off withLuaOptions.AllowInterrupt = false).- A call could return normally although its limit had tripped (item 6): a
script that caught the limit’s error with
pcallas its last action (return pcall(f)), a stop that the time limit orInterrupt()set after the call’s last Lua instruction, and anInterrupt()while a chunk was compiled without running it. Such a call now fails with the limit’s exception (core-stop-latency.md). table.insert,table.removeandtable.sortunder a script’s__lenlooped in C up to 2^63 times (2^31 forsort) without allocating, and no limit, the watchdog orInterrupt()could stop them (item 5); with C metamethodstable.concatwas bounded only byMemoryLimit, and on Lua 5.4.9string.rep("", math.maxinteger)looped the same way. They now run under the C budget, andstring.repof two empty strings returns at once (build/LUA_PATCHES.md).- A table operation from C# that failed (a nil or NaN key) inside a host
function that the script called through
xpcallran the script’s message handler; a handler that raised left the native frame of the host function pointing at a dead Lua frame until it returned (item 1; no memory error was observed). Table operations now run no Lua code.
Not covered:
- No termination guarantee without limits. With
InstructionLimitandTimeLimitoff, a script can loop forever. - Listed C paths.
string.rep,string.formatandutf8run in C for a time proportional to their input or result, whichMemoryLimitbounds. A full garbage collection takes time proportional to the heap. - Host functions. What a registered C# function does with its arguments, including how long it runs and how much it allocates, is the host’s responsibility. A host function is never interrupted; the call stops when it returns.
- Side channels.
tostringof tables and functions shows addresses; timing is observable. - Other builds. LuaJIT, Lua 5.3, modified builds, builds without the
lstrlib.candltablib.cbudget patch (build/LUA_PATCHES.md), andLuaEnvin any preset. - Lua itself. Bugs in PUC Lua are in scope for reports. The fix depends on upstream or on a local patch to the pinned version.
Supported versions: the current main branch until the first release of xlua-ng, then the latest release.
