上一篇把 PLT Hook 跑通之后, native hook 能力其实已经能覆盖一批很常见的场景了。但很快就会碰到一个更现实的问题:并不是所有 native 函数调用都会经过导入表,也不是所有我们想接管的目标,都适合用 PLT Hook 去处理。
PLT Hook 改的是导入调用链,准确地说,是“调用方通过 PLT/GOT 去找目标函数”这条路径。
可如果目标函数根本不是一个导入函数,或者它在模块内部是直接跳转,甚至我们就是想改掉这个函数本体的执行入口,那么 PLT Hook 就到头了。这时候真正该上场的,就是 Inline Hook。文章中有讲的不对的欢迎在评论指出!
项目地址:ddaK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6^5x3h3q4G2L8U0q4F1k6#2)9J5c8V1&6G2L8$3D9`.
这一篇我希望把下面这几件事讲清楚:
ARM64 的所有 A64 指令都是固定 32 位,也就是 4 字节,patch 时必须按 4 字节为单位覆盖。
ARM64 的每条指令虽然都是 32 位,但这 32 位并不是一个“整体数值”,而是由若干个 bit 字段组成的。
可以先把它粗略理解成:
所以你后面看到类似这样的判断:
其实是在按 ARM64 文档规定的 opcode 编码方式做匹配。它做的事情本质上就是:用掩码 0xFC000000 把高位中和“指令类型”有关的部分保留下来,看看它是不是 B 指令对应的固定模式 0x14000000
以B指令为例,它的 32 位编码格式可以写成:
其中opcode 用来说明“这是一条 B 指令”,imm26 用来表示跳转偏移
B 指令的高 6 位固定是:000101
B somewhere它的机器码可能长这样:
在当前项目里,最基础的工具函数之一是:
它的作用就是从一条 32 位指令里,取出第 start 位到第 end 位之间的那一段 bit 字段。
例如
表示取出低 26 位,这正是 ARM64 B/BL 指令里的立即数字段。
顺便提一下ARM64 最常用的是 x0 ~ x30 这 31 个 64 位通用寄存器,在前面的文章中简单提到过函数调用约定的概念,在inline hook中也需要了解这个调用约定。
ARM64 里有一类非常关键的指令,叫 PC-relative 指令,也就是执行结果依赖当前 PC 地址的指令。比如常见的 adr、adrp、ldr literal 之类,很多都不是“单纯执行当前寄存器计算”,而是会根据指令所在位置去算目标地址。这类指令在原函数入口处执行时没有问题,但如果你把它原封不动搬到 trampoline 里,指令所在位置变了,PC 也变了,最终算出来的地址就可能错掉。
所以 inline hook 不能只做“复制字节”,还必须做“指令重定位”:识别哪些指令依赖PC,再在搬到新地址后重新修正它们。
比如
这通常是先拿到某个页基址,再加页内偏移,最终形成完整地址。这种写法在访问全局变量、字符串、GOT 表项时非常常见。问题在于这条指令在原位置执行,算出来的地址是对的,但是一旦搬到 trampoline,PC 变了,结果就可能错。
站在 inline hook 的角度看,trampoline 可以理解成“原函数入口的替身执行区”。它的设计目标不是单纯跳一下,而是同时解决两个问题:1.原函数入口已经被 patch 掉了,旧代码不能直接从原地址开始执行;2.replacement 里通常还需要“继续调用所以,trampoline 的本质就是:把被覆盖掉的原始指令搬到新地址,修正它们,再从那里跳回原函数剩余部分。原逻辑”,所以必须给调用方一个新的、可安全执行的 original 入口。入口被 patch、replacement 仍需调用 original,所以trampoline 里的代码并不是“原样副本”,而是“语义等价副本”,这和上面提到的PC-relative指令有关。
inline hook 最后要修改的是代码段内存,而代码段默认通常不是可写的。所以真正 patch 之前,必须先把目标页改成可写,修改完成后再恢复权限。但只改内存权限还不够。ARM 平台还要考虑指令缓存一致性。CPU 可能已经把原来的指令缓存进 I-Cache 里了,如果你只是把内存内容写掉,CPU 仍然可能继续执行旧缓存。所以在 patch 完之后,还必须刷新指令缓存。Nook 会结合 __clear_cache 这一类机制,确保新写入的跳转指令真的能被 CPU 执行到。
linker、soinfo、call_constructors 这几个东西,主要不是为了“实现入口 patch”本身,而是为了实现目标 so 还没加载时先登记,等模块真正进来后再安装。这时候就必须接触动态链接器的加载流程。
linker 指 Android 的动态链接器。它负责把 ELF so 加载进进程,完成这些事情:
如果我们想要hook某一个so中的函数,但此时该so还没有被加载,我们就必须先知道so是什么时候被加载进来的,因此需要引入linker相关逻辑。
soinfo 可以理解成 linker 内部用来描述一个已加载 so 的对象。它不是标准 ELF 概念,而是 Android linker 自己维护的运行时结果。这个结构里面通常会有:模块路径、基址base、大小、动态段信息等等,我们这里关心的是它能提供“这个模块是谁、它被加载到哪、接下来能不能对它安装 hook”这些信息。
call_constructors 是动态库加载过程中很关键的一步,它的名字已经很直白了,就是“调用构造函数”。之前的文章里面也简单提到过一个 so 被加载时,通常流程不是只把它 mmap 进来就结束了,还会继续做:
call_constructors 就处在这个比较靠后的阶段。
对 hook 框架来说,这个位置很有价值,因为当执行到这里时,通常意味着这个模块已经完成了基本加载,他的内存映射和重定位大多已经可用了。
假设我们有这样一个普通函数:
现在我们的目标是:当程序调用 Add(1, 2) 时,不再直接进入原函数,而是先进入我们自己的替换函数,在里面打印参数、调用原逻辑、再修改返回值。
从接口语义上,这件事通常会写成这样:
这段代码里最重要的是三个角色:
安装完成后,执行流就会从原来的:
变成
也就是说,inline hook 做的并不是“把函数指针换掉”,而是直接改写目标函数入口处的机器码。原来程序一调用 Add,CPU 会从 Add的第一条指令开始执行;而 hook 之后,这个入口已经被 patch 成了一段跳转代码,所以 CPU 一进去就被重定向到 Hook_Add 了。
但问题也随之出现:如果 Add 的前几条指令已经被改写了,那 orig_Add 为什么还能继续执行“原函数”?答案就是 trampoline。
框架在安装 hook 时,会先把 Add 入口处即将被覆盖的那几条原始指令搬到另一块新的可执行内存里,再在那块内存的末尾补一段“跳回 Add + 覆盖长度”的代码。这样,orig_Add 实际指向的就是这段 trampoline。它不是回到 Add 的原始入口,而是先执行“被搬走的前几条指令”,然后再接回原函数后半段。
所以从这个最小例子里,其实已经能看清 inline hook 的本质:
上面的其实是C/C++的视角来看,真正在运行时中,CPU并不知道什么叫orig_Add、Hook_Add,它只认识一条条 ARM64 指令。所以从机器执行的角度看,inline hook 本质上是在改三样东西:
假设 Add 编译成 ARM64 后,函数开头大致是这样:
如果需要在这里写入一段“跳到 Hook_Add”的 patch,而假设这个 patch 正好需要 16 字节,那么它就必须覆盖前 4 条指令。覆盖之后,原函数入口就不再是原来的样子了。
hook 安装完成后,Add 的入口逻辑上会变成这样:
这里的 jump Hook_Add 只是逻辑表达,不一定真的是汇编里单独一条 b Hook_Add。在 ARM64 里,框架为了兼容任意地址,通常不会假设目标地址一定在短跳范围内,而更倾向于构造一种“绝对跳转模板”,比如逻辑上等价于:
或者别的等价实现,核心思想只有一个:原函数入口不再执行旧指令,而是立刻把控制流转交给 replacement,这个具体会在后面讲。
这一步做完之后,后续任何对 Add 的调用,都会先进入 Hook_Add。
原本 Add 的前几条指令是:
现在它们已经被 patch 覆盖掉了。也就是说,如果不做额外处理,原函数逻辑其实已经断了。orig_Add 也就根本不可能存在。
所以必须在 patch 原入口之前,先把这些即将被覆盖的指令搬到别处。这个“别处”就是 trampoline。
逻辑上,trampoline 可能会长成这样:
最后这一句 jump Add + 16 的意思是:前面被覆盖掉的 16 字节我已经帮你执行完了,现在回到原函数第 5 条指令继续往下跑。 这样,原函数的执行流就被接起来了。
original 为什么能继续调用原逻辑呢?这时就可以重新看 orig_Add 的意义了,它并不是:
因为 Add 的入口已经被改写成跳到 Hook_Add 了,你如果还把 orig_Add 指向 Add,那它一调用又会重新进入 hook,自然就死循环了。
真正正确的做法是:
所以当 Hook_Add 里调用:
实际发生的事情是:
先看最短主线:
如果站在“调用方写代码”的角度,这次 hook 往往是这样发起的:
这里传入的 4 个参数分别是:
框架先校验参数是否合法等,最终进入真正的安装函数InstallInlineHook
它要完成的事情可以概括成五步:
一旦决定要 hook 某个函数,接下来第一件关键事情就是申请 trampoline 内存,用来放“被搬走的原始指令”和后续的回跳代码。这一步就是上面的AllocateExecutableTrampoline。他承担了两个角色:1. 作为原入口前半段代码的新执行位置;2. 作为 original 最终暴露给调用方的可调用入口。
接着是重定位原始指令RelocateArm64InstructionSequence,这和上面提到的PC-Relative指令有关,他的职责是:
当被覆盖的原始指令已经重定位进 trampoline 后,trampoline 还差最后一块拼图,那就是“回跳”。因为 trampoline 只保存了原函数入口前面那一小段代码,它并不包含整个原函数。所以在执行完这几条搬运过去的指令之后,还必须继续回到原函数剩余部分,也就是:
这一步通常也是通过跳转 patch 来完成的。所以 trampoline 的完整逻辑其实就是:
然后才到了改写原入口WriteAbsoluteJumpPatch,这里的目标很明确,就是让所有原本进入 target 的执行流,先跳到 replacement。
当入口patch写好后,只要程序调用目标函数,就会先被重定向到 replacement。但是到这还并没有结束,还需要考虑后续的可管理性,比如:
所以框架还要把这次 hook 的信息登记起来,这就是ActivateInlineHookRecord的作用。
一条 hook record 里通常会包含:
如果站在调用方角度,一次安装完成后的效果可以概括成这样:
于是执行流从原来的:
变成:
先从入口InstallInlineHook开始,函数签名设计为:
这四个参数正好对应一次 inline hook 的四个核心对象:
当前实现里,ARM64 入口 patch 固定占 5 条指令,也就是 20 字节
并且上面也提到过original 最终拿到的,并不是 target_address 本身,而是 trampoline.address,因为 target_address 后面会被改写成“跳到 replacement”的入口 patch,如果还把 original 直接指向 target_address,那replacement 里一调 original,就会再次跳回 replacement,最后直接死循环。
先创建 handle,再备份目标入口前 5 条指令,接着它分配一个 InlineHookHandle:
然后直接把目标函数入口前 5 条指令拷出来:
这里的 original_words 非常关键,它就是“即将被覆盖掉的原始入口指令”。后面会发生三件事:
这段代码的意思是:
也就是说,trampoline 大小不是简单 5 * 4 字节,而是回跳 patch 的长度 + 前 5 条指令各自重定位后的展开长度,这个设计说明一件事:一条 ARM64 指令搬到 trampoline 后,未必还是 4 字节,有可能膨胀。
算完大小以后,才真正分配 trampoline:
后面会把重定位后的指令直接写到handle->trampoline的 address 上
这里传进去的参数含义:
RelocateArm64InstructionSequence 的实现分两段。
第一段先预计算每条指令重写后的长度:
这里的含义在于:先不急着改写,而是去计算指令修复之后会膨胀成多长,并存进rewritten_lengths
用于后面:
核心逻辑是:
如果原来某条分支跳到“被搬走的第 3 条指令”,那重定位以后,它就不能还跳到原地址了,而要跳到 trampoline 里“第 3 条重写后 指令块”的新地址。
第二段再逐条真正重写:
前面我们已经看到,InstallInlineHook 在构造 trampoline 时,关键的一步就是调用:
其实就是把原来位于 source_address 的一段 ARM64 指令,搬到 relocated_address 对应的输出区里。
后面真正重写的工作核心:RewriteWithInternalContext(...)又是怎么工作的呢?
先做指令类型识别:
它告诉我们:当前项目重点处理的是这些会受位置变化影响的 ARM64 指令:
比如无条件跳转和带链接跳转:
可以拆成四步理解:
由EmitAbsoluteBranch生成跳转函数:
B 被重写成 BR X17,BL 被重写成 BLR X17,也就是说,原来靠相对偏移跳转,现在统一变成“先加载绝对地址,再走寄存器跳转”。
这里做的事情也很清楚:先按ADR或ADRP的编码规则还原它本来要得到的地址,再把这个地址直接塞进字面量里,最后用LDR Xd, #8把绝对地址加载到目标寄存器rd
以 B.cond 为例:
含义是: output[0] 仍然保留原来的条件判断,只是把分支偏移改成很短的 #8
如果条件成立,就跳过 output[1],进入后面的绝对跳转逻辑
如果条件不成立,就执行 output[1] = B #20,直接越过整个“绝对跳转块”
所以重定位后的控制流等价于:
只是这里的 goto target 已经变成了“加载绝对地址 + BR”。
CBZ/CBNZ、TBZ/TBNZ的处理几乎同构:
例如:
原来的 LDR literal 语义是:以当前 PC 为基准,算出某个 literal 地址,再从那个地址取数据。
重定位后不再依赖当前 PC,而是拆成两步:
这里的思路是:
当 RelocateArm64InstructionSequence 完成后,trampoline 里只放好了“被覆盖的前 5 条原始指令的重定位版本”。这还不够,因为执行完它们之后,还要回到原函数剩余部分。
这里和前面的绝对跳转模板是同一套思路:
唯一变化是,这次跳的不是 replacement,而是:
也就是 target + 20,即原函数被覆盖区域之后的地址。
假设目标函数 target 的入口地址是,0x100000
它前 5 条 ARM64 指令是:
最终生成的trampoline大概长这样:
接下来框架会先把这次 hook 的关键信息登记进 record:
这里把几个关键信息都塞进 record 里了:
前面的准备都完成后,才来到真正的 patch 动作:
WriteAbsoluteJumpPatch这一步会在 target_address 处构造一段固定 5 word 的绝对跳转模板:
这里的target实际上是:
也就是说,入口 patch 干的事情本质上就是:把 replacement 的绝对地址塞进模板,然后让 target_address 入口一执行就跳到 replacement
WriteAbsoluteJumpPatch 的真正写入过程是:
也就是运行时 patch 的三个关键动作:改权限、写机器码、刷新指令缓存。
安装成功后,original 指向的是 trampoline:
最后看 unhook:
它的执行顺序也很清楚:
其中真正负责“恢复原入口”的是:RestoreOriginalCode。
RestoreOriginalCode 会把 record.original_code 直接 memcpy 回原函数入口,再刷缓存
传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!