聊逆向很多人第一反应是汇编、So文件、内存断点。这些确实是逼近真相的终局手段但真到分析一个具体业务逻辑时一上来就砸二进制反而容易迷路。相当多的产品会把业务规则放在脚本层承载Lua就是其中最典型的一种轻量、可嵌入、热更新方便游戏逻辑、界面脚本、甚至服务端扩展里到处都有。正因如此Lua Hook 成了脚本层逆向里性价比极高的一招——不需要看懂寄存器不需要处理内存断点只要把虚拟机的函数调用机制摸清楚就能实现函数拦截、参数篡改、返回值伪造、甚至整个流程的重定向。这篇文章我会从 Lua 虚拟机里的函数调用模型讲起一路走到 debug.sethook、全局替换、Upvalue 修改、元表劫持这些实战手段最后用一个小型业务 Demo 完整演示“怎么把一个正常流程改成我们想要的流程”。内容默认你有一定编程基础但没深入过逆向也能跟得上。所有目标程序都来自你有权修改或本地自制的环境分析能力本身是中性工具用在合法场景里才有价值。1. Lua Hook到底在Hook什么先把调用模型看穿1.1 一次函数调用在Lua虚拟机里是怎么发生的Lua 的函数调用不像 C 那样直接跳到函数地址执行它有一套自己的栈和指令机制。看这段很普通的代码local function foo(a, b) local c a b return c, c * 2 end local ok, result foo(1, 2)当foo(1, 2)被调用时Lua 虚拟机做的事可以拆成四步先把foo这个函数对象压栈把实参1、2压栈然后执行函数体内的字节码最后把返回值写回栈上供调用方取用。这个过程里的函数对象在 Lua 内部是一个闭包结构包含两部分一份函数原型bytecode、调试信息和一组 Upvalue外部局部变量的引用。所以“Hook 一个 Lua 函数”本质上就是在上面四步的某个位置插入我们自己的逻辑。你可以选择在函数被调用之前动手可以选择替换掉这个函数对象本身也可以选择修改闭包里记录的 Upvalue。只要理解这层模型Lua Hook 就不再是黑魔法。1.2 可以打的牌事件型、替换型、状态型我在实际项目里会把 Lua Hook 的路数归成三类后面所有的操作都逃不出这三类。事件型靠debug.sethookLua 虚拟机执行到函数调用、函数返回、新行指令时会回调我们注册的 Lua 函数。它适合做观测、计数、熔断不适合做大规模业务改写因为回调频率太高性能开销明显。替换型最简单粗暴把全局表_G或者模块表里的函数引用换掉。原来调用checkLogin(token)的代码实际会去找当前环境里的checkLogin字段我们把这个字段换成自己的函数所有调用者就都走我们这条逻辑了。状态型更隐蔽不改函数体而是直接用debug.getupvalue/debug.setupvalue修改闭包里的变量或者给表加元表拦截字段访问。函数本身还是原函数但它的内部状态已经变了。方式拦截位置风格主要风险debug.sethook函数调用/返回/行指令边界观测与熔断性能开销大全局/模块表替换函数引用查找阶段直接遇到 local 引用时失效Upvalue 修改闭包内部状态隐蔽只对纯 Lua 函数有效元表/环境劫持字段访问阶段影子式原表结构复杂时容易弄乱1.3 第一节课就要分清楚Lua函数和C函数Lua 里有一部分函数不是 Lua 写的而是 C 实现的比如math.random、os.time、string.find这类底层函数。它们同样可以被全局替换但行为上和 Lua 函数有本质区别C 函数没有字节码没有行号信息也没有 Upvalue 可以让debug.getupvalue枚举。用debug.sethook里的call事件去监听 C 函数不同 Lua 版本表现还不一样很多时候根本不触发。所以拿到一个目标函数第一步先判断它是 Lua 函数还是 C 函数。判断方法很简单local info debug.getinfo(math.random) print(info.what) -- C 函数输出 CLua 函数输出 LuaLua 函数用 Upvalue 修改和字节码分析那一套C 函数通常只能整体替换包装。这个区别贯穿全文后面所有方案选型都依赖这一步判断。2. 动手前先读懂目标找函数、看签名、定Hook点2.1 找函数从全局表开始“抄家”Hook 的第一步永远是定位。如果目标程序允许我们执行 Lua 脚本最简单的定位方式就是先遍历全局表看看有哪些函数直接暴露在_G下面for k, v in pairs(_G) do if type(v) function then print(k, v) elseif type(v) table then for sub_k, sub_v in pairs(v) do if type(sub_v) function then print(k .. . .. sub_k, sub_v) end end end end这段“抄家”脚本能帮你快速建立起目标程序的函数地图。你会发现很多游戏会把核心业务函数挂在一个模块表里比如GameMgr.EnterLevel、ActivityMgr.GetReward。找到这些函数后再用debug.getinfo确认它们的参数数量、是否为变参心里先有个底。2.2 看签名debug.getinfo帮你确认参数和返回值定位到函数还不够得知道它接收什么、返回什么。debug.getinfo能给出不少关键信息local function foo(a, b) return a b end local info debug.getinfo(foo) print(info.nparams) -- 参数个数2 print(info.isvararg) -- 是否变参false print(info.currentline) -- 当前行号 print(info.short_src) -- 源码来源返回值没法靠getinfo直接看只能通过行为推断调用一次观察调用方的后续取值。多返回值是 Lua 里最容易埋雷的点比如同时返回(ok, errMsg)、(playerData, code)这种结构。你 Hook 一个函数时如果只返回一个值调用方取第二个值时会拿到nil后面的业务几乎必然崩。2.3 定Hook点先回答自己五个问题真正动手之前我会按固定的问题清单过一遍避免做无用功目标函数是全局函数还是模块表里的字段如果是某个文件的 local 函数全局替换这条路直接封死。调用方是怎么拿到这个函数的每次从_G里取还是启动时就缓存了引用缓存了引用就只能改对象本身或状态。关键数据放在哪一层参数、返回值、Upvalue、全局变量还是对象属性目标函数是纯 Lua 函数还是 C 函数如果是 C 函数getupvalue和行级 Hook 都不用想了。业务要求的“流程控制”到底控制什么是跳过校验、改参数、改返回结果还是改内部状态这几个答案定了Hook 方案基本也就定了。比如目标是 local 函数那就优先看它的 Upvalue目标是全局函数直接替换最省事目标是对象属性元表劫持最合适。3. 三种主流函数拦截链路以及它们各自的代价3.1 全局替换法最快吃到肉也最容易失效全局替换是入门必学的第一招核心写法就这几行-- 保存原函数 local original_check checkLogin -- 替换全局函数名 checkLogin function(token) -- 这里可以读取/修改参数 print(hook token:, token) -- 选择一直接短路不调用原函数 -- return true, { id 10001, name Hooked } -- 选择二调用原函数但修改返回结果 local ok, player original_check(token) return ok and true or false, player end注意checkLogin必须是调用方实际访问的那个全局函数名。如果目标脚本里有local checkLogin require(login).checkLogin这种把引用缓存成局部变量的写法全局替换就完全无效了。这也是全局替换最脆弱的地方它拦截的是“名字查找”这一步而不是函数本身。另一个容易被忽略的点是参数转发必须用...不要写死参数个数否则遇到变参函数会丢参数-- 错误示范只转发了固定参数 local function bad_hook(a, b) return original(a, b) end -- 正确示范完整转发 local function good_hook(...) return original(...) end3.2 Upvalue 改写不换函数体改内部状态很多业务函数的核心状态不放在参数里而是以 Upvalue 形式存在函数闭包里。一个抽奖函数可能内部维护了一个drawCount记录调用次数外部只能通过返回值感知。这时直接改函数体不好下手但改 Upvalue 会非常干净。local function dump_upvalues(func) for i 1, 100 do local name, value debug.getupvalue(func, i) if not name then break end print(i, name, value) end end local function set_upvalue(func, target_name, new_value) for i 1, 100 do local name, value debug.getupvalue(func, i) if not name then break end if name target_name then debug.setupvalue(func, i, new_value) return true end end return false end -- 使用示例把 tryDraw 里的 drawCount 重置为 0 dump_upvalues(tryDraw) set_upvalue(tryDraw, drawCount, 0)debug.getupvalue从 1 开始编号遍历到没有名字时停止。注意它是保存在闭包里的修改一次就会影响后续所有调用。这个方案最大的限制是只能作用于纯 Lua 函数C 函数没有 Upvalue 可枚举。3.3 元表劫持在字段访问那一步做文章面向对象风格的 Lua 代码遍地都是对象的方法挂在一张表上调用方用obj:GetHp()或者obj.Hp直接访问。这种情况下替换全局函数往往影响不到对象内部的方法表但元表劫持可以。local player { name Tom, hp 100 } local mt getmetatable(player) -- 如果对象没有元表要先用 debug.setmetatable 设置 if not mt then mt {} debug.setmetatable(player, mt) end local old_index mt.__index mt.__index function(tbl, key) if key hp then return 99999 -- 读取 hp 时永远返回这个值 end if type(old_index) function then return old_index(tbl, key) elseif type(old_index) table then return old_index[key] end return rawget(tbl, key) end这里要特别小心原元表的__index可能是一个函数也可能是一张表甚至可能是 nil。直接覆盖会破坏原有查找链所以一定要先保存并正确处理原来的__index。另外元表劫持只对“通过obj.xxx或obj:method()这种形式访问”的场景有效内部如果用了rawget绕过则不会触发。3.4 debug.sethook观测为王改写为辅debug.sethook是 Lua 官方提供的调试钩子可以监听函数调用、函数返回、行执行和按指令计数四种事件。它特别适合做调用链分析、函数调用计数和动态熔断。local call_count 0 debug.sethook(function(event, line) if event call then local info debug.getinfo(2, n) local name info.name or anonymous print(call:, name) if name tryDraw then call_count call_count 1 if call_count 3 then error(tryDraw 调用次数异常, 2) end end elseif event return then print(return from, debug.getinfo(2, n).name) end end, cr, 100)上面这段里cr是事件掩码c表示监听函数调用r表示监听函数返回最后的数字100表示每执行 100 条指令触发一次line事件。debug.getinfo(2, n)里的层级2是因为层级1是 Hook 回调函数自身层级2才是被钩住的函数。debug.sethook的定位是“观测和熔断”不建议拿它做高频率业务 Hook。每次函数调用都过 Lua 回调性能开销非常大。我在实际项目里一般用它分析“某个函数到底被谁调用了”摸清调用链之后再换成全局替换或 Upvalue 修改来做最终改写。4. 从拦截到控制一个带业务逻辑的完整通关案例4.1 目标程序一个包含登录、体力、抽奖的迷你Demo为了把前面几种手段串起来我写了一个模拟目标程序。它模拟了很多业务程序最常见的三个模块登录校验、体力消耗、抽奖。假设这是某个我们没有源码、但可以加载 Lua 脚本的宿主环境。-- 目标程序假设由宿主环境加载 local energy 100 local draw_count 0 local LOGIN_KEY secret-token-2024 local function checkLogin(token) if token ~ LOGIN_KEY then return false, invalid token end return true, { id 10001, name DemoPlayer } end local function costEnergy(num) if energy num then return false, energy not enough end energy energy - num return true end local function tryDraw() if draw_count 1 then return nil, draw count limit end draw_count draw_count 1 local r math.random(1, 100) if r 90 then return SSR elseif r 60 then return SR end return R end -- 模拟宿主按流程调用这些函数 local token wrong-token local ok, player checkLogin(token) if ok then print(login success:, player.name) end这是一个典型业务闭环客户端带 token 登录服务端校验玩家抽奖要消耗体力抽奖次数每天限量。我们的目标是把这套流程改成我们想要的样子。4.2 第一关让登录校验变成“必过”但别破坏返回结构登录校验最直接的做法是替换全局函数。但checkLogin是个 local 函数全局表里根本找不到它。所以在真实环境里第一件事是确认它到底是不是 local。我这里的 Demo 把它写成 local就是为了演示“直接替换可能失效”的情况。如果checkLogin恰好是全局函数Hook 会非常简单checkLogin function(token) return true, { id 10001, name HookedPlayer } end这里有一个关键点原函数返回了两个值true, player调用方拿到后使用player.name。如果你只写return true调用方会得到player nil紧接着访问player.name就崩了。Hook 必须保持返回结构一致——哪怕你要伪造数据也要伪造得和原函数一样完整。如果是 local 函数就需要找到调用方所在的环境表或者利用 debug 库访问调用栈里的函数。在入门阶段更实用的思路是先观察全局表里有没有宿主暴露的可替换入口或者直接改调用方取用的对象。4.3 第二关让体力消耗变成“假消耗”体力扣减函数costEnergy里判断了energy num然后把energy减掉。我们想让玩家可以无限消耗但表面上程序还正常。最直接的方法是包装原函数local original_costEnergy costEnergy costEnergy function(num) -- 把扣减数量改成 0原逻辑会认为消耗了 0 体力 return original_costEnergy(0) end调用方拿到true以为消耗成功了实际上体力根本没变。这里要注意original_costEnergy(0)和original_costEnergy(num)的区别传0时内部energy energy - 0体力不会减少传num再改返回值反而容易漏掉energy的副作用。如果你是 global 函数的场景这个包装方案直接就成立。如果是 local 函数同样面临定位问题。本 Demo 为了展示效果你先假设可以通过前面遍历全局表找到了一个可注入点后面我们会专门讨论 local 函数怎么处理。4.4 第三关抽奖次数和结果一起控制抽奖有两个限制每天次数draw_count 1会拒绝随机结果由math.random决定。先处理次数限制这里用 Upvalue 修改是最优雅的。我们可以在每次调用tryDraw前把draw_count重置成0local function reset_draw_count() for i 1, 100 do local name, value debug.getupvalue(tryDraw, i) if not name then break end if name draw_count then debug.setupvalue(tryDraw, i, 0) return true end end return false end reset_draw_count()然后处理随机结果。math.random是 C 函数没有 Upvalue但可以被全局替换math.random function(a, b) if b then return 95 -- 在 [a, b] 区间固定返回 95 end return 95 -- 单参数形式也返回 95 end这样tryDraw内部判断r 90成立永远返回 SSR。你可能会问如果游戏内部有独立的随机数算法不是用math.random怎么办那就要去改那个自定义随机数函数的 Upvalue 或者返回值原理是一样的先定位随机源再让随机源的结果落进你想要的分支。4.5 用一张“流程控制清单”复盘这轮操作经过上面三关我们其实已经覆盖了流程控制的五种常见套路提前返回不调用原函数直接给结果。登录校验的例子。参数篡改把传入的扣减数改成 0让业务逻辑认为“消耗成功”。体力扣减的例子。返回值篡改调用原函数但修改它的返回结果。修改登录返回的玩家数据。状态修改直接重设闭包里的draw_count让次数限制失效。注入旁路逻辑在 Hook 回调里执行额外代码比如调用另一个函数、上报数据等。这五招组合起来绝大多数 Lua 业务流程都能被改造成你想要的样子。关键在于搞清楚目标程序的流程里哪些节点是可以通过 Hook 拦截的。5. 没有人告诉你的破坏性问题Hook完为什么崩、为什么没生效5.1 多返回值丢一个业务连锁炸这是 Lua Hook 里最容易踩的坑。原函数返回两个值你的 Hook 只返回一个Lua 不会报错但调用方拿到的第二个值会是nil。如果调用方紧接着对nil做索引操作才会报出莫名其妙的错误。-- 原函数 local function getPlayer() return true, { id 1, name Tom } end -- 错误 Hook把第二个返回值弄丢了 getPlayer function() return true end -- 调用方 local ok, p getPlayer() print(p.name) -- attempt to index a nil value解决办法是Hook 函数里如果调用原函数一定要用多值接收再原样返回getPlayer function(...) local results { original_getPlayer(...) } -- 可以改 results 里的值但别弄丢数量 return table.unpack(results) end5.2 递归风暴Hook 回调里调原函数也要小心全局替换时如果你在 Hook 里调用了原函数而原函数内部又调用了同一个全局名字会形成递归。比如local function process() process_helper() -- 内部调用 process_helper end process function() print(hooked) return original_process() -- original_process 内部可能又通过全局名调用 process end如果原函数体内部通过全局名访问自身或相关函数而你又把全局名替换成了自己的 Hook原函数执行时就会再次进到 Hook。这会迅速打爆 Lua 栈报C stack overflow。更隐蔽的情况是debug.sethook回调里触发了被 Hook 调用同样会造成回调重入。我的习惯是在 Hook 里加一层防重入标记local in_hook false target_func function(...) if in_hook then return original_target_func(...) end in_hook true local ok, err xpcall(function() return original_target_func(...) end, debug.traceback) in_hook false if not ok then print(hook error:, err) end return ... endxpcall能把 Hook 里的异常和原生业务逻辑隔离避免一个错误导致整个宿主崩溃。5.3 时机问题函数还没定义Hook 等于白Hook脚本的加载顺序是个经典陷阱。如果目标程序在require(login)之前执行了你的 Hook而函数是在require(login)里才定义并挂到全局的那你的替换会被后续的赋值覆盖掉反过来如果函数已经以 local 形式被其他模块捕获你之后再改全局那些模块用的还是旧引用。处理方式有两种。一种是在业务代码加载完成后再注入保证 Hook 时机在函数定义之后另一种是“熔断式修补”先用 debug.sethook 监听目标函数的第一次调用确认它存在了再在运行时完成替换。local hooked false debug.sethook(function(event) if hooked then return end if event call then local info debug.getinfo(2, n) if info.name target_func then hooked true -- 延迟到此时再替换确保目标已经定义 target_func function(...) return true end end end end, c)5.4 对方把debug库封了怎么办很多商业程序会移除debug库或者只保留部分函数因为debug太方便做分析和篡改了。遇到debug nil上面的方案几乎全部失效。替代思路有三条。一条是绕开debug直接用元表和环境表替换但前提是你对目标函数的环境足够了解一条是在 C 层重新编译一个包含debug库的 Lua 运行时把目标脚本加载进去但这已经超出纯 Lua Hook 范畴属于二进制层工作最后一条是用函数原本的宿主入口比如游戏框架自己暴露的调试接口很多框架内置了热更新或控制台功能利用这些通道也能达到类似效果。入门阶段我的建议是先确认目标环境是否允许debug库。允许就好好用不允许就先别硬碰硬换低阶手段后面再学。5.5 一个可以直接拷贝的Hook基础模板最后分享一个我常用的 Hook 基础模板。它兼顾了原函数保留、参数完整转发、返回结构保持、异常隔离几个关键点local function make_hook(global_name, before, after) local original _G[global_name] if type(original) ~ function then error(global_name .. is not a function) end _G[global_name] function(...) local args { ... } local bypass false local fake_results {} -- before 阶段可以改参数也可以选择不走原函数 if before then bypass, fake_results before(args) or false, fake_results end local results if bypass then results fake_results else local ok, err xpcall(function() results { original(table.unpack(args)) } end, debug.traceback) if not ok then error(hook error: .. tostring(err), 2) end end -- after 阶段统一修改返回值 if after then results after(results) or results end return table.unpack(results) end return original end -- 用法示例 make_hook( checkLogin, function(args) -- 直接绕过返回伪造结果 return true, { true, { id 1, name Fake } } end, nil )这个模板并不完美但它能避免 80% 的新手错误多返回值不会丢异常不会直接炸掉宿主原函数还能通过返回的original继续访问。从我个人的实操体会来说Lua Hook 入门最容易走向两个极端一个是停留在“我知道有 debug.sethook 这回事”但不会用另一个是一上来就搞复杂框架结果连目标函数都定位不到。建议你先在本地写几个自带业务逻辑的 Lua 脚本反复练习“找函数—看签名—定Hook点—改写—验证”这五步循环。等你对全局替换、Upvalue 修改、元表劫持这三种手段都形成肌肉记忆了再看真实程序里的 Lua 层逆向思路会清晰得多。