15 KiB
15 KiB
Milestone 1: Core Sandbox Foundation
Status: ✅ Complete Goal: Create isolated Lua environments with resource limits and dangerous global removal.
Overview
This milestone establishes the foundational security layer for the Mosis app sandbox. Every subsequent milestone builds on this foundation.
Key Deliverables
- LuaSandbox class - Per-app isolated Lua state
- Custom allocator - Memory tracking and limits
- Instruction hook - CPU limit enforcement
- Globals removal - Remove dangerous functions
- Bytecode prevention - Text-only loading
- Metatable protection - Freeze globals
File Structure
src/main/cpp/sandbox/
├── lua_sandbox.h # Main header
├── lua_sandbox.cpp # Implementation
└── sandbox_tests.h # Test declarations (for test runner)
sandbox-test/
├── CMakeLists.txt # Test build config
├── README.md # Test documentation
├── src/
│ ├── main.cpp # Test runner entry
│ ├── test_harness.h # Test framework
│ └── test_harness.cpp # Test framework impl
└── scripts/
├── test_globals_removed.lua
├── test_bytecode_rejected.lua
├── test_memory_limit.lua
├── test_cpu_limit.lua
├── test_metatable_protected.lua
├── test_safe_operations.lua
└── test_string_dump_removed.lua
Implementation Details
1. SandboxLimits Structure
// lua_sandbox.h
struct SandboxLimits {
size_t memory_bytes = 16 * 1024 * 1024; // 16 MB default
size_t max_string_size = 1 * 1024 * 1024; // 1 MB max string
size_t max_table_entries = 100000; // Prevent hash DoS
uint64_t instructions_per_call = 1000000; // ~10ms execution
int stack_depth = 200; // Recursion limit
};
2. SandboxContext Structure
// lua_sandbox.h
struct SandboxContext {
std::string app_id;
std::string app_path;
std::vector<std::string> permissions;
bool is_system_app = false;
};
3. LuaSandbox Class
// lua_sandbox.h
class LuaSandbox {
public:
explicit LuaSandbox(const SandboxContext& context,
const SandboxLimits& limits = {});
~LuaSandbox();
// Non-copyable
LuaSandbox(const LuaSandbox&) = delete;
LuaSandbox& operator=(const LuaSandbox&) = delete;
// Load and execute Lua code
bool LoadString(const std::string& code, const std::string& chunk_name = "chunk");
bool LoadFile(const std::string& path);
// State access
lua_State* GetState() const { return m_L; }
const std::string& GetLastError() const { return m_last_error; }
// Resource usage
size_t GetMemoryUsed() const { return m_memory_used; }
uint64_t GetInstructionsUsed() const { return m_instructions_used; }
// Context access
const SandboxContext& GetContext() const { return m_context; }
const std::string& app_id() const { return m_context.app_id; }
// Reset instruction counter (call before each event handler)
void ResetInstructionCount();
private:
void SetupSandbox();
void RemoveDangerousGlobals();
void ProtectBuiltinTables();
void SetupInstructionHook();
static void* SandboxAlloc(void* ud, void* ptr, size_t osize, size_t nsize);
static void InstructionHook(lua_State* L, lua_Debug* ar);
lua_State* m_L = nullptr;
SandboxContext m_context;
SandboxLimits m_limits;
size_t m_memory_used = 0;
uint64_t m_instructions_used = 0;
std::string m_last_error;
};
4. Custom Memory Allocator
// lua_sandbox.cpp
void* LuaSandbox::SandboxAlloc(void* ud, void* ptr, size_t osize, size_t nsize) {
auto* sandbox = static_cast<LuaSandbox*>(ud);
// Calculate new usage
size_t new_usage = sandbox->m_memory_used - osize + nsize;
// Check limit (only when allocating, not freeing)
if (nsize > 0 && new_usage > sandbox->m_limits.memory_bytes) {
return nullptr; // Allocation fails, triggers Lua memory error
}
// Update tracking
sandbox->m_memory_used = new_usage;
// Free
if (nsize == 0) {
free(ptr);
return nullptr;
}
// Alloc or realloc
return realloc(ptr, nsize);
}
5. Instruction Hook
// lua_sandbox.cpp
void LuaSandbox::InstructionHook(lua_State* L, lua_Debug* ar) {
// Get sandbox from registry
lua_getfield(L, LUA_REGISTRYINDEX, "__sandbox");
auto* sandbox = static_cast<LuaSandbox*>(lua_touserdata(L, -1));
lua_pop(L, 1);
if (!sandbox) return;
// Increment by hook interval (1000 instructions)
sandbox->m_instructions_used += 1000;
// Check limit
if (sandbox->m_instructions_used > sandbox->m_limits.instructions_per_call) {
luaL_error(L, "instruction limit exceeded (%llu instructions)",
sandbox->m_instructions_used);
}
}
void LuaSandbox::SetupInstructionHook() {
lua_sethook(m_L, InstructionHook, LUA_MASKCOUNT, 1000);
}
6. Dangerous Globals Removal
// lua_sandbox.cpp
void LuaSandbox::RemoveDangerousGlobals() {
const char* dangerous_globals[] = {
// Code execution
"dofile", "loadfile", "load", "loadstring",
// Raw access (bypass metatables)
"rawget", "rawset", "rawequal", "rawlen",
// Metatable manipulation (removed for protection)
"getmetatable", "setmetatable",
// GC manipulation
"collectgarbage",
// Dangerous libraries
"os", "io", "debug", "package",
// LuaJIT/FFI (if present)
"ffi", "jit", "newproxy",
nullptr
};
for (const char** p = dangerous_globals; *p; ++p) {
lua_pushnil(m_L);
lua_setglobal(m_L, *p);
}
// Remove require (we'll add safe version later)
lua_pushnil(m_L);
lua_setglobal(m_L, "require");
// Remove string.dump (creates bytecode)
lua_getglobal(m_L, "string");
if (lua_istable(m_L, -1)) {
lua_pushnil(m_L);
lua_setfield(m_L, -2, "dump");
}
lua_pop(m_L, 1);
}
7. Metatable Protection
// lua_sandbox.cpp
void LuaSandbox::ProtectBuiltinTables() {
// Protect string metatable
lua_pushstring(m_L, "");
if (lua_getmetatable(m_L, -1)) {
lua_pushstring(m_L, "string");
lua_setfield(m_L, -2, "__metatable");
lua_pop(m_L, 1); // pop metatable
}
lua_pop(m_L, 1); // pop string
// Freeze _G with protective metatable
lua_pushglobaltable(m_L);
lua_newtable(m_L); // metatable for _G
// __metatable prevents access via getmetatable
lua_pushstring(m_L, "globals");
lua_setfield(m_L, -2, "__metatable");
// __newindex prevents modification
lua_pushcfunction(m_L, [](lua_State* L) -> int {
const char* key = lua_tostring(L, 2);
return luaL_error(L, "cannot modify global '%s'", key ? key : "(unknown)");
});
lua_setfield(m_L, -2, "__newindex");
lua_setmetatable(m_L, -2);
lua_pop(m_L, 1); // pop _G
}
8. Load with Bytecode Prevention
// lua_sandbox.cpp
bool LuaSandbox::LoadString(const std::string& code, const std::string& chunk_name) {
// Reset instruction count for this execution
ResetInstructionCount();
// Load as TEXT ONLY - "t" mode rejects bytecode
int result = luaL_loadbufferx(m_L, code.c_str(), code.size(),
chunk_name.c_str(), "t");
if (result != LUA_OK) {
m_last_error = lua_tostring(m_L, -1);
lua_pop(m_L, 1);
return false;
}
// Execute
result = lua_pcall(m_L, 0, 0, 0);
if (result != LUA_OK) {
m_last_error = lua_tostring(m_L, -1);
lua_pop(m_L, 1);
return false;
}
return true;
}
Test Cases
Each test has a corresponding Lua script and C++ test function.
Test 1: Dangerous Globals Removed
Script: scripts/test_globals_removed.lua
-- Test that dangerous globals are nil
local dangerous = {
"os", "io", "debug", "package", "ffi", "jit",
"dofile", "loadfile", "load", "loadstring",
"rawget", "rawset", "rawequal", "rawlen",
"collectgarbage", "newproxy", "require"
}
for _, name in ipairs(dangerous) do
local value = _G[name]
if value ~= nil then
error("FAIL: " .. name .. " should be nil but is " .. type(value))
end
end
print("PASS: All dangerous globals removed")
C++ Test:
TEST(LuaSandbox, DangerousGlobalsRemoved) {
LuaSandbox sandbox(TestContext());
EXPECT_TRUE(sandbox.LoadFile("scripts/test_globals_removed.lua"));
}
Test 2: Bytecode Rejected
Script: scripts/test_bytecode_rejected.lua
-- This script tests that bytecode loading is blocked
-- The actual bytecode test is done from C++ side
print("PASS: Text loading works")
C++ Test:
TEST(LuaSandbox, BytecodeRejected) {
LuaSandbox sandbox(TestContext());
// Lua bytecode starts with signature: \x1bLua
std::string bytecode = "\x1bLua\x54\x00\x19\x93"; // Fake bytecode header
EXPECT_FALSE(sandbox.LoadString(bytecode, "bytecode_test"));
EXPECT_TRUE(sandbox.GetLastError().find("binary") != std::string::npos ||
sandbox.GetLastError().find("attempt to load") != std::string::npos);
}
Test 3: Memory Limit Enforced
Script: scripts/test_memory_limit.lua
-- This script intentionally tries to exhaust memory
-- Should fail with memory error
local t = {}
local i = 0
while true do
i = i + 1
t[i] = string.rep("x", 100000) -- 100KB strings
if i > 1000 then
error("FAIL: Should have hit memory limit by now")
end
end
C++ Test:
TEST(LuaSandbox, MemoryLimitEnforced) {
SandboxLimits limits;
limits.memory_bytes = 1024 * 1024; // 1 MB limit
LuaSandbox sandbox(TestContext(), limits);
EXPECT_FALSE(sandbox.LoadFile("scripts/test_memory_limit.lua"));
// Should fail due to memory allocation failure
}
Test 4: CPU Limit Enforced
Script: scripts/test_cpu_limit.lua
-- Infinite loop - should be stopped by instruction limit
while true do
-- busy loop
end
error("FAIL: Should never reach here")
C++ Test:
TEST(LuaSandbox, CPULimitEnforced) {
SandboxLimits limits;
limits.instructions_per_call = 10000; // Very low limit
LuaSandbox sandbox(TestContext(), limits);
EXPECT_FALSE(sandbox.LoadFile("scripts/test_cpu_limit.lua"));
EXPECT_TRUE(sandbox.GetLastError().find("instruction") != std::string::npos);
}
Test 5: Metatable Protected
Script: scripts/test_metatable_protected.lua
-- Test 1: String metatable is protected
local mt = getmetatable("")
if mt ~= "string" then
error("FAIL: string metatable should return 'string', got " .. tostring(mt))
end
-- Test 2: Cannot modify global environment
local ok, err = pcall(function()
_G.my_new_global = "test"
end)
if ok then
error("FAIL: Should not be able to add globals")
end
-- Test 3: Cannot modify existing globals
local ok2, err2 = pcall(function()
_G.print = nil
end)
if ok2 then
error("FAIL: Should not be able to modify print")
end
print("PASS: Metatables protected")
C++ Test:
TEST(LuaSandbox, MetatableProtected) {
LuaSandbox sandbox(TestContext());
EXPECT_TRUE(sandbox.LoadFile("scripts/test_metatable_protected.lua"));
}
Test 6: Safe Operations Work
Script: scripts/test_safe_operations.lua
-- Test that safe operations still work
-- Math operations
local x = math.sin(1.5) + math.floor(3.7)
assert(type(x) == "number", "Math failed")
-- String operations
local s = string.format("hello %d", 42)
assert(s == "hello 42", "String format failed")
local upper = string.upper("test")
assert(upper == "TEST", "String upper failed")
-- Table operations
local t = {1, 2, 3}
table.insert(t, 4)
assert(#t == 4, "Table insert failed")
table.sort(t)
assert(t[1] == 1 and t[4] == 4, "Table sort failed")
-- Iteration
local count = 0
for i, v in ipairs(t) do count = count + 1 end
assert(count == 4, "ipairs failed")
count = 0
for k, v in pairs({a=1, b=2, c=3}) do count = count + 1 end
assert(count == 3, "pairs failed")
-- Error handling
local ok, err = pcall(function() error("test error") end)
assert(not ok, "pcall should catch error")
assert(err:find("test error"), "Error message wrong")
-- Type checks
assert(type({}) == "table", "type table failed")
assert(type("") == "string", "type string failed")
assert(type(123) == "number", "type number failed")
assert(type(function() end) == "function", "type function failed")
-- tonumber/tostring
assert(tonumber("42") == 42, "tonumber failed")
assert(tostring(42) == "42", "tostring failed")
-- select
local a, b = select(2, 1, 2, 3)
assert(a == 2 and b == 3, "select failed")
-- assert works
assert(true, "assert failed")
print("PASS: All safe operations work")
Test 7: string.dump Removed
Script: scripts/test_string_dump_removed.lua
-- string.dump can create bytecode, should be removed
if string.dump ~= nil then
error("FAIL: string.dump should be nil")
end
print("PASS: string.dump removed")
Build & Test Commands
Build
cd sandbox-test
cmake -B build -DCMAKE_TOOLCHAIN_FILE=%VCPKG_ROOT%/scripts/buildsystems/vcpkg.cmake
cmake --build build --config Debug
Run All Tests
./build/Debug/sandbox-test.exe
Run Single Test
./build/Debug/sandbox-test.exe --test DangerousGlobalsRemoved
Test Output Format
{
"name": "Lua Sandbox Security Tests",
"timestamp": "2024-01-15T10:30:00Z",
"summary": {
"passed": 7,
"failed": 0,
"total": 7
},
"tests": [
{
"name": "DangerousGlobalsRemoved",
"status": "passed",
"duration_ms": 5
},
...
]
}
Acceptance Criteria
All tests must pass:
TEST(LuaSandbox, DangerousGlobalsRemoved)- os/io/debug/etc are nilTEST(LuaSandbox, BytecodeRejected)- Binary chunks rejectedTEST(LuaSandbox, MemoryLimitEnforced)- OOM before system impactTEST(LuaSandbox, CPULimitEnforced)- Infinite loops stoppedTEST(LuaSandbox, MetatableProtected)- _G frozen, string mt protectedTEST(LuaSandbox, SafeOperationsWork)- Normal Lua code worksTEST(LuaSandbox, StringDumpRemoved)- string.dump is nil
Security Audit Checklist
After implementation, verify:
- No way to access
os.executeor equivalent - No way to load bytecode
- No way to read arbitrary files
- Memory exhaustion causes graceful failure
- CPU exhaustion causes graceful failure
- Cannot escape sandbox via metatable tricks
- Cannot modify protected globals
Next Steps
After Milestone 1 passes:
- Integrate with kernel (replace global lua_State)
- Add permission system (Milestone 2)
- Add audit logging (Milestone 3)