上一篇把 PLT Hook 跑通之后, native hook 能力其实已经能覆盖一批很常见的场景了。但很快就会碰到一个更现实的问题:并不是所有 native 函数调用都会经过导入表,也不是所有我们想接管的目标,都适合用 PLT Hook 去处理。PLT Hook 改的是导入调用链,准确地说,是“调用方通过 PLT/GOT 去找目标函数”这条路径。
可如果目标函数根本不是一个导入函数,或者它在模块内部是直接跳转,甚至我们就是想改掉这个函数本体的执行入口,那么 PLT Hook 就到头了。这时候真正该上场的,就是 Inline Hook。文章中有讲的不对的欢迎在评论指出!项目地址:11fK9s2c8@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 位固定是:000101B 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。它不是回
...(已截断)
---
来源: 看雪论坛
原文链接: https://bbs.kanxue.com/thread-291145.htm
[原创]从0到1构建一个Hook工具之Inline Hook篇(五)
334 浏览
9 回复
感谢分享
tql
感谢分享
ql
ql
学习学习
感谢分享
nice
非常感谢,学习学习