我第一次被“Hook”这个词折磨,是在排查一个老SDK闪退的时候。那个SDK已经没法重新编译,但我必须搞清楚它内部某个函数的入参到底传了什么。同事甩来一句“找个点Hook一下就行”,然后留我一个人对着调试器发呆。等真正跑通一次Hook之后我才意识到,这东西根本不是什么黑魔法——它只是把程序原本要走的路,从中间挖出一条观察窗或者一条岔路。只要这个心智模型建立起来,HOOK技术能干什么、怎么写、为什么要分层,全都能顺下来。
这篇文章我会用几段可以直接运行的代码,把Hook从“函数替换”到“事件回调”,再从“导入表钩子”到“Inline Hook”的层层概念讲清楚,最后聊聊我最容易踩的几个坑。适合刚接触Hook、想搞懂它到底在拦什么的开发者;已经会写装饰器和猴子补丁的同学,也可以直接跳到后面看底层原理和踩坑部分。
1. 先用一个可运行的例子,理解“代码中途被截胡”
1.1 为什么需要Hook:不改源码的增强诉求
先想一个很常见的场景:项目里引用了某个第三方库,或者某个模块已经上线,里面有个函数被几十个地方直接调用。现在需求变了,每次调用这个函数时都要记录一条日志,或者某个特定账号不能被查询。
最直接的做法是到处找调用点,在调用前加一行代码。但调用点太分散,而且有些调用发生在框架内部,你根本碰不到。另一个做法是改那个函数本身,把日志和判断写进去。但如果这个函数不是你的代码,或者编译产物已经不能改了,这条路也走不通。
这时候就需要Hook:在外部不动原代码,却在函数执行到一半时“截胡”,插入一段自己的逻辑,然后再放原逻辑继续跑。打个比方,原来的函数就像一条直达的公路,普通调用是车从A开到B。Hook就像在公路上设了一个服务区,所有车经过时都得先停一下,检查、加油、登记,然后放行。
1.2 一个只有十几行的函数替换Hook
高级语言里,最朴素的Hook其实就是“函数引用替换”。在Python中,函数是对象,函数名只是指向这个对象的一个变量。你完全可以提前保存原函数,再把名字指向一个新的包装函数。
python复制def get_balance(user_id):
# 假设这是某个底层接口,已经被很多业务方直接调用
return 10086
# 1. 先保存原始函数
_original_get_balance = get_balance
# 2. 定义包装函数
def get_balance_with_hook(user_id):
print(f"[HOOK] 收到查询请求: {user_id}")
result = _original_get_balance(user_id) # 调用原始逻辑
print(f"[HOOK] 原始返回: {result}")
if user_id == "blacklist_001":
print("[HOOK] 命中黑名单,修改结果")
return -1
return result
# 3. 执行替换:从这行开始,所有直接调用 get_balance 的代码都会先走上面的包装逻辑
get_balance = get_balance_with_hook
print(get_balance("u_001"))
print(get_balance("blacklist_001"))
注意一个关键细节:包装函数里不能直接写 get_balance(user_id),而必须调用第一步保存下来的 _original_get_balance。因为执行完替换后,全局名字 get_balance 已经指向包装函数自身,再调用就会无限递归,最后爆栈。
这段代码运行后,外部业务代码完全不需要改动,但只要它们通过 get_balance 这个名字发起调用,就会自动被“截胡”。你可以在截胡点做日志、做鉴权、修改返回值,也可以什么都不做只观察。这就是Hook最原始的形态。
1.3 从引用替换到函数入口跳转,差别只在于“改哪里”
有人会问:这个例子里 get_balance 函数本体没有变,只是名字被换了个指向,这也算Hook吗?算。它是解释型语言里最常见的Hook,Python、JavaScript、Ruby里大量使用。它的本质是改变了“调用点的解析结果”,让调用者在查找函数时找到了被掉包的那一个。
编译型语言和系统底层玩的则是另一种:函数真实的机器码就躺在内存里,调用方不管是通过导入表还是直接CALL指令,最终都要跳到同一个地址。Hook想拦截它,就得直接修改函数入口的机器指令,让CPU刚进入函数就跳到自己的代码里。第4章我会细讲这两类底层实现。
你可以先把“改名换引用”和“改内存指令”看成同一种策略的两个版本,目标都是让原执行流改道。真正区别在于,语言层的引用替换操作很安全、可回滚;内存层的Inline Hook则涉及线程同步、指令备份、调用栈平衡,风险高得多。但Hook思维是同一套。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Python里三种最常见的Hook写法,跑起来就能消化
2.1 装饰器写法:统一给一批函数挂上埋点
装饰器是Python里最像“Hook模板”的语法。它的作用就是把一个函数包装成另一个函数,再把新函数赋回原来的名字。看起来是魔法,其实和上一节 get_balance = get_balance_with_hook 完全同构。
直接看一个记录函数耗时的例子。
python复制import functools
import time
def log_duration(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
cost = (time.perf_counter() - start) * 1000
print(f"[duration] {func.__name__} cost {cost:.2f}ms")
return wrapper
@log_duration
def calculate(x, y):
time.sleep(0.02)
return x + y
print(calculate(1, 2))
@log_duration 本质上只是 calculate = log_duration(calculate) 的语法糖。执行完这行之后,业务代码里写 calculate(1, 2),进去的其实是外层 wrapper。包装函数在调用真正 calculate 的前后分别记录时间,这就是一次最简单的埋点Hook。
装饰器适合用在“你拥有源码、可以在定义处显式声明”的场景。如果你明确知道要Hook哪些函数,用装饰器可读性最好,也方便统一开关。它的缺点是“静态”,必须在代码里提前写好,没法在程序运行之后,对一个已经加载的第三方函数临时生效。真正上线排查问题时,更常用的是猴子补丁。
2.2 猴子补丁写法:替换第三方库的类方法
如果不想改动源码文件,又想在运行时对某个类的方法进行替换,这种操作叫Monkey Patch,比如动态给别人的类加方法、换实现。对Hook来说,它最典型的使用场景是:不是你的代码,但你想拦截它。
假设有一个支付类,现在要在支付前做个金额风控,但类的原代码不能改:
python复制import time
class PayService:
def pay(self, account, money):
print(f"真实扣款: {account} -> {money}")
return {"code": 0}
# 1. 先把原方法保存下来
_original_pay = PayService.pay
# 2. 定义一个新的实例方法
def pay_with_hook(self, account, money):
print(f"[HOOK] 收到支付请求: {account}, 金额: {money}")
if money > 10000:
print("[HOOK] 超过风控限额,拦截")
return {"code": 403, "msg": "blocked"}
return _original_pay(self, account, money)
# 3. 替换类方法
PayService.pay = pay_with_hook
PayService().pay("u_001", 200)
PayService().pay("u_002", 99999)
这里需要注意几个细节。第一,替换类方法时,包装函数第一个参数必须是 self,因为替换后的 PayService.pay 仍然会以实例方法的形式被调用。第二,保存原始方法时要用模块级局部变量 _original_pay,如果在 pay_with_hook 里再去写 PayService.pay(self, ...),就会再次进入新的包装函数,形成递归。第三,这种替换影响的是这个类后续所有调用,包括已经创建好的实例,因为Python实例方法在执行时动态到类对象上查找函数。
猴子补丁在测试、临时修复第三方库、插桩监控中非常高频。无侵入是它最大的价值,但也很容易埋坑,后面第5章会专门说。
2.3 回调注册写法:插进事件流里的Hook
还有一种写法不替换原函数,而是在原逻辑的关键节点预留出口,让调用方“注册”自己的回调。框架层帮你留好了Hook点,你只需要把自己那一段代码挂上去。
常见的例子包括:Web框架里的请求前置钩子、Android生命周期回调、前端DOM的 addEventListener、pytest的插件系统。这些本质上都是一种Hook:事件发生时,框架先执行注册进来的函数。
下面用一个极简的事件点来演示这种模型:
python复制class HookPoint:
def __init__(self):
self._callbacks = []
def register(self, fn):
self._callbacks.append(fn)
def emit(self, *args, **kwargs):
for cb in self._callbacks:
cb(*args, **kwargs)
# 两个观察者
def write_audit_log(user):
print(f"[审计] 用户 {user} 登录了")
def send_login_notice(user):
print(f"[通知] 给 {user} 推送登录提示")
login_hook = HookPoint()
login_hook.register(write_audit_log)
login_hook.register(send_login_notice)
def login(user):
# 登录核心逻辑,这里不关心有哪些观察者
print(f"[核心] {user} 正在登录")
login_hook.emit(user)
login("u_003")
和“函数替换型Hook”的区别在于,替换型Hook把路直接挖断了再重新接通,回调注册型Hook则是在路边预留了一个插线板,让需要的人自己插上功能。现在主流框架越来越倾向于回调注册,因为插件之间互不干扰,顺序可控,也方便框架在调用前后统一处理异常。
3. 从日志采集到反作弊,Hook的典型战场
3.1 无侵入埋点:日志、链路追踪、性能分析
我一直觉得Hook最大的价值不是“炫技”,而是解决了“我不能改线上代码,但必须知道线上发生了什么”这个经典矛盾。比如排查问题时发现某个核心服务变慢,但代码里没有性能统计。把服务停掉、加日志、重新上线,成本高且不一定能复现。用Hook方式,在目标函数外面包一层耗时统计,就能在不改业务逻辑的情况下,把每次调用耗时上报给监控系统。
稍微成熟一点的APM工具,内部普遍都有Hook引擎:有些基于字节码插桩,有些基于框架回调,有些基于运行时替换。原理和上面写的装饰器、猴子补丁没有本质区别,只是做了框架化和协议化。
我自己的经验是,无侵入埋点非常适合“对比验证”。比如你怀疑某个第三方SDK在某种网络环境下会出现慢请求,不需要改SDK代码,直接Hook它的请求方法,把所有出参入参、耗时、错误码全量打出来,一个小脚本就能把假设验证完。不干净,但快,非常适合做技术判断。
3.2 不改依赖源码也能修问题:补丁型Hook
有些线上问题,明明知道根源在哪,但依赖的SDK不维护了,或者升级成本很高,这时候Hook可以充当“补丁”。
举个实际遇到过的情况:某个老版本JSON解析库在解析超大数字时会丢精度,引发下游金额错误。你不可能为了这一个问题,把所有业务方都升级到新版本。折中方案是在初始化阶段,把解析大数字的内部方法替换成自己实现的高精度版本,或者在外面包一层对风险数值的预处理。这个操作本质上就是Hook。
补丁型Hook必须非常克制,能定位到一个明确的、较小的拦截面,而不能因为图省事就全局替换。如果补丁函数本身出现缺陷,影响范围会覆盖所有走这条逻辑的调用方,比原SDK的问题更可怕。所以每次做这种补丁,我都要在代码里留下足够清楚的注释,至少要把“为什么会有这个Hook”“何时可以移除”写明白。
3.3 测试界的Mock,本质也是一种Hook
很多做测试的同学可能没意识到,自己每天都在写Hook。unittest.mock.patch、pytest.monkeypatch 做的是什么事情?把一个对象的方法或属性替换成模拟实现,测试结束后再恢复。这就是Hook的典型动作。
Mock和Hook的关系可以帮助你理解“替换”这个动作的含义。
- 测试时不想真的调用外部支付接口,就可以把支付客户端的方法替换成一个返回固定结果的假方法。这样被测代码看起来在调支付,实际走的是测试替身。
- 想验证接口对超时的处理,不需要真的把网络断掉,只需要Hook住请求方法,让它抛一个
TimeoutError。
这种替换让测试变得可控,而Hook的所有坑也会在测试里遇到:忘记恢复原方法、模拟的实现和真实签名不一致、替换时机不对导致没有生效。如果能在测试框架里先积累这些经验,后面做系统级Hook会顺手很多。
3.4 Hook在安全监控里的角色
Hook在安全领域是一把绕不开的双刃剑。防御端会大量使用Hook:终端安全软件为了监控程序是否存在恶意行为,会在关键系统API上挂钩子,比如文件读取、进程启动、网络连接等,每当有程序触发这些API,就记录调用栈和行为特征。这不是为了攻击谁,而是为了发现异常行为。
另一个常见的应用是反外挂与Game Security对抗。诚实地说,恶意软件和游戏作弊程序也很喜欢用Hook去篡改逻辑、窃取输入。反作弊系统因此必须有能力检测“关键函数是否已经被Hook”:比对内存中的函数头和原始文件中的字节是否一致、扫描进程里是否加载了可疑模块、检查调用栈是不是被第三方库拦截了。这一整套攻防,本质上都在围绕“执行流是否被改写”做文章。
正因为Hook能力如此犀利,我在这篇文章里只讲原理和正常的工程、防御视角,不展开任何可用于绕过检测或恶意篡改的实现。理解它能干什么、会被怎么检测,已经足够你读懂绝大多数系统软件的开源代码了。
4. 往底层走一层:消息钩子、IAT Hook 和 Inline Hook 的区别
4.1 消息钩子负责“你能看到的事件”
对有UI的程序来说,系统在“事件分发层”就提供了Hook能力。图形界面系统收到鼠标、键盘、窗口消息后,会先经过一个消息队列,再分发给对应窗口。Windows系统提供的 SetWindowsHookEx 可以把一个钩子安装到指定的消息链上。不是直接改哪个函数的代码,而是把我们的自定义回调挂进系统的消息传递过程中。
这一层Hook编程门槛不高,适合做国际化输入法、翻译软件取词、无障碍辅助这类工具。它的特点是拦得“高”,离业务逻辑近,看到的是已经封装好的事件对象,而不是CPU指令。但这也意味着性能开销必须控制好,如果回调里做了耗时操作,整个消息分发链路都会被拖慢,你会明显感到系统卡顿。
4.2 IAT Hook修改的是“程序访问API的门牌号”
想拦截程序对系统API的调用,就不能只停留在事件层了。Windows下PE格式的EXE文件会有一张导入表,里面记录了它要用哪些系统DLL函数,每次调用 MessageBoxW 之类的API时,程序实际是通过导入表查找函数地址再跳过去的。
IAT Hook做的事情很直接:把导入表中记录的函数地址,偷偷改成我们自己函数的地址。程序执行到这里,以为自己在调 MessageBoxW,实际CPU跳进了我们的函数,等我们处理完再手动调用真正的 MessageBoxW。
它的优点是实施起来比Inline Hook稳定,因为不需要改写系统函数的机器码,风险较低。缺点是局限在导入表这一亩三分地。如果程序是用 GetProcAddress 动态获取函数地址再调用的,绕开了导入表,IAT Hook就拦不住了。
4.3 Inline Hook直接改CPU要执行的指令
比IAT更底层的实现是Inline Hook。它不管导入表怎么写,直接修改函数的机器码入口。程序每次进入这个函数,CPU都会先执行函数头部的指令,如果头部前几个字节被改写成一条跳转指令,执行流就会直接飞到我们自己准备的内存区域。
打个比方,IAT Hook是改了门口的门牌号,让访客走错门;Inline Hook是直接改了大门入口的地砖,人一踩上去就滑到另一条通道里。它的威力更强,能拦截更隐蔽的调用路径,但风险也更高。
难点在于:原始函数头部的几条指令在执行前必须做备份,否则跳回时原逻辑就丢了;修改函数头部时如果其他线程正在执行这些指令,可能触发崩溃;被Hook的函数越短,可供修改的字节越少,改起来也越麻烦。正因如此,成熟的Hook框架才需要做线程挂起、缓存刷新、指令长度反汇编这些复杂操作。
4.4 三种Hook方式的对比与检测思路
我把这几种Hook的层次梳理成一张表,方便对照理解。
| Hook类型 | 作用层 | 修改目标 | 常见场景 |
|---|---|---|---|
| 消息钩子 | 系统事件分发 | 消息路由或事件回调 | 翻译插件、无障碍工具、UI自动化 |
| IAT Hook | 进程模块边界 | EXE的导入表项 | API调用监控、兼容性适配 |
| Inline Hook | CPU指令执行流 | 函数入口机器码 | 安全监控、探针、调试器、反作弊检测 |
检测思路通常是反过来做的:检查导入表里的函数地址是否指向了可疑模块,叫IAT检查;对比内存中函数入口的字节和磁盘原始DLL的同名函数是否一致,叫Inline Hook检查;再看调用栈里是否出现了不属于业务链路的第三方模块,可以部分识别事件层Hook。这些技术没有善恶属性,是防守方识别篡改行为的重要手段。
5. 写Hook的五个高风险点,我都踩过
5.1 没保存原引用,一进来就无限递归
这个坑几乎所有写过Monkey Patch的人都踩过。包装函数里不仅要写自己的逻辑,最后还得调用原逻辑。如果你没有提前用局部变量把原函数保存下来,而是在包装函数里继续写“同名类方法”,那执行时就又会钻进你自己的包装函数,形成死循环,直到栈溢出。
python复制PayService.pay = pay_with_hook
def pay_with_hook(self, account, money):
# 错误写法:又触发了一次新的 pay_with_hook
return PayService.pay(self, account, money)
正确写法是把原方法保存到一个模块级私有变量里:_original_pay = PayService.pay,之后再在包装函数里调用 _original_pay(self, account, money)。这个原则听起来很简单,但在多个Hook叠加、改名重构、类继承等复杂场景里很容易被破坏。每加一层Hook,都要厘清这个调用最终会走到哪里。
5.2 回调抛异常,直接把原业务带崩
很多线上事故不是因为Hook写错了逻辑,而是Hook里出现了未捕获异常,然后异常顺着调用链一路传到业务方,把原来的功能搞挂了。比如一个只为了记录日志而加的Hook,如果日志对象临时不可写、抛了个 OSError,那支付流程也会跟着失败。
所以观察型Hook必须遵循一个原则:你的日志、统计、监控逻辑,不能影响主业务。实现上我会在Hook里做明确的异常隔离。核心逻辑调用原函数,外面包一层 try/finally 确保不管成功失败都能埋点;如果只是附加行为,则单独用 try/except 把异常记录下来,再决定是吞掉还是原样抛出。绝不轻易让埋点代码自己抛出新异常。
5.3 多线程并发,统计数字对不上
Hook一旦挂到“被高频调用”的函数上,就可能运行在任何线程里。若你自己维护了一个 counter 用来计数,又没加锁或没用原子操作,最终结果往往少于真实次数。在高并发下,count += 1 读和写之间会被其他线程打断,精度就丢了。
如果只想记录调用次数、耗时这类指标,建议直接使用线程安全的数据结构或原子变量,不要在回调里做复杂的共享状态修改。回调里也尽量别做阻塞性IO,否则可能把工作线程拖垮。Hook回调应该是轻量、快速、无阻塞的。
5.4 替换没生效,原来是对着副本使劲
还有一种隐蔽的情况:你替换了A方法,但代码里早就用 func = obj.method 这种方式把原方法取出来赋给了变量,后续业务一直调用的是那个变量。Hook替换的是类字典里的名字,这个提前绑定到局部变量的引用并不会跟着变,所以你忙半天,外面该走旧逻辑还是走旧逻辑。
遇到这种情况,先顺着调用链查清楚函数到底是从方法入口进入的,还是已经变成了一个被缓存的函数对象。同样,Python里如果某个方法被装饰器替换过,你再从外层对它做Monkey Patch,target的获取方式也要一致,否则你修改的可能是一个wrapper而非最终执行逻辑。所有这些,归根到底都在提醒你:Hook不是修改了“函数实体”,而是修改了“到达函数的那条路”。
5.5 线上Hook一定要留退路
Hook本质上是一个运行时补丁,迟早要移除或更新。所以它不是写完了就可以不管的代码,必须设计成可管理、可恢复的。我在实际项目里会单独封装一个 HookRegistry,把所有被替换的原始引用集中保存,并暴露 install 和 uninstall 方法。这样上线时先关掉开关,观察稳定后再灰度放开;一旦出问题,可以通过配置中心动态卸载Hook,恢复原函数。
线上加Hook还有一个容易被忽略的风险:Hook生效时机太早或太晚都会出问题。太早,所在的模块还没初始化完整;太晚,某些核心对象已经按旧逻辑初始化过了。稳妥的做法是在启动流程里选择一个非常靠前但又确定所有前置模块都已就绪的点位安装Hook,并且在正式启用前先打一行日志确认“目标函数已被替换”。
我自己现在养成的习惯是:写Hook代码之前,先在心里回答三个问题。第一,我的Hook会不会影响原逻辑的异常路径?第二,如果Hook在运行时崩了,系统能不能降级到无Hook状态?第三,这段Hook代码什么时候可以删?如果这三个问题都回答不清楚,那最好先不要用Hook解决眼前的问题,因为它带来的隐藏复杂度,往往比常规业务代码更高。
HOOK技术本身不复杂,真正的复杂度在于你拿它改写了运行期行为之后,别人甚至未来的自己已经很难从代码表面看出底层执行顺序了。所以保存好原引用、隔离好异常、记录好日志、留好后门,这几点比Hook叫什么名字重要得多。如果你能把前面那些小例子亲手跑一遍,再在真实项目里找一两个日志埋点需求练手,我保证你对程序“调用过程”的感知会和之前完全不同。
