diff --git a/default.project.json b/default.project.json new file mode 100644 index 0000000..a89c9f4 --- /dev/null +++ b/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "H3x", + "tree": { + "$className": "DataModel" + } +} \ No newline at end of file diff --git a/src/H3xScriptSandbox/Context.lua b/src/H3xScriptSandbox/Context.lua new file mode 100644 index 0000000..747398d --- /dev/null +++ b/src/H3xScriptSandbox/Context.lua @@ -0,0 +1,235 @@ +-- H3x V1.2 +local Context = {} + +Context.Nil = newproxy() + +local setmetatable = setmetatable +local next = next +local tostring = tostring +local setfenv = setfenv +local loadstring = loadstring +local getfenv = getfenv +local pairs = pairs +local coroutine = coroutine +local assert = assert +local require = require +local script = script +local print = print +local warn = warn +local error = error +local debug = debug + +local _IS_SHUTDOWN = false +local threads = {} +local function captureThread() + local thread = coroutine.running() + threads[thread] = coroutine.status(thread) + + if _IS_SHUTDOWN then + error("The script is shutdown.") -- Kill the thread + end +end + +local function Pointer(...) -- A pointer function. For any arguments you give it it returns them back without using any tables or table functions. + local func + func = coroutine.wrap(function(...) -- Create a coroutine. + captureThread() + coroutine.yield(func) -- Yield the coroutine + return ... + end) + return func(...) +end + +local function wrapFunction(func) + local f = coroutine.wrap(function(...) + captureThread() + local result = function()end + while true do + result = Pointer(func(coroutine.yield(result()))) + end + end) + f() + + return f +end +local context +local runSafe +local communicateShutdown +local function isCFunction(func) + return not pcall(coroutine.wrap, func) +end +local function threadWatch(env) + if env then + local funcs = {} + return setmetatable({}, { + __index = function(_, index) + captureThread() + + local value = env[index] + return value + end, + __newindex = function(_, index, value) + captureThread() + + env[index] = value + end, + __metatable = getmetatable(env) + }) + end +end + +if script.Name == "__Context" then -- We are in a new instance of Context + Context.Libraries = {} + + context = coroutine.wrap(function() + captureThread() + + local id = tostring(context):sub(#"function: "+1) + + local function applyEnv(func, env, mergeMode) + if env then + --env = threadWatch(env) + if not mergeMode then + setfenv(0, env) + else + local cenv = getfenv(0) + + for index, value in pairs(env) do + if value == Context.Nil then + value = nil + end + cenv[index] = value + end + end + end + if func then + setfenv(func, getfenv(0)) + end + end + + setfenv(0, threadWatch(getfenv(0))) + applyEnv(nil, coroutine.yield(getfenv(0))) + + local __context_ = loadstring(coroutine.yield()) + + while true do + applyEnv(__context_, coroutine.yield(wrapFunction(__context_), getfenv(0))) + end + end) + context() + + runSafe = coroutine.wrap(function() + captureThread() + + local func + while true do + func = wrapFunction(coroutine.yield(func)) + end + end) + runSafe() + + communicateShutdown = coroutine.wrap(function() + _IS_SHUTDOWN = true + + for thread, oldStatus in pairs(threads) do + spawn(function() + pcall(function() + local steps = 0 + while coroutine.status(thread) ~= "dead" do -- Suspended, running, or normal. + if steps == 100 then + warn("A sandboxed thread may be inifitely yielding. Will try to invoke an error within ~30 seconds (900 resumes).") + elseif steps > 100 and steps < 1000 then + wait() + elseif steps >= 1000 then + warn("Failed to kill the thread allowing the thread to continue.") + return true + end + + local success = pcall(function() + coroutine.resume(thread) + steps = steps + 1 + end) + if not success then + wait() + end + end + end) + end) + end + end) +end +Context.ThreadWatcherImplemented = false -- Ignore me + +Context.Pointer = Pointer -- For external use +function Context:Create(code, env, mergeMode) + local scr = script:Clone() -- Make a new Context script + scr.Name = "__Context" -- Signal to this script that it will be an instance of Context. + local ctx = require(scr) -- Require the new instance + + if code then + assert(typeof(code) == "string", "The provided code must be a string.") + ctx:Load(code, env, mergeMode) + end + + return ctx +end + +function Context:Load(code, env, mergeMode) + assert(context, "Invalid context.") + self:SetEnvironment(env, mergeMode) -- Set the environment + Context.__context, Context.environment = context(code) -- Compile code and set the context + return Context.__context -- Return the context +end + +function Context:Execute(...) + assert(context, "Invalid context.") + assert(Context.__context, "Context isn't loaded.") + local ptr = Pointer(Context.__context(...)) -- Create pointer for return arguments + Context.__context, Context.environment = context() -- Regenerate context + return ptr() -- Get pointer value +end + +function Context:GetEnvironment() + assert(context, "Invalid context.") + + return Context.environment +end + +function Context:SetEnvironment(env, mergeMode) + assert(context, "Invalid context.") + Context.__context, Context.environment = context(env, mergeMode) -- Set the new context +end + +function Context:GetFunction() + assert(Context.__context, "Invalid context") + return Context.__context +end + +function Context:InjectFunction(func) + assert(context, "Invalid context.") + return runSafe(func) +end + +function Context:AddLibrary(name, lib) + assert(context, "Invalid context.") + Context.Libraries[name] = lib +end + +function Context:RemoveLibrary(name) + Context.Libraries[name] = nil +end + +function Context:Destroy() + if not _IS_SHUTDOWN then + setmetatable(Context, {__mode = "kv"}) + pcall(function() + self:GetEnvironment()() -- Gc environment + end) + self:SetEnvironment({}) + context = nil + runSafe = nil + communicateShutdown() + end +end + +return Context \ No newline at end of file diff --git a/src/H3xScriptSandbox/Runner/Dispatch/Shared.lua b/src/H3xScriptSandbox/Runner/Dispatch/Shared.lua new file mode 100644 index 0000000..3941af5 --- /dev/null +++ b/src/H3xScriptSandbox/Runner/Dispatch/Shared.lua @@ -0,0 +1 @@ +return {} \ No newline at end of file diff --git a/src/H3xScriptSandbox/Runner/Dispatch/init.server.lua b/src/H3xScriptSandbox/Runner/Dispatch/init.server.lua new file mode 100644 index 0000000..71ff467 --- /dev/null +++ b/src/H3xScriptSandbox/Runner/Dispatch/init.server.lua @@ -0,0 +1,12 @@ +local Shared = require(script:WaitForChild("Shared")) + +while true do + Shared.DispatchEvent = Shared.DispatchEvent or Instance.new("BindableEvent") + local argPtr = Shared.DispatchEvent.Event:Wait() + if Shared.DispatchFunction and Shared.CompleteEvent then + coroutine.wrap(function() + Shared.CompleteEvent:Fire(Shared.DispatchFunction(argPtr())) + end)() + script.Disabled = true + end +end \ No newline at end of file diff --git a/src/H3xScriptSandbox/Runner/init.lua b/src/H3xScriptSandbox/Runner/init.lua new file mode 100644 index 0000000..457f960 --- /dev/null +++ b/src/H3xScriptSandbox/Runner/init.lua @@ -0,0 +1,84 @@ +-- H3x V1.2 +local Context = require(script.Parent:WaitForChild("Context")) +local Dispatch = script:WaitForChild("Dispatch") +local Runner = {} + +function Runner:LoadFunction(func, ctx) + assert(func, "Please provide a function to load.") + assert(typeof(func) == "function", "Provided value isn't a function.") + local Script = {} + local newScript = Dispatch:Clone() + + local Shared = require(newScript:WaitForChild("Shared")) + Shared.Context = ctx + + Script.ScriptInstance = newScript + Script.Shared = Shared + + if ctx then + Script.Context = ctx + ctx.TargetScript = Script + end + + Script.TargetFunction = func + function Script:Start(...) + return Runner:StartScript(self, ...) + end + function Script:Stop() + return Runner:StopScript(self) + end + + newScript.Parent = script + return Script +end + +function Runner:LoadScript(scr, env, mergeMode) + assert(scr, "Please provide a script.") + assert(scr.IsA and scr:IsA("Script"), "Provided value isn't a Script instance.") + + assert(pcall(function() + return scr.Source + end), "The Runner cannot load scripts in this context. (Doesn't have permission to script source)") + + local ctx = Context:Create() + ctx:Load(scr.Source, env, mergeMode) + + return Runner:LoadContext(ctx) +end + +function Runner:LoadContext(ctx) + assert(ctx, "Please provide a Context.") + + return Runner:LoadFunction(ctx:GetFunction(), ctx) +end + +function Runner:StopScript(scr) + if typeof(scr) == "Instance" then + scr = {ScriptInstance = scr} + end + assert(typeof(scr.ScriptInstance) == "Instance" and scr.ScriptInstance:IsA("Script"), "Not a valid script.") + scr.ScriptInstance.Disabled = true +end + +function Runner:StartScript(scr, ...) + if typeof(scr) == "Instance" then + scr = {ScriptInstance = scr} + end + assert(typeof(scr.ScriptInstance) == "Instance" and scr.ScriptInstance:IsA("Script"), "Not a valid script.") + local Shared = scr.Shared + + local argPtr = Context.Pointer(...) -- Used to performantly pass arguments + Shared.CompleteEvent = Shared.CompleteEvent or Instance.new("BindableEvent") + Shared.DispatchEvent = Shared.DispatchEvent or Instance.new("BindableEvent") + Shared.DispatchFunction = scr.TargetFunction + + scr.ScriptInstance.Disabled = false -- Enable the script + spawn(function() + Shared.DispatchEvent:Fire(argPtr) -- Dispatch the script + end) + local returnPtr = Context.Pointer(Shared.CompleteEvent.Event:Wait()) -- Used to performantly (and easily) store return values + + return returnPtr() +end + +return Runner \ No newline at end of file diff --git a/src/H3xScriptSandbox/Sandbox.lua b/src/H3xScriptSandbox/Sandbox.lua new file mode 100644 index 0000000..57b404f --- /dev/null +++ b/src/H3xScriptSandbox/Sandbox.lua @@ -0,0 +1,178 @@ +-- H3x V1.2 +local Context = require(script.Parent:WaitForChild("Context"):Clone()) + +local Sandbox = {} + +function Sandbox:Load(code, env, mergeMode) + local ctx = Context:Create() + local loadedFunc = ctx:Load(code) + local hook + if not env then + env, hook = Sandbox:MakeEnvironment(ctx) + end + ctx:SetEnvironment(env, mergeMode) + return loadedFunc, ctx, hook +end + +local function toTable(...) -- Tuple to table + local tbl = {} + for i=1, select("#", ...) do + tbl[i] = select(i, ...) + end + return tbl, select("#", ...) +end + +local rootEnv = getfenv() +function Sandbox:MakeEnvironment(ctx) + local realEnv + local hook = {} + local env = ctx:GetEnvironment() + env.game = nil + env.workspace = nil + env.Game = nil + env.Workspace = nil + + local wrapFunction + local protect + protect = function(index, value) + local protected = value + if typeof(value) == "Instance" or value == ctx.Nil then + protected = nil + elseif typeof(value) == "function" then + protected = wrapFunction(index, value) + elseif typeof(value) == "table" or type(value) == "userdata" then + local result + if typeof(value) == "table" then + result = {} + else + result = newproxy(true) + end + local meta = getmetatable(result) or {} + meta.__index = function(_, tblIndex) + local tblValue = value[tblIndex] + if hook.OnGetIndex then + local override, overrideValue = hook:OnGetIndex(ctx, value, tblIndex, tblValue) + if override then + return protect(overrideValue) + end + end + return protect(tostring(index).."."..tostring(tblIndex), tblValue) + end + meta.__newindex = function(_, tblIndex, tblValue) + if hook.OnSetIndex then + local override, overrideValue = hook:OnSetIndex(ctx, value, tblIndex, tblValue) + if override then + tblValue = protect(overrideValue) + end + end + value[tblIndex] = tblValue + end + meta.__call = function() + local canEnv = pcall(function() + getfenv(3) + end) + if not canEnv or getfenv(2) ~= rootEnv then + return wrapFunction(index, value) + end + meta.__mode = "kv" + meta.__index = nil + meta.__call = nil + setmetatable(meta, {__mode = "kv"}) + meta = nil + value = nil + warn("Garbage collected sandbox object.") + end + meta.__metatable = "The metatable is locked" + if typeof(value) == "table" then + setmetatable(result, meta) + end + protected = result + end + + if hook.OnProtectValue then + local override, overrideProtected = hook:OnProtectValue(ctx, index, value, protected) + if override then + return overrideProtected + end + end + + return protected + end + + wrapFunction = function(index, value) + if hook.OnProtectFunction then + local override, overrideProtected = hook:OnProtectFunction(ctx, index, value) + if override then + value = overrideProtected + end + end + + return ctx:InjectFunction(function(...) + local results, len = toTable(value(...)) -- Contrary to popular belief simply doing {value(...)} doesn't suffice. If there are nil indexes than the table will not include anything beyond. + + local function stack(next, ...) + if next then + return next(), ... + else + return ... + end + end + + for i, value in ipairs(results) do + results[i] = protect(value) + end + + local stackList = {} + for i=1, len do + local idx = #stackList+1 + stackList[idx] = function() + return stack(stackList[idx+1], results[i]) + end + end + + return stack(stackList[1]) + end) + end + + env.require = protect("require", function(library) + return ctx.Libraries[library] or error("Cannot require non existant library: "..library) + end) + + local overrides = function()end + + local meta + meta = { + __index = function(_, index) + local value = env[index] + + if hook.OnGetIndex then + hook:OnGetIndex(ctx, env, index) + end + + return protect(index, value) + end, + __newindex = function(_, index, value) + if hook.OnSetIndex then + hook:OnSetIndex(ctx, env, index, value) + end + + env[index] = value + end, + __call = function() + if getfenv(2) ~= rootEnv then + return error("attempt to call a table value") + end + meta.__mode = "kv" + meta.__index = nil + meta.__call = nil + setmetatable(meta, {__mode = "kv"}) + meta = nil + warn("Garbage collected sandbox environment.") + end, + __metatable = "The metatable is locked" + } + realEnv = setmetatable({}, meta) + return realEnv, hook +end + +return Sandbox \ No newline at end of file diff --git a/src/H3xScriptSandbox/Test.Context.lua b/src/H3xScriptSandbox/Test.Context.lua new file mode 100644 index 0000000..0165bf6 --- /dev/null +++ b/src/H3xScriptSandbox/Test.Context.lua @@ -0,0 +1,12 @@ +local Context = require(script.Parent:WaitForChild("Context"):Clone()) + +local ctx = Context:Create("assert(abc123, 'Set environment test one failed') assert(getfenv(0).tostring, 'Environment test two failed')", {print = print, tostring = tostring, assert = assert, pcall = pcall, getfenv = getfenv, abc123 = "test1"}) +ctx:Execute() + +local ctx2 = Context:Create("assert(abc123, 'Merge environment test failed.')", {abc123 = "test2"}, true) +ctx2:Execute() + +local ctx3 = Context:Create("assert(getfenv(0) and getfenv(1), 'Fenv test failed') assert(not pcall(getfenv, 2), 'Get environment security check failed.')") +ctx3:Execute() + +return nil \ No newline at end of file diff --git a/src/H3xScriptSandbox/Test.Runner.lua b/src/H3xScriptSandbox/Test.Runner.lua new file mode 100644 index 0000000..7839ba7 --- /dev/null +++ b/src/H3xScriptSandbox/Test.Runner.lua @@ -0,0 +1,28 @@ +local Runner = require(script.Parent:WaitForChild("Runner")) +local Context = require(script.Parent:WaitForChild("Context")) + +local completed +delay(2, function() + assert(completed, "Runner execute test failed. Permanent yielding") +end) + +local ctx = Context:Create("return true") +local Script = Runner:LoadContext(ctx) +assert(Script:Start(), "Runner return test failed.") +assert(Script:Start(), "Runner repeated return test failed.") + +completed = true + +local ctx2 = Context:Create("assert(abc123, 'Set environment test one failed') assert(getfenv(0).tostring, 'Environment test two failed')", {print = print, tostring = tostring, assert = assert, pcall = pcall, getfenv = getfenv, abc123 = "test1"}) +local Script = Runner:LoadContext(ctx) +Script:Start() + +local ctx3 = Context:Create("assert(abc123, 'Merge environment test failed.')", {abc123 = "test2"}, true) +local Script = Runner:LoadContext(ctx2) +Script:Start() + +local ctx4 = Context:Create("assert(getfenv(0) and getfenv(1), 'Fenv test failed') assert(not pcall(getfenv, 2), 'Get environment security check failed.')") +local Script = Runner:LoadContext(ctx3) +Script:Start() + +return nil \ No newline at end of file diff --git a/src/H3xScriptSandbox/Test.Sandbox.lua b/src/H3xScriptSandbox/Test.Sandbox.lua new file mode 100644 index 0000000..6a7537a --- /dev/null +++ b/src/H3xScriptSandbox/Test.Sandbox.lua @@ -0,0 +1,35 @@ +local sbx = script.Parent:WaitForChild("Sandbox"):Clone() +sbx.Parent = script.Parent +local Sandbox = require(sbx) +sbx:Destroy() + +local test, ctx = Sandbox:Load([[ +assert(not game, "Instance reference security check one failed.") +assert(not script, "Instance reference security check two failed.") +assert(not stats(), "Instance return security check failed.") +assert(not pcall(getfenv, 3), "Get environment security check failed.") +assert(abc123, "SetEnvironment test failed.") +]]) + +ctx:SetEnvironment({abc123 = "abc"}, true) + +test() +ctx:Destroy() + +if ctx.ThreadWatcherImplemented then + local test, testCtx = Sandbox:Load([[ + coroutine.yield() + return true + ]]) + + local thread = coroutine.running() + spawn(function() + assert(test(), "Return/yield test failed") + testCtx:Destroy() + coroutine.resume(thread) + end) + + coroutine.yield() +end + +return nil \ No newline at end of file diff --git a/src/H3xScriptSandbox/TestRunner.server.lua b/src/H3xScriptSandbox/TestRunner.server.lua new file mode 100644 index 0000000..69a73bc --- /dev/null +++ b/src/H3xScriptSandbox/TestRunner.server.lua @@ -0,0 +1,11 @@ +local tests = { + "Test.Context", + "Test.Sandbox", + "Test.Runner" +} + +for _, test in ipairs(tests) do + spawn(function() + require(script.Parent:WaitForChild(test)) + end) +end \ No newline at end of file diff --git a/src/H3xScriptSandbox/init.meta.json b/src/H3xScriptSandbox/init.meta.json new file mode 100644 index 0000000..1025b06 --- /dev/null +++ b/src/H3xScriptSandbox/init.meta.json @@ -0,0 +1,3 @@ +{ + "ignoreUnknownInstances": true +} \ No newline at end of file