在之前的系列文章中,我们已经梳理了注入器和 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_LOAD 与 PT_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);
如果 strcmp 和 malloc 的具体实现都不在 libnative-lib.so 自身,而是在另一个 so(例如 libc.so)里,那么对 libnative-lib.so 而言,strcmp、malloc 就是导入函数。
与之对应的导出函数,我理解的概念是:如果一个函数定义在某个 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.offset 和 runtime_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 映像”,所以很多字段都不再是文件偏移意义上的概念,而是直接围绕运行时内存布局来做解释。
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
这一点是 ElfReader 和 ELFIO 路径差异很大的地方。
在 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 原创,首发于看雪论坛。