项目上线前,游戏后端最让人揪心的就是第三方脚本出岔子。策划丢来一堆技能逻辑,外包写了一段不太干净的Lua,只要一个死循环,服务器CPU瞬间拉满,整个房间的玩家一起卡成PPT。类似的事故经历多了,我们才下定决心把OpenResty和Lua沙箱结合起来做一层隔离。这篇文章不讲高深的理论,就用实际踩坑的经验,把整个方案掰开揉碎了聊一聊,你只要写过一点Lua,有点OpenResty基础,就能跟着做下去。
一、应用场景:什么样的游戏后端需要沙箱
游戏后端和普通Web后端最大的区别在于玩法的动态性和迭代速度。今天策划要新增一个活动,明天要调整技能参数,如果每次都要重新发布服务器程序,体验上完全跟不上。于是Lua就成了最常用来承载“可热更逻辑”的脚本。开发效率是上去了,可安全性立刻成了大问题,因为Lua本身是允许写出任意危险操作的,比如读取系统文件、连接外部网络、占用大量内存。
常见的危险场景大致有三类。第一类是资源滥用,最常见就是死循环,比如一个人写了一个while true的占卜逻辑,没有设置出口,服务器上这个worker就直接被卡死。第二类是权限逃逸,Lua的标准库里有os.execute、io.open这些函数,如果脚本里有恶意代码,就能在服务器上执行shell命令,那跟直接把服务器密码给别人没区别。第三类是数据越界,脚本可以访问不该访问的表,比如全局注册表、debug库,进而读取到其他玩家数据。
我们的需求很明确:允许策划玩家写逻辑,但不能让这段逻辑伤害到游戏主进程和宿主机。也就是说,脚本应该能调用我们暴露给他的受限API,同时绝对不能访问文件系统、网络、debug库,以及任何我们不想暴露的内部数据。这就是沙箱存在的意义——给脚本一个精心布置的“围栏”,在围栏里怎么跑都行,出了围栏立刻截断。
二、技术优缺点:沙箱不是万能的,但一定得做
OpenResty配合Lua沙箱的方案,团队内部讨论过很多次。先把优缺点摊开来说,这能帮你判断自己的项目适不适合这么干。
先是优点。第一,隔离度高,一旦脚本里出现非法访问,我们通过在沙箱层拦截就能立刻阻止,不会对宿主进程造成影响。第二,热更友好,Lua本身就是解释执行,沙箱里的代码可以做到秒级更新,不用重启服务。第三,资源可控,借助OpenResty的定时器和Lua的debug钩子,我们能对运行时间做硬性限制,像“运行超过10毫秒直接终止”这种规则完全可以落地。第四,调试简单,脚本错误会被转换成我们自定义的错误对象,不会拖垮整个进程,日志里能很清楚地看到是哪段角色技能脚本出了问题。
缺点也不回避。第一,性能损耗,每层沙箱检查其实都是有代价的,尤其是频繁调用受限函数时,会多出好几层判断开销。不过对于游戏后端来说,这种损耗通常可以接受。第二,隔离不彻底,Lua本身的安全机制再怎么加固也不是操作系统级的安全边界,如果宿主环境本身存在漏洞,或者你错误地暴露了某些函数,沙箱依然可能被绕过。第三,开发成本不低,你需要自己管理沙箱环境、封装API、处理复杂对象的传递,前期工作量不小。第四,维护要细心,版本升级时标准库的变化可能导致沙箱出现新的漏洞,需要持续关注安全补丁。
即便如此,在大多数游戏后端场景下,做沙箱的收益还是要远远大于不做。我们只需要把最核心的几条路堵死,再配合运行限制,就足以应对绝大多数实际需求。
三、核心实现:从零搭一个OpenResty下的Lua沙箱
先明确技术栈:本案例所有脚本均使用 Lua 5.1,运行环境是 OpenResty 的 init_worker_by_lua_block 和 content_by_lua_block。请不要混用其他语言,这样你才能专注于沙箱本身的逻辑。
Lua沙箱的基本思路是:创建一个全新的环境表(_ENV),把危险函数全部移除,只暴露我们需要的白名单函数。在Lua 5.1里没有_ENV,我们用的是setfenv函数来改变脚本的运行环境。OpenResty自带的LuaJIT也支持setfenv,所以可以放心用。
3.1 最简单的白名单沙箱
我们先不看OpenResty的请求上下文,单独把沙箱的核心架子搭出来。这个例子里,我们只允许脚本调用print、string、math等安全模块,其他统统不给。
-- 沙箱核心工具.lua
local safe_lib = {} -- 空表,后面用来装白名单
-- 创建一个安全的全局环境
local function create_safe_env()
local env = {}
-- 白名单:只暴露print和基本模块
env.print = print
env.string = {
sub = string.sub,
len = string.len,
upper = string.upper,
lower = string.lower,
format = string.format,
rep = string.rep,
find = string.find,
match = string.match,
gmatch = string.gmatch,
gsub = string.gsub
}
env.math = {
floor = math.floor,
ceil = math.ceil,
abs = math.abs,
random = math.random,
max = math.max,
min = math.min,
sqrt = math.sqrt
}
env.table = {
insert = table.insert,
remove = table.remove,
concat = table.concat,
sort = table.sort,
unpack = table.unpack or unpack -- 兼容5.1
}
-- 这些是关键:绝对不允许暴露
-- os, io, require, dofile, loadfile, loadstring, collectgarbage, debug, package
return env
end
-- 执行用户脚本
local function run_in_sandbox(user_code, arg_table)
local env = create_safe_env()
-- 构造一个可传入参数的函数,用setfenv改变其环境
local chunk = assert(loadstring(user_code, "玩家脚本"))
setfenv(chunk, env)
-- 如果脚本有返回值,这里能拿到
local result = chunk(arg_table)
return result
end
-- 测试一下
local test_code = [[
local a = 10
local b = 20
print("a + b =", a + b)
return a * b
]]
local result = run_in_sandbox(test_code)
print("返回值:", result)
代码里的注释已经写得很清楚了,这个沙箱虽然结构简单,但已经把危险模块全部隔离了。不过问题也随之而来:loadstring本身是危险的,因为它可以在现有环境下加载任意字符串代码,如果我们允许用户脚本里调用loadstring,就跳过沙箱了。所以上面这个示例还不能直接用,我们得再做一层加固。去掉全局的loadstring还不够,只要脚本运行环境里没有loadstring,他就没办法自己去定义新代码。但是作为沙箱宿主,我们自己在外部调用loadstring来编译用户代码是可以的,那不代表用户能碰。
3.2 加上运行时间限制
光隔离模块是不够的,死循环这种问题得靠另一个手段:debug库的钩子函数。很多新手一听到debug库就害怕,其实在沙箱场景下它反而能成为救命稻草。我们可以在编译好的chunk执行前,设置一个调试钩子,每执行多少条指令就检查一次时间,超了就强制抛出错误。
这里有一个很重要的细节:在LuaJIT中,debug.sethook对于钩子事件的支持需要小心,但Lua5.1原生的debug库是完整的。OpenResty自带的LuaJIT也兼容debug.sethook的使用,只是如果你是LuaJIT特有的一些trace,钩子未必能百分百覆盖。不过对于绝大多数监控需求,这个办法已经够用了。
下面这个例子加入了运行时间上限,同时把debug库从沙箱环境中彻底移除,因为用户不能自己修改钩子。
-- 带时间限制的沙箱.lua
local debug = require("debug") -- 沙箱宿主自己可以使用debug,但不暴露给脚本
local function create_safe_env()
local env = {}
env.print = print
-- 只放必要的工具模块,省略部分与上面相同
env.string = { sub = string.sub, len = string.len, format = string.format }
env.math = { floor = math.floor, ceil = math.ceil }
env.table = { insert = table.insert, concat = table.concat }
-- 这里没有 os、io、debug、package、require、dofile、loadfile、loadstring
-- 也没有 collectgarbage,防止用户手动GC影响服务
return env
end
-- 用调试钩子限制运行时间
local function run_with_limit(chunk, env, time_limit_ms)
local start_clock = os.clock() -- 秒为单位的CPU时间
local quota = time_limit_ms / 1000 -- 转换成秒
-- 每1000条指令检查一次
debug.sethook(function()
local now = os.clock()
if now - start_clock > quota then
-- 清掉钩子,然后抛出错误
debug.sethook(nil)
error("脚本运行超时(超过 " .. time_limit_ms .. " 毫秒)")
end
end, "", 1000) -- 计数钩子:每1000条指令调用一次
setfenv(chunk, env)
local success, result = xpcall(chunk, function(err)
return { error = err }
end)
-- 不管成功还是失败,都要清除钩子
debug.sethook(nil)
if success then
return true, result
else
return false, result.error
end
end
local player_code = [[
local x = 0
while true do -- 一个故意的死循环
x = x + 1
end
return x
]]
local env = create_safe_env()
local chunk = assert(loadstring(player_code, "玩家死循环脚本"))
local ok, res = run_with_limit(chunk, env, 5) -- 最多允许5毫秒
if not ok then
print("沙箱拦截:", res)
else
print("脚本结果:", res)
end
这段代码有个细节值得注意:我们用了os.clock()来计算CPU时间,而不是os.time(),因为os.time()是墙上时钟,服务器繁忙时可能不准确。游戏中如果同时跑很多脚本,用CPU时间更合理。
3.3 搭配OpenResty使用
现在把沙箱放进OpenResty里。比如我们有一个HTTP接口,玩家提交一段Lua脚本,这个脚本将被执行并且返回结果。实际应用中,脚本会由后端存储逻辑从数据库里加载出来,而不是直接接收外部输入,但原理一样。下面就是一个完整的OpenResty接口示例。
-- 在 nginx 配置中开启 lua_code_cache on;
-- 然后在 access_by_lua 或 content_by_lua 中加载这个沙箱模块
-- 沙箱模块 sandbox.lua
local _M = {
VERSION = "1.0"
}
local debug = require("debug")
-- 在这个环境里,提供给游戏对象访问的接口
local function create_game_env(player_data)
local env = {}
-- 基础安全函数
env.print = function(...)
ngx.log(ngx.INFO, ...) -- 脚本的print重定向到nginx日志
end
-- 给脚本提供只读的游戏数据快照
env.player = {
name = player_data.name,
level = player_data.level,
hp = player_data.hp,
mp = player_data.mp,
items = player_data.items or {}
}
-- 提供一些可控的游戏操作函数,但经过校验
env.game_api = {
add_buff = function(buff_id, duration)
-- 这里只做演示,实际需要校验buff_id是否合法
return { id = buff_id, left_seconds = duration }
end,
deal_damage = function(target_id, amount)
-- 目标需要安全映射,不能直接暴露内部对象
return true
end
}
-- 安全的字符串工具
env.string = {
sub = string.sub,
len = string.len,
format = string.format,
find = string.find,
match = string.match,
gsub = string.gsub
}
env.math = {
floor = math.floor,
ceil = math.ceil,
abs = math.abs,
min = math.min,
max = math.max,
random = math.random,
sqrt = math.sqrt
}
env.table = {
insert = table.insert,
remove = table.remove,
concat = table.concat,
sort = table.sort
}
return env
end
-- 带超时执行
local function run_chunk(chunk, env, time_limit_ms)
local start = os.clock()
local limit = time_limit_ms / 1000
debug.sethook(function()
local now = os.clock()
if now - start > limit then
error("脚本超时")
end
end, "", 1000)
setfenv(chunk, env)
local results = { xpcall(chunk, function(e)
return { msg = tostring(e) }
end)}
debug.sethook(nil)
if results[1] then
return true, table.unpack(results, 2)
else
return false, results[2]
end
end
-- 供OpenResty调用的主入口
function _M.execute_script(script_body, player_data, time_limit_ms)
time_limit_ms = time_limit_ms or 2 -- 默认2毫秒
-- 使用loadstring编译用户脚本(Lua5.1)
local chunk, compile_err = loadstring(script_body, "玩家技能脚本")
if not chunk then
return false, "脚本编译错误: " .. tostring(compile_err)
end
local env = create_game_env(player_data)
return run_chunk(chunk, env, time_limit_ms)
end
return _M
然后在OpenResty的content_by_lua_block里面调用它:
-- 该文件通常叫做 test_sandbox.lua
local sandbox = require("sandbox")
-- 模拟一个玩家数据
local player = {
name = "小明",
level = 30,
hp = 1000,
mp = 500,
items = { "回血药", "蓝瓶" }
}
-- 模拟策划配置的一段战斗逻辑
local skill_script = [[
-- 这段脚本会运行在沙箱里
local target_id = 10001
local damage = math.floor(player.level * 2 + 10)
print("玩家: " .. player.name .. " 对目标 " .. target_id .. " 造成伤害:", damage)
-- 只能访问我们setfenv里给的game_api,不能做其他事
local buff_result = game_api.add_buff(5001, 5)
-- 尝试访问危险函数(这里会报错,因为当前环境没有os)
-- local t = os.time() -- 这行如果取消注释就会执行失败
return {
damage = damage,
buff = buff_result.id
}
]]
local ok, result = sandbox.execute_script(skill_script, player, 5)
if ok then
ngx.say("脚本执行成功: ", require("cjson").encode(result))
else
ngx.say("脚本执行失败: ", tostring(result))
end
注意上面注释里提到的那行-- local t = os.time(),如果你真的取消注释,沙箱会阻止它,因为env里没有os这个全局变量,调用时会抛出“attempt to index global 'os' (a nil value)”错误。这种报错很常见,也是沙箱起作用的表现。
3.4 对象传递与深度拷贝
沙箱里传出去的对象需要格外小心。直接传内部对象引用的话,脚本可以顺手修改你的内部表,甚至插入恶意字段。所以安全做法是只传副本,或者传一个只读代理。最简单的方案就是深拷贝一份数据给脚本,让脚本随便改它自己的那份拷贝,用完后我们再把需要的数据读回来。这里用cjson序列化来做深拷贝,简单粗暴但有效。
-- 安全对象传递示例.lua
local cjson = require("cjson.safe")
-- 把一个对象深拷贝到沙箱环境
local function deep_copy_for_script(obj)
-- 先序列化成JSON字符串,再解析成全新的Lua表
local str = cjson.encode(obj)
if not str then
-- 有循环引用的复杂对象不建议直接传,我们只传简单数据
return nil
end
return cjson.decode(str)
end
-- 示例:把玩家背包数据做成副本
local function make_safe_bag(raw_bag)
local copy = deep_copy_for_script(raw_bag)
return copy
end
local raw_bag = {
gold = 1000,
items = {
{ id = 101, count = 2 },
{ id = 102, count = 1 }
}
}
-- 脚本侧修改副本不影响真实背包
local bag_copy = make_safe_bag(raw_bag)
bag_copy.gold = 99999
print("真实背包金币:", raw_bag.gold) -- 仍然是1000
print("脚本中的背包金币:", bag_copy.gold) -- 99999
这种方法非常适合游戏内道具数量、属性值之类的传递。但是要注意,如果对象特别大,每次都序列化反序列化会有性能开销,所以最好限定脚本只能访问它需要的字段,别一股脑全给他。
3.5 关联技术:聊聊debug库的钩子机制
前面我们一直在用debug.sethook,这里再展开说说。Lua的调试钩子有三种类型:call(每次函数调用)、return(每次函数返回)、line(每执行一行新代码)。而我们用的是count钩子,即第几个参数传一个正整数,表示每执行指定数量的指令就触发一次钩子函数。钩子函数里可以做时间检查、指令数检查,甚至可以根据脚本当前的文件名和行号来决定是否禁止某些操作。
在沙箱中,我们自己作为宿主调用debug.sethook,然后把debug库从沙箱环境中拿掉,这样用户代码无法自行清除钩子或改变钩子设置。这样才能保证我们的限时机制不被绕过。有一个小坑是LuaJIT在编译热代码后,钩子的触发频率可能不如预期稳定,但绝大多数情况我们的限制是“最后一道防线”,平时正常脚本几毫秒就执行完了,只有在死循环或者极端情况下钩子才会真的介入,所以够用。
另外,debug.getinfo也可以用在钩子中来获取当前执行的函数名或源文件,方便我们在超时日志里定位是哪段脚本出了问题。这在游戏项目里排错特别有用,能在日志里看到“玩家小明发起了一次死循环脚本,位置在活动玩法/打地鼠.lua第114行”。
3.6 内存限制的简单实现
时间限制解决了死循环,但还有一个问题:脚本可能申请大量内存,比如不停往表里插数据,把服务器内存一点点吃光。Lua没有内置的内存限制API,但我们可以通过启用collectgarbage的step来监控内存,或者用一个更偷懒的方式:在每个脚本执行前和超时钩子检查时,看一下当前Lua虚拟机的总内存,如果超过设定阈值就直接抛错。注意这里需要关闭LuaJIT的JIT对一些极端内存操作的影响,实际上我们只要设置一个足够宽松的阈值就可以。
-- 内存监控片段.lua
local limit_memory_mb = 10 -- 沙箱内最多允许使用10MB
local start_mem = collectgarbage("count") -- KB为单位
debug.sethook(function()
local now_mem = collectgarbage("count") -- 单位KB
if now_mem - start_mem > limit_memory_mb * 1024 then
error("脚本内存超限")
end
end, "", 500) -- 每500条指令检查一次
这里用了collectgarbage("count")来获得Lua虚拟机的内存使用量,不是精确值,但对于安全监控已经足够。如果脚本分配了大量内存,这个差值会很快涨上去。
四、注意事项:这些坑千万不能踩
第一,永远不要把你的内部对象引用直接传入沙箱。哪怕你觉得只是读取,脚本也可能通过修改元表或者遍历来间接修改你的内部状态。安全做法是只传副本或者经过代理的对象。副本会降低性能,但能保命。
第二,别在沙箱环境中暴露string.dump、loadstring、load、require、dofile、loadfile,一个都不要留。即使你只暴露了loadstring,脚本也可以利用它编译出新的代码,从而绕过你设置的所有限制。正则编译也会用到loadstring,所以也要关注这类隐式调用。
第三,setfenv在LuaJIT和Lua5.2+的兼容性差异很大。如果将来项目升级到Lua5.2以上,需要改成_ENV模式,而且loadstring的语义会变化。现在OpenResty官方打包LuaJIT,所以还能继续用老办法,但如果你的OpenResty版本较新,建议提前把沙箱模块抽成独立文件,方便未来切换。
第四,超时钩子的粒度问题。如果你测试时发现死循环需要跑很久才被限制,可能是因为钩子计数太长,或者钩子函数本身被JIT优化了。建议把计数参数调小一点,比如100或200,同时在钩子里不调用高开销的函数,否则钩子本身也会拖慢性能。
第五,xpcall只能捕获错误,不能保证栈上资源完全释放。如果脚本不断创建协程而自己又不终止,可能会造成协程泄漏。在沙箱里尽量不要让用户脚本创建协程,如果需要异步逻辑,由宿主通过ngx.thread或ngx.timer来调度。
第六,不要用setenv来让整个全局表共享。每个玩家最好都有自己独立的沙箱环境,不要因为想省几KB内存就让所有脚本共享同一个env,否则一个脚本往env里写了一个变量,另一个脚本就能读到他,很容易串数据。
第七,日志要记录脚本的源文件和行号。我们可以在编译chunk时给loadstring第二个参数传一个名字,然后在钩子里通过debug.getinfo(2, "Sln")拿到源文件和行号。这样一旦有脚本违规或超时,我们能快速定位是哪个玩法文件出来的问题。
-- 记录违规位置的示例.lua
local function timeout_hook()
local info = debug.getinfo(2, "Sln")
if info then
ngx.log(ngx.ERR, "脚本超时, 源: ", info.source, " 第", info.currentline, "行")
end
error("超时")
end
五、文章总结
把OpenResty和Lua沙箱组合起来,本质上是我们需要热更新的灵活性,又承受不了脚本出问题的风险。我们在本篇文章中一步步搭出了一个可用的沙箱模型:创建新的环境表、用setfenv绑定到用户chunk、剥离危险库、通过调试钩子限制运行时间和内存、对传入脚本的数据做副本隔离,最后在OpenResty的请求处理中安全执行。这套方案适用于大多数游戏后端的活动脚本、玩家自定义技能、商城折扣等逻辑。
它并不算完美,有很多边界情况需要你在实际项目中根据需求去加强。比如极端情况下,debug.sethook并不能百分之百保证拦截到所有恶意路径,尤其是LuaJIT的JIT编译路径,所以建议把沙箱和Lua代码的整体运行环境隔离到独立的ngx.worker中,配合更强监控。我们当前是把沙箱融合在业务worker中,如果你追求极致安全,可以考虑将游戏业务拆成两个进程——一个专门跑沙箱脚本,另一个做数据代理,通过进程间通讯交换数据,这是更高等级的隔离,但成本也更大,这里就不再展开了。
希望这篇文章能帮你摆脱“脚本一挂,全服重启”的痛苦,让策划和玩家能安全地释放创意。项目代码里的坑我们替你踩了不少,你再走就顺多了。
评论
围绕“OpenResty与游戏后端Lua沙箱:脚本安全隔离的落地实践”参与讨论