Skip to content

Commit 507a7a1

Browse files
committed
lua states now always start in a sandbox where all dangerous stdLib objects are removed
1 parent c22cc18 commit 507a7a1

4 files changed

Lines changed: 135 additions & 7 deletions

File tree

Changelog.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11

22
# Change Log
33

4+
## [0.91.6] - 2026-06-06
5+
6+
### Security
7+
8+
- Embedded Lua scripts are now run in a restricted sandbox. The core now opens only a subset of safe libraries (`base`, `table`, `string`, `math`, `coroutine`, and `utf8` on Lua 5.3+) and strips the dangerous globals (`os`, `io`, `package`, `debug`, `require`, `load`, `loadstring`, `loadfile`, `dofile`, `collectgarbage`.)
9+
10+
### Fixed
11+
12+
- The standalone build now properly checks for either SQLite or JSON serialization support
13+
414
## [0.91.4] - 2023-02-20
515

616
### Changed

src/core/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ set(CMAKE_MODULE_PATH "${CMAKE_MODULE_PATH};${CMAKE_CURRENT_SOURCE_DIR}/cmake")
66
# Current version of the library
77
set(TROGDOR_VERSION_MAJOR 0)
88
set(TROGDOR_VERSION_MINOR 91)
9-
set(TROGDOR_VERSION_PATCH 5)
9+
set(TROGDOR_VERSION_PATCH 6)
1010

1111
configure_file(${CMAKE_CURRENT_LIST_DIR}/cmake/version.h.in ${CMAKE_CURRENT_BINARY_DIR}/version.h @ONLY)
1212

src/core/include/trogdor/lua/luastate.h

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,51 @@ namespace trogdor {
121121
L = luaL_newstate();
122122
}
123123

124+
/*
125+
Opens only a safe subset of the Lua standard library by loading
126+
everything, then stripping out anything we consider unsafe.
127+
128+
Input:
129+
(none)
130+
131+
Output:
132+
(none)
133+
*/
134+
inline void openSafeLibs() {
135+
136+
// Safe standard libraries: base (for pairs/type/tostring/pcall/...)
137+
// plus the pure table/string/math/coroutine helpers
138+
static const luaL_Reg safeLibs[] = {
139+
{"_G", luaopen_base},
140+
{LUA_TABLIBNAME, luaopen_table},
141+
{LUA_STRLIBNAME, luaopen_string},
142+
{LUA_MATHLIBNAME, luaopen_math},
143+
{LUA_COLIBNAME, luaopen_coroutine},
144+
#if LUA_VERSION_NUM >= 503
145+
{LUA_UTF8LIBNAME, luaopen_utf8},
146+
#endif
147+
{nullptr, nullptr}
148+
};
149+
150+
for (const luaL_Reg *lib = safeLibs; lib->func; lib++) {
151+
luaL_requiref(L, lib->name, lib->func, 1);
152+
lua_pop(L, 1); // luaL_requiref leaves the module on the stack
153+
}
154+
155+
// Strip dangerous globals from the base library
156+
static const char *unsafeGlobals[] = {
157+
"load", "loadstring", "loadfile", "dofile",
158+
"require", "collectgarbage", "package",
159+
"os", "io", "debug",
160+
nullptr
161+
};
162+
163+
for (const char **name = unsafeGlobals; *name; name++) {
164+
lua_pushnil(L);
165+
lua_setglobal(L, *name);
166+
}
167+
}
168+
124169
/*
125170
Opens libraries and registers API stuff.
126171
@@ -132,12 +177,8 @@ namespace trogdor {
132177
*/
133178
inline void initLibs() {
134179

135-
// load standard library
136-
// TODO: only open certain standard libraries. I don't, for example,
137-
// want to allow things like os.exit(). Hold off on this until I
138-
// migrate to Lua 5.2+. The answer to this seems to be here:
139-
// https://stackoverflow.com/questions/4551101/lual-openlibs-and-sandboxing-scripts
140-
luaL_openlibs(L);
180+
// Open only a safe subset of the standard library
181+
openSafeLibs();
141182

142183
// register Game type as well as a global instance of it
143184
// corresponding to the game that spawned the instance of LuaState

src/core/test/lua/luastate.cpp

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,83 @@ TEST_SUITE("LuaState (luastate.cpp)") {
539539
// TODO: test blank string, string with syntax errors, and string with valid Lua
540540
}
541541

542+
TEST_CASE("LuaState (luastate.cpp): Lua sandbox: dangerous stdlib is removed") {
543+
544+
std::unique_ptr<trogdor::Game> game = std::make_unique<trogdor::Game>(
545+
std::make_unique<trogdor::NullErr>()
546+
);
547+
548+
trogdor::LuaState L(game.get());
549+
550+
// Every dangerous global must be absent from an untrusted script's view.
551+
// The script returns true only if all of them are nil.
552+
L.loadScriptFromString(
553+
"function sandboxCheck()\n"
554+
" local blocked = {\n"
555+
" 'os', 'io', 'package', 'debug', 'require',\n"
556+
" 'load', 'loadstring', 'loadfile', 'dofile', 'collectgarbage'\n"
557+
" }\n"
558+
" for _, name in pairs(blocked) do\n"
559+
" if _G[name] ~= nil then return false end\n"
560+
" end\n"
561+
" return true\n"
562+
"end\n"
563+
);
564+
565+
L.call("sandboxCheck");
566+
L.execute(1);
567+
568+
if (!L.getBoolean(0)) {
569+
FAIL("A dangerous standard-library global is still exposed to Lua scripts.");
570+
}
571+
}
572+
573+
TEST_CASE("LuaState (luastate.cpp): Lua sandbox: safe stdlib remains available") {
574+
575+
std::unique_ptr<trogdor::Game> game = std::make_unique<trogdor::Game>(
576+
std::make_unique<trogdor::NullErr>()
577+
);
578+
579+
trogdor::LuaState L(game.get());
580+
581+
// The safe libraries (and core base functions) that bundled game
582+
// scripts rely on must still be present
583+
L.loadScriptFromString(
584+
"function safeLibsCheck()\n"
585+
" return type(string) == 'table'\n"
586+
" and type(table) == 'table'\n"
587+
" and type(math) == 'table'\n"
588+
" and type(pairs) == 'function'\n"
589+
" and type(tostring) == 'function'\n"
590+
" and type(pcall) == 'function'\n"
591+
"end\n"
592+
);
593+
594+
L.call("safeLibsCheck");
595+
L.execute(1);
596+
597+
if (!L.getBoolean(0)) {
598+
FAIL("A safe standard-library function expected by game scripts is missing.");
599+
}
600+
}
601+
602+
TEST_CASE("LuaState (luastate.cpp): Lua sandbox (C1): RCE attempt fails safely") {
603+
604+
std::unique_ptr<trogdor::Game> game = std::make_unique<trogdor::Game>(
605+
std::make_unique<trogdor::NullErr>()
606+
);
607+
608+
trogdor::LuaState L(game.get());
609+
610+
// A classic os.execute() RCE attempt must not run. Indexing the now nil
611+
// 'os' global should raise a Lua error (LuaException) rather than
612+
// executing a shell command or crashing.
613+
CHECK_THROWS_AS(
614+
L.loadScriptFromString("os.execute(\"echo pwned\")\n"),
615+
trogdor::LuaException
616+
);
617+
}
618+
542619
TEST_CASE("LuaState (luastate.cpp): All variants of pushArgument()") {
543620

544621
// TODO

0 commit comments

Comments
 (0)