找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3693

积分

0

好友

491

主题
发表于 3 小时前 | 查看: 4| 回复: 0

在之前的系列文章中,我们已经梳理了注入器和 Java Hook 的基本原理。接下来的问题是:如果目标方法不在 Java 层,而是 so 库里的 native 函数,Hook 又该怎么实现?

Native Hook 通常可以分成两条主要路线:直接修改机器码的 Inline Hook,以及利用动态链接过程中留存的导入表与重定位信息做文章的 PLT Hook。相比之下,PLT Hook 更适合作为构建 Native Hook 框架的第一步——它一开始不必触碰目标函数入口的原始指令,而是优先利用 ELF 文件格式和动态链接器已经准备好的基础信息。

本文目标

本文目标是将思路落地,实现一个可运行的 PLT Hook Demo。

项目地址:https://github.com/x1aon1ng/Nook

读完本文,我希望至少能把下面这些问题讲明白:

  • PLT Hook 到底 Hook 的是什么?
  • 一个导入函数在运行时如何通过 GOT/PLT 被调用?
  • 为什么 PLT Hook 在本质上属于“修改重定位结果”?
  • 为什么改掉某个槽位里的函数指针,就能劫持 native 调用?
  • 一次 hook_symbol() 调用在内部经历了哪些步骤?

预备知识

1. ELF 与 so

在 Android/Linux 系统中,native 动态库本质上就是 ELF 文件。当 libxxx.so 被加载进进程后,并非将文件原样复制到内存,而是由动态链接器依据 ELF 中的 program header、dynamic segment、relocation 等数据完成装载和重定位。

如果仅从 Hook 的视角出发,ELF 中最关键的几类信息是:

  • 动态符号表 .dynsym
  • 动态字符串表 .dynstr
  • 重定位表(如 .rel.plt.rela.plt.rel.dyn.rela.dyn
  • PT_LOADPT_DYNAMIC 这类 program header
  • Section Header Table (SHT):ELF 除了 Program Header Table,还有一套 Section Header Table,简称 SHT,对应着 ELF 的两种描述视角。这里暂且不做展开,你可以先简单理解前面提到的 .dynsym、.dynstr 都是 section,每个 section header 都描述了某个 section 的类型、偏移、大小等信息。

后续的 PLT Hook 操作,本质上就是围绕这些数据展开的。

2. 导入函数

导入函数,简单说就是:当前模块里“要调用,但实现不在自己这个模块里”的函数。
例如,在 libnative-lib.so 中写了:

strcmp(a, b);
malloc(16);

如果 strcmpmalloc 的具体实现都不在 libnative-lib.so 自身,而是在另一个 so(例如 libc.so)里,那么对 libnative-lib.so 而言,strcmpmalloc 就是导入函数。
与之对应的导出函数,我理解的概念是:如果一个函数定义在某个 so 里,且其符号对外可见、能被其他模块链接和调用,那它就是这个 so 的导出函数。

了解这个概念后,我们就可以回答上面的问题了:PLT Hook 实际上 Hook 的就是导入函数。

3. 动态链接、PLT 与 GOT

当一个 so 调用另一个 so 中的导入函数时,编译器通常不会把调用点直接写成最终的真实地址。原因很简单:编译时还不知道这个函数在目标进程里最终会被映射到哪里。

为了解决这个问题,需要两层十分重要的中间结构:

  • PLT (Procedure Linkage Table),可理解为导入函数调用的跳板。
  • GOT (Global Offset Table),可理解为运行时保存目标地址的槽位表。

一个粗略但够用的理解方式是:

调用点
->
PLT stub
->
GOT 槽位
->
真实函数地址

一旦动态链接器完成重定位,GOT 里的某个槽位就会被填上对应的导入函数的真实地址。此后,调用链就会顺着这个槽位跳转到真正的目标函数里。

所以说,所谓 PLT Hook,从运行时视角看,它并不是去修改 PLT 的机器码,而是去改变“PLT/GOT 这条调用链最终依赖的那个槽位里的函数指针”。
简而言之,PLT Hook 的本质就是修改重定位的结果

4. 重定位条目是什么

如果把 GOT 槽位视为最终要修改的目标,那么 relocation entry(重定位条目)就是告诉你“该改哪里”的索引。

一个重定位条目中最关键的通常是三个字段:

  • 符号索引——标明这条 relocation 对应哪个导入符号。
  • 重定位类型 (relocation type)——标明这条 relocation 属于哪一类修正。
  • 重定位偏移量 (relocation offset)——标明最终要修正的目标位置在哪儿。

PLT Hook 来说,最核心的问题可以拆解为:

  • 先找到目标符号对应的 relocation。
  • 再拿到它的 offset。
  • 然后把这个 offset 换算成进程里的真实地址。
  • 最后在那个地址上将原函数地址替换掉。

地址被替换后,调用流自然就会进入我们的 Hook 逻辑,这正是 PLT Hook 的核心所在。

5. 文件里的 offset 不等于内存里的地址

ELFIO 解析的是磁盘上的 ELF 文件,而真正的 Hook 动作发生在已经加载进进程内存的 so 映像上。文件里的 relocation offset 仅仅是“相对于 ELF 映像布局”的偏移量,并非可直接写入内存的真实地址。

因此,中间必须经过runtime bias(运行时基址偏移)的换算。最常见的一种写法是:

slot_address = runtime_bias + relocation_offset

runtime_bias 的求法,通常需要结合 PT_LOAD 段和运行时模块基址一起计算:

runtime_bias = runtime_module_base + p_offset - p_vaddr

6. 为什么要临时 mprotect

GOT/PLT 所在的内存页在运行时往往不天然具备写权限,很多时候只有读权限,甚至还会保留执行权限。要在上面修改指针,就得先把对应页临时设为可写:

  • 先查询当前页权限。
  • mprotect 改为可写。
  • 写入 replacement。
  • 恢复原来的页权限。

7. PLT Hook 和 Inline Hook 的区别

这两类 Hook 的最大区别不在于“Hook 的函数都是 native 函数”,而在于“改的是哪一层”。

PLT Hook 修改的是导入调用链路上的目标槽位,特点是:

  • 不直接修改目标函数机器码。
  • 更依赖 ELF 和重定位信息。
  • 只能影响经过导入槽位发起的调用。

Inline Hook 修改的是函数入口处的机器码,特点是:

  • 直接劫持目标函数的执行流。
  • 不依赖导入表。
  • 能覆盖的场景更广。
  • 实现难度和风险也更高。

从一个最小例子理解 PLT Hook

假设有一个目标模块 libnative-lib.so,它内部调用了 strcmp。编译和链接完成后,运行时这个调用大致会依赖某个 relocation 对应的 GOT 槽位。

一开始,槽位里存放的是原始的 strcmp 地址:

libnative-lib.so
->
strcmp 对应的 GOT 槽位
->
libc.so:strcmp

若我们将这个槽位改成自己的 hooked_strcmp

libnative-lib.so
->
strcmp 对应的 GOT 槽位
->
hooked_strcmp

那么,此后只要 libnative-lib.so 依然通过这条导入链路调用 strcmp,执行流就会优先进入 hooked_strcmp
而如果在改写前,我们先将槽位里原本保存的函数地址读到 original 变量里,那么在 hooked_strcmp 内部就能继续调用原始的 strcmp,从而实现调用链的“劫持并保留”。

当前项目中 PLT Hook 的整体结构

先来看一下当前项目里和 PLT Hook 相关的目录划分:

include/nook/
  NookPltHook.h
  NookNativeHook.h

src/framework/
  NookPltHook.cpp
  NookNativeHook.cpp

src/native_hook/core/
  module_info.cpp
  module_match.cpp
  native_hook_dispatcher.cpp
  runtime_patch.cpp

src/native_hook/plt_hook/
  plt_hook_impl.cpp
  elfio_image_parser.cpp
  elf_reader.cpp
  elf_hash.cpp

这几层各自负责的事情大致是:

  • include/nook/NookPltHook.h
    对外暴露 PLT Hook API。
  • src/framework/NookPltHook.cpp
    负责参数校验、初始化、策略装配。
  • src/framework/NookNativeHook.cpp
    当前只是把 Native Hook 门面转向 Plt Hook。
  • src/native_hook/core
    集中处理模块定位、路径匹配、通用调度、内存 patch。
  • src/native_hook/plt_hook
    负责 ELF 元数据解析。

一次 PLT Hook 调用链

先把整条调用链路串起来,再分别讲解细节。一次 hook_symbol() 大致会经历下面这些步骤(这只是针对当前项目的实现,一个简化的 PLT Hook 实际并不需要如此复杂):

  • 用户调用 NookNativeHookHookSymbol()
  • 它直接转发给 NookPltHookSymbol()
  • NookPltHookSymbol() 组装依赖并进入统一调度器。
  • 调度器通过 /proc/self/maps 找到目标模块的运行时基址和磁盘路径。
  • 尝试用 ELFIO 解析主路径。
  • 最终定位到某个 relocation 对应的 slot 地址。
  • 通过统一的 runtime patch 逻辑改写该地址里的函数指针。
  • 同时将原始函数地址保存到 original

也就是说,对外看起来只是一个调用:

api.hook_symbol("libnative-lib.so",
                "strcmp",
                reinterpret_cast<void*>(hooked_strcmp),
                &original);

但内部实际上完成了“模块定位 -> 文件解析 -> 重定位筛选 -> 地址换算 -> 内存页修改 -> 指针改写”这一整套动作,即:

  NookPltHookSymbol
-> HookSymbolWithFallback
-> get_module_info
-> TryPltHookWithElfio
-> LoadFromFile
-> ComputeRuntimeBias
-> CollectRelocationsForSymbol
-> PatchPointerAtAddress
-> 失败时 TryPltHookWithElfReader

对外接口层:NookPltHook 做了什么

先看公开头文件:

NookStatus NookPltHookInitialize(void);
NookStatus NookPltHookIsAvailable(int* available);
NookStatus NookPltHookSymbol(const char* module_name,
                             const char* symbol_name,
                             void* replacement,
                             void** original);

对外 API 非常薄,真正的核心都在 NookPltHookSymbol() 里。

它主要做三件事:

  • 参数校验。
  • 懒初始化。
  • 组装 primary/fallback 依赖。

对应代码:

NookStatus NookPltHookSymbol(const char* module_name,
                             const char* symbol_name,
                             void* replacement,
                             void** original) {
    if (module_name == nullptr || module_name[0] == '\0' ||
        symbol_name == nullptr || symbol_name[0] == '\0' ||
        replacement == nullptr || original == nullptr) {
        return NOOK_STATUS_INVALID_ARGUMENT;
    }

    *original = nullptr;
    if (!g_plt_hook_initialized) {
        const NookStatus status = NookPltHookInitialize();
        if (status != NOOK_STATUS_OK) {
            return status;
        }
    }

#if defined(__ANDROID__) || defined(__linux__)
    const NookNativeInternal::FallbackHookDependencies dependencies = {
            &ResolveModuleInfo,
            &NookNativeInternal::TryPltHookWithElfio,
            &NookNativeInternal::TryPltHookWithElfReader,
            nullptr};

    return NookNativeInternal::HookSymbolWithFallback(
            module_name, symbol_name, replacement, original, dependencies);
#else
    return NOOK_STATUS_NOT_IMPLEMENTED;
#endif
}

可以看到,这一层本身完全不碰 ELF 头、不碰 relocation,也不碰 mprotect。它只负责把此次 Hook 所需的策略拼装起来,然后将执行权交给内部调度器。

模块定位:如何从 /proc/self/maps 找到目标 so

在真正解析 ELF 之前,先要回答一个问题:目标模块在进程里到底被加载到了哪里?这个问题在之前关于注入器的文章中也多次提到,这里我们再快速过一遍。
当前的做法很传统,也很直接,就是扫描 /proc/self/maps

get_module_info() 的核心逻辑可以概括为:

  • 打开 /proc/self/maps
  • 逐行读取映射记录。
  • 从每一行中解析出起始地址、权限、路径。
  • 使用 module_path_matches() 判断这一行是否为目标模块。
  • 命中后返回 map_start 作为运行时模块基址,同时把路径保存下来。

代码逻辑大致如下:

while (std::fgets(buffer, sizeof(buffer), maps_file)) {
    if (std::sscanf(buffer,
                   "%lx-%lx %4s %*x %*x:%*x %*d %127s",
                   &map_start,
                   &map_end,
                   perms,
                   so_name) != 4) {
        continue;
    }

    if (!module_path_matches(so_name, module)) {
        continue;
    }

    *module_base = reinterpret_cast<void*>(map_start);
    *module_path = so_name;
    return true;
}

主路径一:ELFIO 负责解决什么问题

走到这一步,我们已经拿到了两份非常关键的信息:

  • 运行时视角下的 module_base
  • 文件视角下的 module_path

接下来,ELFIO 路径要解决的问题就比较纯粹了:只从磁盘上的 ELF 文件里,把“这个符号对应哪些 relocation”找出来。

ElfioImageParser 负责的事情大致可以拆成三件:

  • .dynsym 找到目标符号的动态符号索引。
  • 遍历所有 SHT_REL/SHT_RELA section,找出引用该符号的 relocation。
  • 从首个 PT_LOAD 段计算 runtime bias。

1. 查找动态符号索引

ElfioImageParser 首先会拿到 .dynsym,然后逐项遍历:

ELFIO::section* dynsym = elf_file_.sections[".dynsym"];
ELFIO::symbol_section_accessor symbols(elf_file_, dynsym);

for (ELFIO::Elf_Xword index = 0; index < symbols.get_symbols_num(); ++index) {
    if (!symbols.get_symbol(index,
                            current_name,
                            value,
                            size,
                            bind,
                            type,
                            section_index,
                            other)) {
        continue;
    }
    if (current_name == symbol_name) {
        *symbol_index = static_cast<uint32_t>(index);
        return true;
    }
}

一旦拿到 symbol_index,后续的 relocation 过滤就有了依据。

2. 收集该符号对应的 relocation

CollectRelocationsForSymbol() 并不会只盯着 .plt 相关的段,而是会遍历所有 SHT_REL/SHT_RELA section:

for (const auto& section : elf_file_.sections) {
    const ELFIO::section* current_section = section.get();
    const ELFIO::Elf_Word section_type = current_section->get_type();
    if (section_type != ELFIO::SHT_REL && section_type != ELFIO::SHT_RELA) {
        continue;
    }

    ELFIO::relocation_section_accessor reloc_accessor(elf_file_,
                    const_cast<ELFIO::section*>(current_section));
    ...
}

然后只保留 relocation_symbol == symbol_index 的那些条目,并把 offset、type、section_name 等信息记录下来。

这一点很重要:虽然我们习惯把这类方案叫 PLT Hook,但 Nook 当前的主路径实现并不只看 .plt,而是把引用该符号的 relocation 全部纳入候选。这使得它既能覆盖典型的 JUMP_SLOT 场景,也能覆盖部分 .dyn 里的全局数据/函数槽位场景。

3. 计算 runtime bias

有了 relocation offset 还不够,因为这依然只是文件视角下的偏移。

ComputeRuntimeBias() 会遍历 program header,找到首个 PT_LOAD 段:

for (const auto& segment : elf_file_.segments) {
    if (!segment || segment->get_type() != ELFIO::PT_LOAD) {
        continue;
    }

    *runtime_bias = runtime_module_base +
                    static_cast<uintptr_t>(segment->get_offset()) -
                    static_cast<uintptr_t>(segment->get_virtual_address());
    return true;
}

这个式子的含义其实就是:把“文件内偏移体系”平移到“当前进程的运行时映像体系”里去。

主路径二:如何把 relocation offset 变成真实可 patch 地址

ElfioImageParser 把数据都准备好之后,TryPltHookWithElfio() 便只剩最后几步:

  • 加载 ELF 文件。
  • 计算 runtime bias。
  • 收集目标符号的 relocation 列表。
  • 依次尝试 patch 每一个候选 relocation 对应的 slot 地址。

核心逻辑:

std::vector<ElfHooker::ParsedRelocation> relocations;
if (!parser.CollectRelocationsForSymbol(target.symbol_name, &relocations)) {
    return false;
}

for (const auto& relocation : relocations) {
    void* slot_address = reinterpret_cast<void*>(runtime_bias + relocation.offset);
    if (ElfHooker::PatchPointerAtAddress(slot_address, target.replacement, target.original)) {
        return true;
    }
}

可以看到,这里并没有再去关心这个 relocation 是来自 .rel.plt 还是 .rela.dyn,也没有继续纠缠 ELF 头结构。它只做了一件事:把“文件中的 relocation offset”换算成“进程中的 slot 地址”,然后交给统一的 patch 层去修改。

从分层设计上看,这一点是当前实现里最清晰、也最舒服的地方:

  • ELFIO 只负责元数据提取。
  • runtime patch 只负责内存改写。
  • 两者之间通过 relocation.offsetruntime_bias 无缝对接。

运行时 patch:真正改写 GOT/PLT 槽位时发生了什么

如果说上面几节解决的是“该改哪里”,那么这一节解决的就是“怎么安全地改”。

runtime_patch.cpp 中主要包含三块逻辑:

  • relocation 匹配辅助。
  • 跨页 patch 范围计算。
  • 真正的指针改写。

1. 先保存原指针,再写新指针

这一步最核心的逻辑其实就两句:

*original = *slot;
*slot = replacement;

这两句的语义很关键。original 保存的不是“从符号表重新解析出来的函数地址”,而是“这个 GOT/PLT 槽位在被改写之前,原本指向的那个真实目标地址”。这正是后续 Hook 函数继续调用原函数时最需要的值。

2. 为什么要计算跨页范围

如果 patch 地址恰好落在页尾,sizeof(void*) 的写入完全可能跨越两页。为了避免只修改了一半或 mprotect 范围不够,ComputePatchPageRange() 会先算出完整范围:

const uintptr_t start = target_address & page_mask;
const uintptr_t end = (target_address + write_size - 1u) & page_mask;

range.start = start;
range.length = (end - start) + page_size;

3. 真正的 PatchPointerAtAddress

PatchPointerAtAddress() 的完整思路是:

  • 查询 slot 当前所在页的原始权限。
  • 生成一个“去掉执行、补上写权限”的临时权限。
  • mprotect 使该范围可写。
  • 先保存原指针,再写入 replacement。
  • 清理 cache。
  • 恢复原始页权限。

对应代码大致如下:

int original_protection = 0;
if (!get_address_protection(slot_address, &original_protection)) {
    return false;
}

int writable_protection = original_protection & ~PROT_EXEC;
writable_protection |= PROT_WRITE;

if (mprotect(page_start, patch_range.length, writable_protection) != 0) {
    return false;
}

const bool wrote_pointer =
    CaptureAndWritePointer(reinterpret_cast<void**>(slot_address), replacement, original);
clear_cache(page_start, patch_range.length);
const int restore_result = mprotect(page_start, patch_range.length, original_protection);
return wrote_pointer && restore_result == 0;

这部分逻辑正是整套 PLT Hook 生效的关键:把对应地址里的指针改掉,从而跳转到我们自己的逻辑中。

ElfReader 是怎么工作的

前面讲的都是当前 Nook 里优先走的 ELFIO 主路径,当前项目还保留了一条手写的 ElfReader 路径作为 fallback。这样做有几个好处:

  • 主路径失败时还有兜底。
  • 新旧实现可以在同一个公开 API 下共存。
  • 重构过程中可以降低一次性切换的风险。

这条 fallback 路径和 ELFIO 最大的区别在于:它不是“先抽取元数据,再交给外层 patch”,而是自己把解析、查找和改写串成了一整条链。

其实底部实现是类似的,两条路径的不同在于:

  • ELFIO 路径更像“文件解析层 + 公共 runtime patch 层”。
  • ElfReader 路径更像“项目内置的一体化兼容实现”。

代码参考了:https://github.com/MelonWXD/ELFHooker

1. 它解析的不是磁盘文件,而是内存中的已加载映像

ELFIO 主路径拿到的是 module_path,然后去读磁盘上的 ELF 文件;而 ElfReader 构造时直接拿到的是模块运行时基址:

ElfReader reader(target.module_name, target.module_base);
if (reader.parse() != 0) {
    return false;
}
return reader.hook(target.symbol_name, target.replacement, target.original) == 0;

这意味着它面对的是“当前进程里已经映射好的 ELF 映像”,所以很多字段都不再是文件偏移意义上的概念,而是直接围绕运行时内存布局来做解释。

2. 先校验 ELF 头,再找 program header 和 dynamic segment

parse() 的入口逻辑大致是:

  • start 当作 ELF header 起点。
  • 校验 magic、位数、endianness、e_machine
  • 解析 program header。
  • 找到首个 PT_LOAD 段算出 bias
  • 再进入 parseDynamicSegment()

这里的 bias 和前面 ELFIO 路径里的 runtime_bias 本质上解决的是同一类问题,只不过做法更贴近“当前这块内存如何解释成一个已装载 ELF”。

对应代码主干大致如下:

this->ehdr = reinterpret_cast<ElfW(Ehdr) *>(this->start);
if (0 != verifyElfHeader()) {
    return -1;
}

this->phdrNum = ehdr->e_phnum;
this->phdr = reinterpret_cast<ElfW(Phdr) *>(this->start + ehdr->e_phoff);
this->bias = getSegmentBaseAddress();
if (0 == this->bias) {
    return -1;
}
if (0 != parseDynamicSegment()) {
    return -1;
}

3. 它自己解析 dynamic segment 里的关键表

parseDynamicSegment() 做的事情,就是把后续 Hook 需要的一批关键数据结构先准备出来,包括:

  • DT_STRTAB 对应的字符串表。
  • DT_SYMTAB 对应的符号表。
  • DT_REL/DT_RELA
  • DT_JMPREL
  • DT_HASH
  • DT_GNU_HASH

也就是说,在 ElfReader 这条路径里,符号查找、relocation 扫描这些动作并不依赖外部库,而是完全靠自己把 dynamic segment 中的元数据拆出来。

4. 符号查找:自己实现了 ELF hash 和 GNU hash

这一点是 ElfReaderELFIO 路径差异很大的地方。
ELFIO 路径里,当前项目的做法是直接遍历 .dynsym 去找目标符号;但在 ElfReader 里,项目自己实现了两套更传统的符号查找方式:

  • 如果模块有 DT_GNU_HASH,就优先走 GNU hash。
  • 否则就回退到 ELF hash。

这也是为什么 plt_hook 目录下还保留着 elf_hash.cpp 和对应头文件。

5. relocation 扫描:先扫 pltRel,再扫 rel

ElfReader::hook() 的主逻辑:

  • 先通过符号查找拿到目标符号索引 symidx
  • 先扫描 pltRel
  • 如果没命中,再扫描普通 rel
  • 一旦命中,就用 bias + matched_offset 算出最终 slot 地址。
  • 然后执行 patch。

对应代码结构大致是:

if (0 == findSymbolByName(func_name, &sym, &symidx)) {
    rel = this->pltRel;
    for (uint32_t i = 0; i < this->pltRelCount; i++) {
        ...
        if (ElfHooker::FindFirstMatchingRelocationOffset(...)) {
            addr = reinterpret_cast<void *>(this->bias + matched_offset);
            if (0 == hookInternally(addr, new_func, old_func)) {
                return 0;
            }
            break;
        }
    }

    rel = this->rel;
    for (uint32_t i = 0; i < this->relCount; i++) {
        ...
    }
}

这里也能看出它和 ELFIO 路径的风格差异:

  • ELFIO 路径是先把 relocation 全部收集出来,再统一尝试 patch。
  • ElfReader 路径是边扫描、边判断、边计算地址,命中后直接进入改写。

6. hookInternally:自己的 patch 逻辑

ElfReader 不只是负责解析和查找,它内部还有一套自己的 patch 流程,也就是 hookInternally()

它的整体思路和前面公共的 runtime patch 很像:

  • 先判断目标地址所在 segment。
  • 根据 segment flag 推导原始内存权限。
  • 计算跨页范围。
  • mprotect 使目标页可写。
  • 保存原指针并写入 replacement。
  • 清理 cache。
  • 恢复原始权限。

从这里也能看出为什么前面说它是“一体化兼容实现”:在这条路径里,解析 ELF、筛选 relocation、计算地址、改写内存,并没有被拆成多个相对独立的内部层,而是更多集中在 ElfReader 这一个类附近完成。

7. 为什么现在还要保留它

写到这里,其实就很容易回答一个问题:既然已经有了 ELFIO 主路径,为什么不把 ElfReader 删掉?

我认为至少有下面几个原因:

  • 它仍然是一个稳定可用的 fallback。
  • 它可以作为新路径行为的参照。
  • 在某些解析失败场景下,它可能仍然能工作。
  • 重构阶段保留旧路径,比一次性切干净更稳妥。

所以从当前 Nook 的实现定位看,ElfReader 更像是一条兼容和兜底路径,而不是未来主要继续扩展复杂度的方向。

一个完整示例:以 strcmp Hook 为例

项目里已经有一个比较直接的例子:examples/native_hook/nook_native_strcmp_test/payload.cpp

这份 payload 的逻辑不复杂,但很适合把前面的原理串起来:

  • 先通过运行时 loader 解析出 NookNativeApi
  • 调用 initialize()
  • 重试调用 hook_symbol("libnative-lib.so", "strcmp", hooked_strcmp, &original)
  • 成功后把返回的原始函数地址保存到全局变量里。

核心代码大致是:

const NookStatus hook_status = api.hook_symbol(kTargetModule,
                                               kTargetSymbol,
                                               reinterpret_cast<void*>(hooked_strcmp),
                                               &original);
if (hook_status == NOOK_STATUS_OK) {
    g_original_strcmp = reinterpret_cast<int (*)(const char*, const char*)>(original);
    g_hook_installed.store(true);
}

而真正的 Hook 函数只是:

int hooked_strcmp(const char* a, const char* b) {
    __android_log_print(ANDROID_LOG_INFO,
                        kTag,
                        "hooked strcmp: a=%s b=%s",
                        a ? a : "<null>",
                        b ? b : "<null>");
    return NookTestAlwaysEqualStrcmp(a, b);
}

表面上看,这只是一次普通的函数替换;但放回 Nook 内部实现链路中,它实际已经隐含触发了:

  • libnative-lib.so 的运行时定位。
  • strcmp 的动态符号索引查找。
  • relocation 枚举和筛选。
  • runtime bias 计算。
  • GOT/PLT 槽位改写。
  • 原函数地址保存。

这也是为什么我觉得 PLT Hook 很适合作为 Native Hook 框架的第一步:对外接口很简洁,但内部已经把一条完整的 Hook 基础设施链路跑通了。

这套实现的边界与局限

虽然当前 Nook 里的 PLT Hook 已经够用,但它也有非常明确的边界。

1. 它只能 Hook 经过导入表的调用

如果目标调用根本没有经过导入槽位,而是:

  • 模块内部直接调用。
  • 静态函数。
  • 被编译器直接内联。
  • 调用点已被其他优化改写。

那么 PLT Hook 就无能为力了。因为它的切入点从来都不是“目标函数入口”,而是“导入链路上的重定位结果”。

2. 模块名匹配比较宽松

当前 module_path_matches() 支持子串匹配,这使得使用体验更宽容,但也意味着如果进程里存在名字很像的 so,理论上会有误命中的风险。

3. 文件视角和运行时视角必须严格对齐

ELFIO 路径读的是磁盘文件,patch 的是进程内存。如果 runtime bias 算错,最后 patch 的就不会是目标槽位,而是一个错误地址。这也是整个实现里最不允许出错的换算步骤之一。

总结

到这里,其实可以把 Nook 当前的 PLT Hook 核心思路压缩成一句话:Nook 的 PLT Hook,本质上就是“先利用 ELF 元数据定位目标符号对应的重定位槽位,再把该槽位在运行时映像中的真实地址安全改写成 replacement,同时保留原始目标地址供后续继续调用”。

如果再拆分得细一点,这套实现可以被理解成四层:

  • 公开 API 层
    负责暴露 hook_symbol() 能力。
  • 调度与模块定位层
    负责找到目标模块的运行时基址和磁盘路径。
  • ELF 元数据解析层
    负责找到目标符号对应的 relocation。
  • 运行时 patch 层
    负责真正改写 GOT/PLT 槽位。

我认为 Nook 当前这部分实现最值得记录的,并不是“PLT Hook 这个概念本身有多新”,而是它把原本容易耦合在一起的几件事尽量拆开了:

  • 文件解析归文件解析。
  • 运行时写内存归运行时写内存。
  • 对外 API 归对外 API。
  • 新实现和旧实现可以在一个稳定入口下并存。

这让整套 Native Hook 基础设施在演进时更容易控制风险,也更容易继续往 Inline Hook 那一侧扩展。

下一篇继续写 Native Hook,我会尝试顺着这个方向把 Inline Hook 接上:同样是 Hook native 函数,为什么到了 Inline Hook 这里,问题会从“找 relocation 和改槽位”变成“改机器码、搬运指令和构造 trampoline”。

云栈社区,我们也会持续探讨更多关于底层安全与逆向工程的深度技术话题。

一个人在蓝天白云中向上跃起,身体呈倒立姿态,充满动感与力量感,突破极限的视觉效果

本文由 n_1ng 原创,首发于看雪论坛。




上一篇:iPhone 18 Pro 三大升级爆料:可变光圈相机、2nm芯片续航与全新深红配色
下一篇:三星员工自曝崩溃:3万员工改名罢工,90%想跳槽SK海力士
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-5-17 07:21 , Processed in 1.782915 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表