Embedding Lua in C++: State‑Based Isolation, Sandboxing, and Memory Control
Integrate Lua safely into C++ by isolating each script in its own lua_State, restricting libraries for sandboxing, and using custom allocators to keep memory under control.
25 Sept 2025, 06:32 UTC

When a C++ application offers scripting, the first question is not whether scripts run, but whether they can break the host. A malicious or buggy Lua script can crash the process, exhaust memory, or leak data. The solution is to treat Lua as a collection of isolated virtual machines, each with its own stack, environment, and garbage collector. This article walks through the minimal architecture that satisfies isolation, sandboxing, and fault tolerance, and shows how to verify each part.
Requirements for a Stable Integration
Any production embedding must meet three core requirements:
- Isolation: Scripts must not interfere with each other or with the host’s global state.
- Resource Constraints: Scripts should be prevented from performing dangerous operations unless explicitly allowed.
- Fault Tolerance: Lua errors must be caught and handled by the host without terminating the application.
The Smallest Suitable Design: State‑Based Architecture
The lua_State struct represents a complete Lua virtual machine. Creating a separate state for each script or execution thread guarantees isolation because each state has its own global table, stack, and garbage collector. The following C++ example shows the minimal initialization of a sandboxed state:
#include <lua.hpp>
#include <iostream>
void runSandboxedScript()
{
// Create a new Lua state
lua_State* L = luaL_newstate();
if (!L) {
std::cerr << "Failed to create Lua state!" << std::endl;
return;
}
// Open only safe libraries – omit 'os' and 'io'
luaL_openlibs(L); // opens all safe libs
lua_pushnil(L); // remove 'os'
lua_setglobal(L, "os");
lua_pushnil(L); // remove 'io'
lua_setglobal(L, "io");
// Execute a simple script
const char* code = "print('Hello from sandboxed Lua!')";
if (luaL_dostring(L, code) != LUA_OK) {
const char* err = lua_tostring(L, -1);
std::cerr << "Lua Error: " << err << std::endl;
lua_pop(L, 1); // clean error message
}
lua_close(L); // free resources
}
Communication via the Stack Boundary
Lua and C++ communicate exclusively through a stack. To call a Lua function, you push arguments onto the stack, call the function, and then pop the results. The following table summarizes the most common operations:
| Direction | Operation | Example API |
|---|---|---|
| C++ → Lua | Push a value | lua_pushnumber(L, 42) |
| Lua → C++ | Retrieve a value | double v = lua_tonumber(L, -1) |
| C++ → Lua | Call a Lua function | lua_getglobal(L, "foo"); lua_call(L, 1, 1); |
Custom Memory Allocation
Lua’s garbage collector is incremental and uses a default allocator that calls malloc and free. If your host tracks memory usage or wants to pool allocations, provide a custom allocator via lua_newstate:
void* myAlloc(void* ud, void* ptr, size_t osize, size_t nsize)
{
// ud can point to a memory manager struct
if (nsize == 0) {
free(ptr);
return nullptr;
}
return realloc(ptr, nsize);
}
lua_State* L = lua_newstate(myAlloc, nullptr);
After the state is closed, ensure your allocator reports the final memory footprint to confirm reclamation.
Sandboxing and Library Management
Sandboxing is achieved by loading only the libraries you trust. The luaL_openlibs helper loads all standard libs; to exclude os and io, push nil onto the stack and set the globals to nil as shown above. For more granular control, load libraries individually:
luaL_requiref(L, "base", luaopen_base, 1);
luaL_requiref(L, "table", luaopen_table, 1);
luaL_requiref(L, "string", luaopen_string, 1);
// skip luaopen_os, luaopen_io
Error Handling with lua_pcall
Always wrap script execution in a protected call. lua_pcall returns LUA_OK on success; otherwise it leaves an error message on the stack. This prevents a script error from propagating as a C++ exception or terminating the process.
int status = lua_pcall(L, 0, 0, 0);
if (status != LUA_OK) {
const char* err = lua_tostring(L, -1);
std::cerr << "Script error: " << err << std::endl;
lua_pop(L, 1);
}
Failure Modes and Mitigations
- Thread‑Safety:
lua_Stateis not thread‑safe. Use one state per thread or guard shared states with a mutex. - Large Data Transfer: Frequent pushes of large tables are expensive. Pack data into a single userdata block or use
lua_newuserdatato reference C++ objects directly. - Global Namespace Pollution: Avoid setting globals directly. Instead, create a dedicated environment table for each script and set it as the
_Gfor that state.
Operational Checks and Verification
- Run
luaL_dostring(L, "os.execute('echo hello')")and confirm the error message indicatesosis nil. - Execute a script with a syntax error inside
lua_pcalland verify the host prints the error but continues running. - Replace the default allocator with a custom one that tracks allocations. After
lua_close, confirm the allocation counter returns to zero.
When the Design Should Change
Consider the following conditions as triggers to revisit the architecture:
- Multiple scripts need to share state or data structures.
- Performance profiling shows stack operations dominate CPU usage.
- The application requires dynamic loading of new libraries at runtime.
- The host must expose C++ classes to Lua with full method binding.
In such cases, you might introduce a higher‑level scripting manager that pools states, implements reference counting for shared tables, or uses a more efficient foreign‑function interface like sol2 or luabind.
Conclusion
By isolating each script in its own lua_State, selectively opening libraries, and guarding execution with lua_pcall, you can embed Lua in a C++ application while keeping the host robust against script failures. Custom allocators give you control over memory usage, and simple runtime checks validate that your sandbox behaves as intended.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.