PcoWSkbVqDnWTu_dm2ix
We use cookies on this site to enhance your user experience

Jul 02 2018, 5:20 PM PST 10 min

“Sandboxing” is a method of running potentially unsafe code within a “sandbox”, such that it cannot actually cause any real damage.

The primary need for sandboxing in Roblox is in script builders: the player types in a script, and you want to run it. However, there are some things you really don’t want their script to do. For example:

  • kick players from the game
  • kill other players
  • delete important parts of your place

So how do we do that?

A naive approach

An approach taken by many would be to do some pattern matching. For example, you could detect the bad line in this code:

local codeToExecute = [[
game.Players.SomePlayer:Destroy()
]]

with the code:

if codetoexecute:match("game\.Players\.w+:Destroy()") then
    print "Won't execute - tried to take out a player"
else
    loadstring(codetoexecute)()
end

However, that’s not going to stop most cases. It won’t stop:

  • Use of variables
    local p = game.Players.SomePlayer
    p:Destroy()
    
  • Use of string keys
    local harmless = "Players"
    local harmless2 = "SomePlayer"
    game[harmless][harmless2]:Destroy()
    
  • Calling a method from one object on another
    Instance.new("Part").Destroy(game.Players.SomePlayer)
    

Function enviroments

We can run code in a completely sandboxed environment using setfenv:

function runInSandbox(f)
    --get the old function environment
    local oldenv = getfenv(f)

    --create a new one
    local newenv = setmetatable({}, {
        __index = function(_, k)
            if k:lower() == "game" or k:lower() == "workspace" or k == "Instance" then
                --that prevents any instances being accessed
                return nil
            else
                --but otherwise defaults to the old one
                return oldenv[k]
            end
        end
    })

    --call the function in its new environment
    setfenv(f, newenv)
    f()
end

Now we can run arbitrary code completely safely:

runInSandbox(function()
    if game then
        game.Players.SomePlayer:Destroy()
    else
        print("Oh no, I can't be evil!")
    end
end)

However, this code is too sandboxed. The only thing the code can do is print. We need to give the script access to some instances, but not others.

Breaking out of the sandbox

The most important part of building a script sandbox is preventing the script escaping the sandbox. From the earlier examples, it can be seen that as soon as a script gets hold of an instance, it can do anything it pleases. Where can a script get hold of an instance then?

  • Global environment
    • Instance.new
    • game
    • workspace
    • script
    • require(game.SomeModuleScript).someInstanceKey
  • Properties of other instances
  • Return values of instance methods
  • Arguments to event handlers

Intercepting all accesses to outside the sandbox

In order to make most accesses work, we need to ensure that:

  • local y ## obj.Property -> local y sandbox(unsandbox(obj).Property)
  • obj.Property ## y -> unsandbox(obj).Property unsandbox(y)

We also need to take care with functions:

  • sandbox(f) -> function(...) return sandbox(f(unsandbox(...))) end, so that native methods can deal with sandboxed arguments, but don’t leak raw values
  • unsandbox(f) -> function(...) return unsandbox(f(sandbox(...))) end, so that callback functions have their arguments sandboxed before being invoked

And tables, which require all their keys and values to be sandboxed/unsandboxed.

Making Instances safe

-- TODO: return true if instance or event
local needssandboxing = function(o) return typeof(o) == "Instance" end 
sandbox = {}

-- A map of sandboxed items to their original counterparts.
-- The cache uses weak references so sandboxed objects can be collected if there are no references left to them anywhere else.
sandbox.cache = setmetatable({}, {__mode = "kv"})

-- Since below we used sandbox.any(safeDestroy) we don't need to get the original here
-- The passed argument "obj" is already the unsandboxed instance
local function safeDestroy(obj)
    if obj:IsA("Player") then
        error("You cannot destroy a Player") -- simple error
    end obj:Destroy()
end
-- Let's try something more difficult: Hiding instances from GetChildren
-- For example: Let's hide everything which name starts with "Hidden"
-- If you have a model with those 4 instances: PartA, PartB, HiddenPart and PartC
-- Using GetChildren on the model would return a table with: PartA, PartB and PartC
local function safeGetChildren(obj)
    local res = {}
    for k,v in pairs(obj:GetChildren()) do
    if not v.Name:match("^Hidden") then -- "^Hidden" checks if it starts with "Hidden", then inverse it with "not"
            table.insert(res,v) -- Name doesn't start with "Hidden", so let's add it to the results
        end
    end
    return res -- Since we use sandbox.any(safeGetChildren) below, this table will be automaticly sandboxed
end

sandbox.mt = {
    __index = function(self, k)
        local original = sandbox.cache[self]

        -- todo: add logic here to filter property/method reading

        local v = original[k]

        -- example: filtering destroy to not work on players
        if k:lower() == "destroy" then
            return sandbox.any(safeDestroy) -- easier for above
        elseif k:lower() == "getchildren" or k:lower() == "children" then
            return sandbox.any(safeGetChildren) -- easier for above
        end

        return sandbox.any(v)
    end,
    __newindex = function(self, k, v)
        local original = sandbox.cache[self]

        -- todo: add logic here to filter property writing

        original[k] = unsandbox.any(v)
    end
}
--sandbox any object
function sandbox.any(a)
    if sandbox.cache[a] then
        -- already sandboxed
        return a
    elseif type(a) == "function" then
        return sandbox.func(a)
    elseif type(a) == "table" then
        return sandbox.table(a)
    elseif needssandboxing(a) then
        return sandbox.object(a)
    else
        --doesn't need sandboxing
        return value
    end
end
--sandbox instances and events
function sandbox.object(o)
    local sandboxed = setmetatable({}, sandbox.mt)
    sandbox.cache[sandboxed] = o
    return sandboxed
end
--sandbox a function
function sandbox.func(f)
    local sandboxed = function(...)
        return sandbox(f(unsandbox(...)))
    end
    sandbox.cache[sandboxed] = f
    return sandboxed
end
--sandbox a table. TODO: prevent crash on recursive tables.
function sandbox.table(t)
    local sandboxed = {}
    for k, v in pairs(t) do
        --by sandboxing every key and every value
        sandboxed[sandbox.any(k)] = sandbox.any(v)
    end
    return sandboxed
end
unsandbox = {}
--unsandbox any objects
unsandbox.any = function(a)
    if sandbox.cache[a] then
        --if we have it cached, return it
        return sandbox.cache[a]
    elseif type(a) == "function" then
        return unsandbox.func(a)
    elseif type(a) == "table"
        return unsandbox.table(a)
    else
        return a
    end
end
--unsandbox a table. TODO: prevent crash on recursive tables.
unsandbox.table = function(t)
    local unsandboxed = {}
    for k, v in pairs(t) do
        --by unsandboxing every key and every value
        unsandboxed[unsandbox.any(k)] = unsandbox.any(v)
    end
    return unsandboxed
end
--unsandbox a function (sandboxed -> sandboxed), such as one passed to an event handler, making it (raw -> raw)
unsandbox.func = function(f)
    local raw = function(...)
        return unsandbox(f(sandbox(...)))
    end
    sandbox.cache[f] = raw 
    return raw
end

-- make sandbox and unsandbox function acting on tuples
local callable_mt = {
   __call = function(self, first, ...)
       if select("#", ...) == 0 then
           return self.any(first)
       else
           return self.any(first), self(...)
       end
   end
}

setmetatable(sandbox, callable_mt)
setmetatable(unsandbox, callable_mt)
Tags:
  • debugging
  • security
  • coding