Let’s build something Outrageous – Part 23: Sandboxing

The idea of using a programing language as the way to write queries against the database makes many security folks hyperventilate. In order to lower their heart-rate and slow their breathing we have to limit the queries using a technique known as “sandboxing“. The Sol2 library we are using in RageDB lets us create an “environment” where our queries will run. Let’s see how we go about doing this.

But before we do, we have to thank Redis for paving the way. Redis creator Salvatore Sanfilippo wrote a post about this topic when cloud providers started hosting multiple databases on the same server which made sandboxing instances much more important. Recently Reginaldo Silva found that on some systems you could break out of the existing Redis sandbox. There is more discussion about this on Hacker News, but it boils down to Redis customizing the Lua source code on some cases and dynamically linking to it on other cases.

If we try to run Reginaldos proof of concept test in RageDB:

local os_l = package.loadlib("/usr/lib/x86_64-linux-gnu/liblua5.1.so", "luaopen_os")
local os = os_l()
os.execute("touch /tmp/rage_poc");
"Sandbox Escape"

We end up with an error message that says it doesn’t have the function “package”.

So how did we remove the package function? At the bottom of our Shard initialization, we override 3 “unsafe” lua functions with our own (more on that later), create a sandboxed environment:

        lua.set_function("loadstring", &Shard::loadstring, this);
        lua.set_function("loadfile", &Shard::loadfile, this);
        lua.set_function("dofile", &Shard::dofile, this);

        auto sandbox = Sandbox(lua);
        env = sandbox.getEnvironment();

Later when we run the script, we use the environment “env” instead of lua directly:

this->lua_lock.for_write().lock().get();
  try {
    script_result = lua.safe_script(executable, env,[] (lua_State *, sol::protected_function_result pfr) {
      return pfr;
    });
    this->lua_lock.for_write().unlock();

To build this environment we’re going to follow along an example written by Ruben Wardy. The first thing we do is create a new environment and set the global variable “_G” for globals:

  void Sandbox::buildEnvironment() {
    env = sol::environment(lua, sol::create);
    env["_G"] = env;

At this point we have zero functions which makes our environment super safe and super useless. So we’ll add the safe global functions back in various categories:

    copyAll(env, lua.globals(), ALLOWED_LUA_FUNCTIONS);
    copyTables(env, lua.globals(), lua, ALLOWED_LUA_LIBRARIES);
    copyAll(env, lua.globals(), ALLOWED_CUSTOM_FUNCTIONS);
    copyAll(env, lua.globals(), ALLOWED_GRAPH_OBJECTS);
    copyAll(env, lua.globals(), ALLOWED_GRAPH_FUNCTIONS);
    copyAll(env, lua.globals(), RESTRICTED_LUA_FUNCTIONS);

The first is the safe Lua functions as described in this Lua Sandboxing wiki guide.

inline static const std::vector<std::string> ALLOWED_LUA_FUNCTIONS = {
      "assert", "error", "ipairs", "next", "pairs", "pcall",
      "print", "select", "tonumber", "tostring", "type",
      "unpack", "_VERSION", "xpcall"

Next are the safe Lua libraries which are just “string”, “table” and “math”. Then comes our custom functions which are “json”, “date” and “ftcsv” which provide JSON functions, Datetime types, and the CSV loader we created in a previous blog post. The graph objects are “Direction”, “Link”, “Node”, “Operation”, “Relationship” and “Roar” which we also created in a previous blog post. The allowed graph functions are our API to the graph data: “NodeGet”, “RelationshipAdd”, “LinkGetLinks”, etc. Take a look at the whole list in Sandbox.h. Finally we have our “restricted” Lua functions which are the “loadstring”, “loadfile”, “dofile” functions we saw earlier. Finally we add a limited set of functions from the “os” and “io” packages:

    sol::table os(lua, sol::create);
    os["clock"] = lua["os"]["clock"];
    os["date"] = lua["os"]["date"];
    os["difftime"] = lua["os"]["difftime"];
    os["time"] = lua["os"]["time"];
    env["os"] = os;

    sol::table io(lua, sol::create);
    io["read"] = lua["io"]["read"];
    io["type"] = lua["io"]["type"];
    env["io"] = io;

You’ll notice the “package” global function was not included and that’s why the sandbox escape query returned an error when we tried to call it earlier. Now let’s take a look at “loadstring”. It checks to see if the string being loaded is pre-compiled Lua bytecode that starts with <esc>:

    std::tuple<sol::object, sol::object> Shard::loadstring(const std::string &str, const sol::optional<std::string> &chunkname) {

      if (!str.empty() && str[0] == LUA_SIGNATURE[0]) {
        return std::make_tuple(sol::nil,
          sol::make_object(lua, "Bytecode prohibited by Lua sandbox"));
      }

      sol::load_result result = lua.load(str, chunkname.value(), sol::load_mode::text);
      if (result.valid()) {
        sol::function func = result;
        env.set_on(func);
        return std::make_tuple(func, sol::nil);

You can find the LUA_SIGNATURE in the Lua codebase. “x1b” is hex for the number 27 which is the “escape” sequence:

/* mark for precompiled code ('<esc>Lua') */
#define LUA_SIGNATURE   "\x1bLua

If a query tries to load any precompiled code, we’ll find it and prohibit it from running. We can test this out with the following query given by the man, the myth, the legend Mike Pall himself:

local sandbox_test = select(2, loadstring("\027")):match("binary") and "VULNERABLE" or "OK"
sandbox_test

When we try running it in RageDB, we get “OK”. See for yourself:

It does allow regular string based Lua to be evaluated and run within our environment so it stays in our sandbox. For example the y function here is perfectly valid, but the first loadstring and the loadfile beneath are not:

i = 1
local x = loadstring("\027", "hey")
local y = loadstring("i = i + 1")
local z1, z2 = loadfile("/etc/passwd")
y()
x, z1, z2, i

When we run this query, you’ll see that our “i” goes from 1 to 2, but x and z1 are null while z2 returns the error message from trying to load the password file:

Lets jump to “loadfile” now to see that it is calling “checkPath” and if it is not allowed returning null for the result and an error in the tuple:

    std::tuple<sol::object, sol::object> Shard::loadfile(const std::string &path) {
      if (!checkPath(path, false)) {
        return std::make_tuple(sol::nil,
          sol::make_object(
            lua, "Path is not allowed by the Lua sandbox"));
      }

The checkPath function takes a read/write boolean and then checks the allowed paths for reading and writing from RageDB. If we are within a folder inside any of the paths (lexically_normal) , it allows it otherwise it returns false.

    bool Shard::checkPath(const std::string &filepath, bool write) {
      for (auto basePath : write ? WRITE_PATHS : READ_PATHS) {
        auto base = std::filesystem::absolute(basePath).lexically_normal();
        auto path = std::filesystem::absolute(filepath).lexically_normal();
        auto [rootEnd, nothing] = std::mismatch(base.begin(), base.end(), path.begin());
        if (rootEnd == base.end()) {
          return true;
        }
      }

      return false;
    }

Right now I’ve decided to leave WRITE_PATHS and READ_PATHS as empty vectors, but we’re giving ourselves the infrastructure to allow this capability in the future. This closes Issue #7 on our repository. If you are interested in helping me with this project, please join the Slack. If you are enjoying this blog post series, make sure to follow me on Twitter so you don’t miss any updates.

Tagged , , , , ,

Leave a comment