substitute 运行时 Hook 库核心原理深度解析:transform-dis 如何改写机器码生成 trampoline
【免费下载链接】substituteA free runtime modification library.项目地址: https://gitcode.com/gh_mirrors/su/substitute
substitute 是一款免费的运行时修改(runtime hook)库,其核心模块 transform-dis 负责逐条反汇编目标函数、改写机器码,并自动生成 trampoline(跳板码),让你既能拦截函数,又能安全地调用原实现。本文将用尽量少的代码,带你完整看懂这条改写流水线是如何工作的。
一、substitute 是什么?能做什么?
substitute 是一个面向 Darwin(macOS / iOS)的开源函数 Hook 库,对外提供三类能力:
- 函数头改写:
substitute_hook_functions直接修改目标函数开头的机器码,把入口重定向到你的替换函数; - 导入表替换:
substitute_interpose_imports修改 GOT/PLT 条目,无需写可执行代码即可拦截导出函数; - Objective-C 方法替换:
substitute_hook_objc_message基于class_replaceMethod的线程安全方法 Hook。
本文聚焦最核心、也最烧脑的第一种:函数头改写(inline hook)。
二、为什么必须改写机器码、又必须生成 trampoline?
Hook 一个函数的最直接做法是:把它开头的若干字节改成一条"跳转到你的替换函数"的跳转指令。但这带来两个难题:
被覆盖的指令去哪了?函数开头被覆盖的那些原始指令,仍然需要可执行——比如你通过
old_ptr拿到的"原函数指针",或者被 Hook 的函数内部逻辑。substitute 的解法是把这些指令原样(或改写后)复制到一块新的可执行内存中,拼成所谓的outro trampoline(尾跳板):尾跳板 = 被覆盖指令的改写副本 + 一条跳回原函数"剩余部分"的跳转。
跳转距离可能不够。ARM 的条件分支只有 ±128MB 左右范围,x86 的相对跳转更短。如果替换函数离得太远,substitute 会先分配一块靠近目标的内存写入长跳转(
make_jump_patch),函数入口先跳到这个intro trampoline(头跳板),再由它跳到最终目标。
因此一次完整的 Hook =头跳板(可选)+ 函数入口短跳转 + 尾跳板(改写指令 + 回跳),其中尾跳板正是 transform-dis 的产物。
三、整体流水线:从 API 调用到落盘
核心调度逻辑在lib/hook-functions.c的substitute_hook_functions中,整体分为两个阶段:
阶段 1:预检与生成(不碰原代码)
- 计算需要的覆盖字节数:
check_intro_trampoline根据"替换函数距离"和架构(ARM 8 或 10 字节,ARM64 8/12/16 字节,见lib/arm/jump-patch.h、lib/arm64/jump-patch.h)决定 patch 区域大小; - 生成 intro trampoline:若需要远跳转,先写入头跳板;
- 生成函数入口的跳转补丁(
make_jump_patch); - 调用
transform_dis_main生成 outro trampoline——本文主角,见下节; - 调用
jump_dis_main做安全性启发式扫描,检查函数后文是否有跳回 patch 区域的跳转(见第五节)。
阶段 2:原子提交
- 把跳板内存页"密封"(seal),禁止继续修改;
- 暂停其他线程,把跳转补丁写入原函数入口,同时校正正好停在被覆盖区域内的线程 PC(
execmem_foreign_write_with_pc_patch)。
这种"先全部预演、再一次性提交"的设计,保证任何一步失败都不会破坏目标函数。
四、transform-dis:机器码改写的核心
4.1 通用骨架:一条指令一条指令地走
入口是lib/transform-dis.c中的transform_dis_main,它的工作循环非常朴素:
- 从 patch 区域起点开始,逐条反汇编指令;
- 判断指令类型,分两种处理:
- 普通指令:原样复制到跳板区;
- 含 PC 相关地址的指令:复制到跳板后地址就错了,必须重新编码;
- 每复制一条,就在
offset_by_pcdiff偏移表中记录"原始偏移 → 跳板内偏移"的映射,供后续修正被覆盖区域内的 PC 修正逻辑使用; - 遇到错误(如 patch 区域中间出现
ret)立即返回对应错误码。
架构相关的"怎么解码、怎么改写"则由lib/<arch>/arch-transform-dis.inc.h与lib/<arch>/dis-main.inc.h提供,通过回调函数接入通用骨架。ARM 的反汇编还会按最低位自动分发到 Thumb / Thumb-2 / ARM 三种模式(见lib/arm/dis-main.inc.h)。
4.2 三类"必须改写"的指令
以 ARM 为例(lib/arm/arch-transform-dis.inc.h),transform-dis 需要处理三类 PC 相关指令:
| 指令类型 | 例子 | 改写策略 |
|---|---|---|
| 数据运算中使用 PC 作为操作数 | adr r3, X、ldr pc, [pc, r0, lsl #2] | 分析寄存器读写关系,分 4 种情况处理:直接重编码、借用空闲寄存器、或借用栈保存临时值(PUSH/POP + MOVW/MOVT) |
| PC 相对寻址 | adr、字面量池ldr、PC 相对跳转 | 把相对地址换算为绝对地址,改用MOVW/MOVT载入,必要时重写加载模式 |
| 分支/调用 | b、bl、Thumbcbz等 | 条件分支先补一条反向条件跳转保持语义,再用MOVW/MOVT+BLX r14实现远跳转 |
其中 ThumbIT 块(指令谓词条件)处理得尤为精细:transform_dis_pre_dis/transform_dis_post_dis会把一个 IT 块拆解为"每条指令各自一条条件跳转"的形式,因此force_keep_transforming标志会让主循环强制继续消费整个 IT 块,避免在块中间截断。
此外还有一个TRANSFORM_DIS_BAN_CALLS选项(线程安全模式下默认开启):如果 patch 区域中间出现函数调用,就放弃 Hook(返回SUBSTITUTE_ERR_FUNC_CALLS_AT_START)。原因是调用产生的返回地址可能正停留在其他线程的栈上,落在被覆盖区域里就无法安全修正了。
4.3 trampoline 的拼装细节
回到hook-functions.c,transform_dis_main返回后:
pc_patch_end会被向后扩展到完整的指令边界(甚至整个 IT 块),确保覆盖区域不会"咬断"一条指令;- 在跳板尾部再写入一条跳回
pc_patch_end的make_jump_patch,outro trampoline 就此完成; hook->old_ptr指向的就是这个尾跳板入口——调用"原函数"时,实际执行的是"改写副本 + 原函数剩余部分"的无缝拼接。
而offset_by_pcdiff偏移表则负责最后一块拼图:原函数中凡是引用被覆盖区域内部位置的 PC 相对指令,都可以按表把地址修正到跳板中的对应副本位置。
五、jump-dis:动手之前的安全检查
生成跳板后,lib/jump-dis.c的jump_dis_main还会做一次启发式跳转分析:从函数开头向后再走最多 512 条指令,追踪所有分支目标,一旦发现某条指令会跳回 patch 区域内部(SUBSTITUTE_ERR_FUNC_JUMPS_TO_START),就放弃本次 Hook。
这个检查是"尽力而为"的——它不处理跳转表,遇到ret之后的代码也不再深入——但足以挡住绝大多数会把程序执行流带进已被覆盖区域、从而崩溃的情况。
六、常见错误码速查
substitute_hook_functions的失败原因一目了然,定义在lib/substitute.h:
| 错误码 | 含义 |
|---|---|
SUBSTITUTE_ERR_FUNC_TOO_SHORT | patch 区域中间就出现了ret,函数太短 |
SUBSTITUTE_ERR_FUNC_BAD_INSN_AT_START | 开头的指令属于暂不支持的特殊情况(如跳回本区域) |
SUBSTITUTE_ERR_FUNC_CALLS_AT_START | 线程安全模式下,开头存在调用指令 |
SUBSTITUTE_ERR_FUNC_JUMPS_TO_START | jump-dis 发现函数后文跳回被覆盖区域 |
SUBSTITUTE_ERR_OUT_OF_RANGE | 目标过远且拿不到足够近的跳板内存 |
SUBSTITUTE_ERR_VM | mmap / mprotect / vm_remap 失败(如内核禁止页面可执行) |
想在自己的环境验证这些行为,test/test-transform-dis.c、test/test-jump-dis.c、test/test-pc-patch.c以及各架构的test/transform-dis-cases-*.S用例是很好的起点。
七、关键源码导读路径
- 对外 API 与文档注释:
lib/substitute.h - Hook 主流程(预检 → 生成 → 原子提交):
lib/hook-functions.c - 机器码改写核心循环:
lib/transform-dis.c(接口在lib/transform-dis.h) - 通用反汇编分发层:
lib/dis.h、lib/darwin/各架构dis-main.inc.h - ARM 的 PC 相关指令改写策略:
lib/arm/arch-transform-dis.inc.h - 各架构跳转补丁(决定 patch 大小):
lib/arm/jump-patch.h、lib/arm64/jump-patch.h、lib/x86/jump-patch.h - 跳转回溯安全检查:
lib/jump-dis.c - 内存写入与线程 PC 修正:
lib/darwin/execmem.c
八、总结
substitute 的巧妙之处在于把"改机器码"这件高风险的事拆成了清晰的四层:通用反汇编分发层保证多架构可移植,transform-dis 改写层逐条修正 PC 相关指令并顺带产出 trampoline,jump-dis 检查层用启发式分析拦截危险目标,execmem 提交层用暂停线程 + PC 校正保证原子性。理解了这条流水线,你就理解了绝大多数 inline hook 库的通用设计,也为读懂各类动态插桩工具打下了坚实基础。
【免费下载链接】substituteA free runtime modification library.项目地址: https://gitcode.com/gh_mirrors/su/substitute
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考