Android inline Hook 笔记

8 阅读6分钟

Inline Hook(内联钩子)是一种非常底层、非常强大的 Hook 技术。与之前我们讨论的、通过修改方法表或注册表来实现 Hook 的方式不同,Inline Hook 直接修改目标函数的机器码,在函数体的开头写入一条跳转指令,强行改变程序的执行流程。

你可以把它想象成一场精密的铁路调度:原本的火车(程序执行)沿着既定轨道(函数A)行驶。Inline Hook 就像是在轨道起点处突然插入一个道岔,让火车瞬间切换到另一条新轨道(你的钩子函数)。在你的函数里处理完业务后,再通过一条隐蔽的支线,将火车引导回原来的轨道继续行驶。

1. Inline Hook 核心原理

Inline Hook 的本质是在运行时修改可执行代码。为了让你更直观地理解,我们来看一下它的工作流程对比:

flowchart TD
    subgraph A [正常执行流程]
        direction LR
        Call[调用函数A] --> Entry[执行函数A入口指令] --> Body[执行函数A主体] --> Return[返回]
    end

    subgraph B [Inline Hook后执行流程]
        direction TB
        Start[调用函数A] --> Hijack{函数A入口被修改}
        
        Hijack -->|跳转| HookFunc[执行钩子函数]
        HookFunc -->|1. 执行自定义逻辑| Logic[自定义代码]
        Logic -->|2. 执行原入口指令| OrigEntry[执行备份的入口指令]
        OrigEntry -->|3. 跳回| Resume[跳回函数A的剩余部分]
        Resume --> ReturnToCaller[返回]
    end

如图所示,这个流程的关键在于几个核心步骤:

  1. 指令备份:在修改目标函数前,必须先将其开头的若干字节(例如5-8个字节,取决于架构和跳转指令长度)完整地保存下来。这是为了在钩子函数执行完后,能恢复现场并执行这些被覆盖的原始指令。
  2. 构建跳转:在目标函数开头,用一条跳转指令(如 jmp)覆盖备份的原始指令。这条指令的目标地址就是你的钩子函数地址。在32位系统中,跳转地址通常通过相对偏移计算;而在64位系统中,由于地址空间更大,通常采用 mov rax, target; jmp rax 的指令序列,将绝对地址存入寄存器后再跳转。
  3. 执行钩子与“隧道”:当程序执行到被修改后的函数入口时,会直接跳转到你的钩子函数。在你的钩子函数里,你需要:
    • 执行自定义的监控或修改逻辑。
    • 执行之前备份的原始指令(通常是在一片新分配的内存区域中执行,称为“trampoline”或“隧道”)。
    • 最后,跳转回原函数剩余的指令部分,继续执行。

2. 关键技术难点与实现细节

Inline Hook 之所以强大,是因为它触及了底层。但也正因如此,它的实现充满了技术挑战:

  • 修改内存页权限:代码段在内存中通常是只读的,以防止意外或被恶意修改。要写入跳转指令,首先需要用 mprotect (Linux/Android) 或 VirtualProtect (Windows) 等系统调用,将目标函数所在的内存页权限修改为可读、可写、可执行 (PROT_READ | PROT_WRITE | PROT_EXEC)。
  • 指令重定位:这是 Inline Hook 中最复杂的部分之一。当我们把备份的原始指令复制到新的内存区域(trampoline)去执行时,这些指令中如果包含了相对寻址的指令(例如跳转到某个相对偏移的地址,或者访问相对于PC寄存器的数据),由于执行地址发生了变化,原来的相对偏移就失效了,直接执行会导致程序崩溃。
    • 解决方案:必须像编译器一样,对这些指令进行反汇编、解析,并重新计算目标地址,然后生成新的指令来替换。这需要非常深厚的底层知识。
  • 多架构适配:ARM、ARM64、Thumb、x86、x86_64……每种指令集的编码格式、指令长度、跳转方式都完全不同。一个成熟的 Inline Hook 框架需要为每种架构单独实现一套逻辑。
  • 线程安全:在多线程环境下,如果一个线程正在执行目标函数,而另一个线程正在修改它的入口指令,程序会瞬间崩溃。因此,Hook 和 Unhook 的过程必须是原子操作,需要非常谨慎的同步机制。

3. 实际案例:Hook malloc 监控 Native 内存泄漏

理解了原理,我们来看一个在 Android 开发中非常实用的案例:通过 Inline Hook 监控 malloc 函数,来检测 Native 层的内存泄漏。

场景:你的应用 Native 层疑似存在内存泄漏,需要监控所有 malloc 调用,记录分配的大小和调用栈。

核心实现思路

  1. 找到目标:通过 dlsym(RTLD_NEXT, "malloc") 获取 libc.somalloc 函数的实际地址。
  2. 备份指令:保存 malloc 函数开头的 8 个字节(以 ARM64 为例)到 backup 数组中。
  3. 修改权限:用 mprotectmalloc 所在内存页设为可写。
  4. 写入跳转:构造一条跳转指令,目标是我们自己的 my_malloc 函数,并将其写入 malloc 开头,覆盖原始指令。
  5. 在钩子函数中“做手脚”
// 伪代码,演示核心逻辑
void* my_malloc(size_t size) {
    // 1. 记录内存分配信息(大小、调用栈等)
    log_memory_allocation(size, GET_CALLSTACK());

    // 2. 执行原函数的功能
    // 这里不能直接调用原 malloc,否则会无限递归跳回 my_malloc
    // 需要先“拆桥”,再“搭桥”
    // 方案:在一段新内存 (trampoline) 中执行备份的指令 + 跳回原函数剩余部分
    void* result = orig_malloc_trampoline(size); 

    // 3. 可选:重新 Hook (因为原函数入口可能被我们恢复后又修改了)
    // ...
    return result;
}

这个案例展示了 Inline Hook 的强大之处:它可以让我们在不修改任何应用源码,甚至无需重启进程的情况下,监控最底层的系统调用,为性能分析、安全检测、Bug 修复提供了无限可能。

4. 总结:利与弊

优势劣势与挑战
无所不钩:可以 Hook 任何函数,无论是动态库导出函数还是静态未导出函数,甚至函数中间的某条指令。实现极其复杂:需要处理指令备份、重定位、多架构、内存权限、线程安全等诸多底层难题。
底层 & 强大:直接修改机器码,能实现 Java Hook 和 PLT Hook 无法做到的事情,如监控内联函数。风险极高:一个微小的错误(如多写一个字节)就会导致程序立即崩溃,调试极其困难。
性能损耗可控:钩子函数执行完后,后续代码直接运行,无额外开销。但如果钩子函数本身写得低效,会严重影响性能。稳定性挑战:不同厂商的 ROM 可能对系统库打补丁,导致 Hook 点指令变化,兼容性难以保证。高频函数(如 clock_gettime)的 Hook 需要极致优化,否则极易引发 ANR,这是血与泪的教训。

5. 工程建议

对于绝大多数应用开发者来说,不建议在生产环境中直接手写 Inline Hook。除非你是做底层 APM 框架(如 Matrix)、安全加固、或者逆向工程,否则这条路的投入产出比太低。

如果确实有需求,可以考虑使用业已成熟的优秀开源框架,如: