从0到1构建一个Hook工具之PLTHook篇

admin 2026-05-18 06:09:10 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细讲解了从零构建Hook工具中的PLTHook技术,阐述了PLTHook通过修改ELF文件重定位表中的GOT槽位函数指针来劫持native函数调用的核心原理。文章剖析了动态链接过程中PLT/GOT机制的工作流程,提供了完整的实现步骤包括模块定位、ELF解析、重定位筛选和内存页权限修改,并对比了PLTHook与InlineHook的差异。最后通过实际项目代码展示了hook_symbol()函数的完整调用链路和实现细节。 综合评分: 88 文章分类: 二进制安全,逆向分析,安全工具,安全开发


cover_image

从0到1构建一个Hook工具之PLT Hook篇

n_1ng n_1ng

看雪学苑

2026年5月16日 17:59 上海

在小说阅读器读本章

去阅读

在前面的几篇文章里,我们已经把注入器和 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

  • 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),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &original);

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

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

#

样式对外接口层:NookPltHook 做了什么

先看公开头文件:

NookStatus&nbsp;NookPltHookInitialize(void);
NookStatus&nbsp;NookPltHookIsAvailable(int* available);
NookStatus&nbsp;NookPltHookSymbol(constchar* module_name,
constchar* symbol_name,
void* replacement,
void** original);

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

它做的事情主要有三类:

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

对应代码:

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

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

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

return&nbsp;NookNativeInternal::HookSymbolWithFallback(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; module_name, symbol_name, replacement, original, dependencies);
#else
return&nbsp;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&nbsp;(std::fgets(buffer,&nbsp;sizeof(buffer), maps_file)) {
if&nbsp;(std::sscanf(buffer,
"%lx-%lx %4s %*x %*x:%*x %*d %127s",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &map_start,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &map_end,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; perms,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; so_name) !=&nbsp;4) {
continue;
&nbsp; &nbsp; }

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

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

#

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

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

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

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

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

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

1. 查找动态符号索引

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

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

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

一旦拿到symbol_index,后面的 relocation 过滤就有了抓手。

2. 收集该符号对应的 relocation

CollectRelocationsForSymbol()不会只盯.plt相关段,而是会遍历所有SHT_REL/SHT_RELAsection:

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

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

然后只保留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&nbsp;(const&nbsp;auto& segment : elf_file_.segments) {
if&nbsp;(!segment || segment->get_type() != ELFIO::PT_LOAD) {
continue;
&nbsp; &nbsp; }

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

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

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

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

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

核心逻辑:

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

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

可以看到,这里并没有再去关心这个 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&nbsp;start&nbsp;=&nbsp;target_address&nbsp;&&nbsp;page_mask;
const uintptr_t&nbsp;end&nbsp;=&nbsp;(target_address&nbsp;+&nbsp;write_size&nbsp;-1u)&nbsp;&&nbsp;page_mask;

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

3. 真正的 PatchPointerAtAddress

PatchPointerAtAddress()的完整思路是:

  • 查询 slot 当前所在页的原始权限

  • 生成一个“去掉执行、补上写权限”的临时权限

  • mprotect

    使该范围可写

  • 先保存原指针,再写入 replacement

  • 清理 cache

  • 恢复原始页权限

对应代码大致如下:

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

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

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

constbool&nbsp;wrote_pointer =
CaptureAndWritePointer(reinterpret_cast<void**>(slot_address), replacement, original);
clear_cache(page_start, patch_range.length);
constint&nbsp;restore_result =&nbsp;mprotect(page_start, patch_range.length, original_protection);
return&nbsp;wrote_pointer && restore_result ==&nbsp;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&nbsp;(reader.parse() !=&nbsp;0) {
return&nbsp;false;
}
return&nbsp;reader.hook(target.symbol_name, target.replacement, target.original) ==&nbsp;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&nbsp;(0&nbsp;!= verifyElfHeader()) {
return&nbsp;-1;
}

this->phdrNum = ehdr->e_phnum;
this->phdr = reinterpret_cast<ElfW(Phdr) *>(this->start + ehdr->e_phoff);
this->bias = getSegmentBaseAddress();
if&nbsp;(0&nbsp;==&nbsp;this->bias) {
return&nbsp;-1;
}
if&nbsp;(0&nbsp;!= parseDynamicSegment()) {
return&nbsp;-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&nbsp;(0&nbsp;== findSymbolByName(func_name, &sym, &symidx)) {
&nbsp; &nbsp; rel =&nbsp;this->pltRel;
for&nbsp;(uint32_t i =&nbsp;0; i <&nbsp;this->pltRelCount; i++) {
&nbsp; &nbsp; &nbsp; &nbsp; ...
if&nbsp;(ElfHooker::FindFirstMatchingRelocationOffset(...)) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; addr = reinterpret_cast<void *>(this->bias + matched_offset);
if&nbsp;(0&nbsp;== hookInternally(addr, new_func, old_func)) {
return&nbsp;0;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
break;
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }

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

这里也能看出它和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&nbsp;NookStatus hook_status = api.hook_symbol(kTargetModule,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;kTargetSymbol,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;reinterpret_cast<void*>(hooked_strcmp),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&original);
if&nbsp;(hook_status == NOOK_STATUS_OK) {
&nbsp; &nbsp; g_original_strcmp = reinterpret_cast<int&nbsp;(*)(const&nbsp;char*,&nbsp;const&nbsp;char*)>(original);
&nbsp; &nbsp; g_hook_installed.store(true);
}

而真正的 Hook 函数只是:

inthooked_strcmp(constchar* a,&nbsp;constchar* b)&nbsp;{
&nbsp; &nbsp; __android_log_print(ANDROID_LOG_INFO,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; kTag,
"hooked strcmp: a=%s b=%s",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; a ? a :&nbsp;"<null>",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; b ? b :&nbsp;"<null>");
return&nbsp;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”。

#

看雪ID:n_1ng

https://bbs.kanxue.com/user-home-985628.htm

*本文为看雪论坛优秀文章,由 n_1ng 原创,转载请注明来自看雪社区

第十届安全开发者峰会【议题征集】-欢迎投稿

往期推荐

安卓逆向基础知识之frida Hook

2025 强网杯和强网拟态部分题解

在逆向分析方面-unidbg真的适合 MCP 吗?

AI静态分析,内核模块隐藏 Frida 特征,绕过linker私有结构遍历崩溃链

某安全so库深度解析

球分享

球点赞

球在看

点击阅读原文查看更多


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:看雪学苑 n1ng n1ng《从0到1构建一个Hook工具之PLT Hook篇》

评论:0   参与:  0