596 lines
15 KiB
Markdown
596 lines
15 KiB
Markdown
# 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
|
|
|
|
1. **LuaSandbox class** - Per-app isolated Lua state
|
|
2. **Custom allocator** - Memory tracking and limits
|
|
3. **Instruction hook** - CPU limit enforcement
|
|
4. **Globals removal** - Remove dangerous functions
|
|
5. **Bytecode prevention** - Text-only loading
|
|
6. **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
|
|
|
|
```cpp
|
|
// 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
|
|
|
|
```cpp
|
|
// 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
|
|
|
|
```cpp
|
|
// 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
|
|
|
|
```cpp
|
|
// 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
|
|
|
|
```cpp
|
|
// 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
|
|
|
|
```cpp
|
|
// 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
|
|
|
|
```cpp
|
|
// 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
|
|
|
|
```cpp
|
|
// 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`
|
|
```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**:
|
|
```cpp
|
|
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`
|
|
```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**:
|
|
```cpp
|
|
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`
|
|
```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**:
|
|
```cpp
|
|
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`
|
|
```lua
|
|
-- Infinite loop - should be stopped by instruction limit
|
|
while true do
|
|
-- busy loop
|
|
end
|
|
error("FAIL: Should never reach here")
|
|
```
|
|
|
|
**C++ Test**:
|
|
```cpp
|
|
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`
|
|
```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**:
|
|
```cpp
|
|
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`
|
|
```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`
|
|
```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
|
|
|
|
```bash
|
|
cd sandbox-test
|
|
cmake -B build -DCMAKE_TOOLCHAIN_FILE=%VCPKG_ROOT%/scripts/buildsystems/vcpkg.cmake
|
|
cmake --build build --config Debug
|
|
```
|
|
|
|
### Run All Tests
|
|
|
|
```bash
|
|
./build/Debug/sandbox-test.exe
|
|
```
|
|
|
|
### Run Single Test
|
|
|
|
```bash
|
|
./build/Debug/sandbox-test.exe --test DangerousGlobalsRemoved
|
|
```
|
|
|
|
### Test Output Format
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
- [x] `TEST(LuaSandbox, DangerousGlobalsRemoved)` - os/io/debug/etc are nil
|
|
- [x] `TEST(LuaSandbox, BytecodeRejected)` - Binary chunks rejected
|
|
- [x] `TEST(LuaSandbox, MemoryLimitEnforced)` - OOM before system impact
|
|
- [x] `TEST(LuaSandbox, CPULimitEnforced)` - Infinite loops stopped
|
|
- [x] `TEST(LuaSandbox, MetatableProtected)` - _G frozen, string mt protected
|
|
- [x] `TEST(LuaSandbox, SafeOperationsWork)` - Normal Lua code works
|
|
- [x] `TEST(LuaSandbox, StringDumpRemoved)` - string.dump is nil
|
|
|
|
---
|
|
|
|
## Security Audit Checklist
|
|
|
|
After implementation, verify:
|
|
|
|
- [x] No way to access `os.execute` or equivalent
|
|
- [x] No way to load bytecode
|
|
- [x] No way to read arbitrary files
|
|
- [x] Memory exhaustion causes graceful failure
|
|
- [x] CPU exhaustion causes graceful failure
|
|
- [x] Cannot escape sandbox via metatable tricks
|
|
- [x] Cannot modify protected globals
|
|
|
|
---
|
|
|
|
## Next Steps
|
|
|
|
After Milestone 1 passes:
|
|
1. Integrate with kernel (replace global lua_State)
|
|
2. Add permission system (Milestone 2)
|
|
3. Add audit logging (Milestone 3)
|