☰
Lua Hook实战:从函数调用模型到脚本层逆向拦截与流程篡改
2026/10/5 9:48:16 网站建设 项目流程

聊逆向,很多人第一反应是汇编、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.sethook,Lua 虚拟机执行到函数调用、函数返回、新行指令时,会回调我们注册的 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 函数输出 "C",Lua 函数输出 "Lua"

Lua 函数用 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(...) end

3.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重置成0:

local 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) end

5.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 ... end

xpcall能把 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 层逆向,思路会清晰得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询