-
-
[原创]从0到1构建一个Hook工具之PLT Hook篇(四)
-
发表于: 4小时前 93
-
在前面的几篇文章里,我们已经把注入器和 Java Hook 这两部分大致梳理了一遍。继续往下走,一个比较自然的问题就是:如果目标不再是 Java 方法,而是 so 里的 native 函数,那 Hook 又该怎么做?
Native Hook 这件事如果再往下拆,其实又可以分成几条路。最常见的两类,一类是直接改机器码的 Inline Hook,另一类是利用动态链接过程留下来的导入表/重定位信息做 PLT Hook。相较之下,PLT Hook 更适合作为一个 Native Hook 框架的第一步:它不用上来就硬改目标函数入口,而是优先利用 ELF 和动态链接器已经准备好的信息。
这篇文章我们先把目标定在实现一个可用的plt hook demo。项目地址:2d3K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6^5x3h3q4G2L8U0q4F1k6#2)9J5c8V1&6G2L8$3D9`.
读完之后,我希望至少能把下面这些问题讲明白:
在 Android/Linux 里,native 动态库本质上就是 ELF 文件。libxxx.so 被加载进进程后,并不是简单把文件原样搬到内存里,而是由动态链接器按照 ELF 中的 program header、dynamic segment、relocation 信息等内容完成装载和重定位。
如果只从 Hook 的角度去看,ELF 里最重要的几类信息是:
后面 的 PLT Hook,本质上就是围绕这些信息展开。
导入函数,简单说就是:
当前模块里“要调用,但实现不在自己这个模块里”的函数。 比如 libnative-lib.so 里写了:
如果 strcmp 和 malloc 的实现都不在 libnative-lib.so 自己内部,而是在别的 so 里,比如 libc.so,那对 libnative-lib.so 来说,strcmp、malloc 就是导入函数。
对应的另一边就是导出函数,我理解的概念大概是:如果一个函数定义在某个 so 里,并且它的符号对外可见、能被别的模块链接和调用,那它就是这个 so 的导出函数。
了解这个概念后,我们就可以回答上面的问题:PLT Hook就是在Hook导入函数。
当一个 so 调用另一个 so 里的导入函数时,编译器通常不会把调用点直接写成最终的真实地址。原因很简单:编译时还不知道这个函数在目标进程里最终会被映射到哪里。
于是就有了两层非常重要的中间结构:
一个很粗略但够用的理解是:
一旦动态链接器完成重定位,GOT 里的某个槽位就会被写成对应导入函数的真实地址。此后,调用链就会顺着这个槽位跳到真正的目标函数里。
所以,所谓 PLT Hook,从运行时视角看,本质上并不是去改 PLT 机器码,而是去改“PLT/GOT 这条调用链最终依赖的那个槽位里的函数指针”。
所以PLT Hook的本质就是在修改重定位的结果。
如果说 GOT 槽位是最终要改的目标,那么 relocation entry 就是“告诉你该改哪里”的索引。
一个重定位条目里,最关键的通常是三个字段:
对 PLT Hook 来说,最核心的问题其实就是:
当地址被替换掉后,自然的就走到了我们的Hook逻辑的,这就是PLT Hook的核心。
ELFIO 解析的是磁盘上的 ELF 文件,而真正的 Hook 动作发生在已经加载到进程内存中的 so 映像上。文件里的 relocation offset 只是“相对于 ELF 映像布局”的偏移,不是可以直接拿来写内存的真实地址。
所以中间必须经过一步 runtime bias 换算。最常见的一种写法是:
而 runtime_bias 的求法,通常要结合 PT_LOAD 段和运行时模块基址一起算出来:
GOT/PLT 对应的内存页在运行时往往不是天然可写的,很多时候只有读权限,甚至还会带执行权限。想要在上面改指针,就得先把对应页临时改成可写:
这两类 Hook 最大的区别不在“Hook 的函数都是 native 函数”,而在“改的是哪一层”。
PLT Hook 改的是导入调用链路上的目标槽位,特点是:
Inline Hook 改的是函数入口处的机器码,特点是:
假设有一个目标模块 libnative-lib.so,它内部调用了 strcmp。编译和链接完成后,运行时这个调用大致会依赖某个 relocation 对应的 GOT 槽位。
一开始,槽位里装的是原始的 strcmp 地址:
如果我们把这个槽位改成自己的 hooked_strcmp:
那么后续只要 libnative-lib.so 仍然通过这条导入链路调用 strcmp,执行流就会先进到 hooked_strcmp。
而如果在改写前,我们先把槽位里原本保存的函数地址读出来存到 original,后续在 hooked_strcmp 里就还可以继续调用原始 strcmp。
先看一下当前项目里和 PLT Hook 相关的目录划分:
这几层各自负责的事情大概是:
先把整条调用链串起来,再分别讲细节。一次 hook_symbol() 大致会经历下面这些步骤,这只是针对当前项目,一个简单的PLT Hook实际并不需要这么复杂:
也就是说,对外看起来只是一个:
但内部实际上完成了“模块定位 -> 文件解析 -> 重定位筛选 -> 地址换算 -> 内存页修改 -> 指针改写”这一整套动作,即:
先看公开头文件:
对外 API 非常薄,真正的核心在 NookPltHookSymbol() 里。
它做的事情主要有三类:
对应代码:
可以看到,这一层本身完全不碰 ELF 头、不碰 relocation,也不碰 mprotect。它只负责把这次 Hook 需要的策略拼起来,然后把执行权交给内部调度器。
在真正解析 ELF 之前,首先得回答一个问题:目标模块当前在进程里到底被加载到了哪里?这个问题其实在之前的文章中也多次提到,这里再简单讲一下。
当前的做法很传统,也很直接,就是扫描 /proc/self/maps。
get_module_info() 的核心逻辑可以概括成:
代码逻辑大致如下:
到了这一步,我们已经拿到了两份非常关键的信息:
接下来 ELFIO 路径要解决的问题就比较纯粹了:只从磁盘上的 ELF 文件里,把“这个符号对应哪些 relocation”找出来。
ElfioImageParser 负责的事情大致可以拆成三件:
它会先拿到 .dynsym,然后逐项遍历:
一旦拿到 symbol_index,后面的 relocation 过滤就有了抓手。
CollectRelocationsForSymbol() 不会只盯 .plt 相关段,而是会遍历所有 SHT_REL/SHT_RELA section:
然后只保留 relocation_symbol == symbol_index 的那些条目,并把 offset、type、section_name 等信息记录下来。
这一点很重要:虽然我们习惯把这类方案叫 PLT Hook,但 Nook 当前的主路径实现并不只看 .plt,而是把引用该符号的 relocation 全部纳入候选。这使它既能覆盖典型 JUMP_SLOT 场景,也能覆盖部分 .dyn 里的全局数据/函数槽位场景。
有了 relocation offset 还不够,因为这依然只是文件视角下的偏移。ComputeRuntimeBias() 会遍历 program header,找到首个 PT_LOAD 段:
这个式子的含义其实就是:把“文件内偏移体系”平移到“当前进程的运行时映像体系”里去。
当 ElfioImageParser 把数据都准备好之后,TryPltHookWithElfio() 就只剩下最后几步:
核心逻辑:
可以看到,这里并没有再去关心这个 relocation 来自 .rel.plt 还是 .rela.dyn,也没有继续纠缠 ELF 头结构。它只做了一件事:把“文件中的 relocation offset”换算成“进程中的 slot 地址”,然后交给统一 patch 层去改。
从分层上看,这一点是 当前实现里最清晰也最舒服的地方:
如果说前面几节解决的是“该改哪里”,那么这一节解决的就是“怎么安全地改”。
runtime_patch.cpp 里主要有三块逻辑:
这一步最核心的逻辑其实就两句:
但这两句的语义很关键。original 保存的不是“从符号表重新解析出来的函数地址”,而是“这个 GOT/PLT 槽位在被改写之前,原本指向的那个真实目标地址”。这正是后续 Hook 函数继续调用原函数时最需要的值。
如果 patch 地址恰好落在页尾,sizeof(void*) 的写入完全可能跨越两页。为了避免只改了一半或 mprotect 范围不够,ComputePatchPageRange() 会先算好完整范围:
PatchPointerAtAddress() 的完整思路是:
对应代码大致如下:
这部分逻辑其实就是整套 PLT Hook 的生效的关键:把对应的地址里的指针改掉,跳到我们自己的逻辑。
前面讲的都是当前 Nook 里优先走的 ELFIO 主路径, 当前的做法是保留一条手写 ElfReader路径作为 fallback,这样一来:
这条 fallback 路径和 ELFIO 最大的区别在于:它不是“先抽取元数据,再交给外层 patch”,而是自己把解析、查找和改写串成了一整条链。
其实实现是类似的,两条路径不同在于:
代码参考了:1bfK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6y4k6h3I4G2L8W2N6j5c8q4)9J5c8V1g2x3c8V1S2G2L8$3E0W2M7R3`.`.
ELFIO 主路径拿到的是 module_path,然后去读磁盘上的 ELF 文件;而 ElfReader 构造时直接拿到的是模块运行时基址:
这意味着它面对的是“当前进程里已经映射好的 ELF 映像”,所以很多字段都不再是文件偏移意义上的概念,而是直接围绕运行时内存布局来做解释。
parse() 的入口逻辑大致是:
这里的 bias 和前面 ELFIO 路径里的 runtime_bias 本质上解决的是同一类问题,只不过做法更贴近“当前这块内存如何解释成一个已装载 ELF”。
对应代码主干大致如下:
parseDynamicSegment() 做的事情,其实就是把后续 Hook 需要的一批关键数据结构先准备出来,包括:
也就是说,在 ElfReader 这条路径里,符号查找、relocation 扫描这些动作并不依赖外部库,而是完全靠自己把 dynamic segment 中的元数据拆出来。
这一点是 ElfReader 和 ELFIO 路径差异很大的地方。
在 ELFIO 路径里,当前项目的做法是直接遍历 .dynsym 去找目标符号;但在 ElfReader 里,项目自己实现了两套更传统的符号查找方式:
如果模块有 DT_GNU_HASH,就优先走 GNU hash;否则就回退到 ELF hash。这也是为什么 plt_hook 目录下还保留着 elf_hash.cpp 和对应头文件。
ElfReader::hook() 的主逻辑:
对应代码结构大致是:
这里也能看出它和 ELFIO 路径的风格差异:
ElfReader 不只是负责解析和查找,它内部还有一套自己的 patch 流程,也就是 hookInternally()。
它的整体思路和前面公共的 runtime patch 很像:
从这里也能看出为什么前面说它是“一体化兼容实现”:在这条路径里,解析 ELF、筛选 relocation、计算地址、改写内存,并没有被拆成多个相对独立的内部层,而是更多集中在 ElfReader 这一个类附近完成。
写到这里,其实就很容易回答一个问题:既然已经有了 ELFIO 主路径,为什么不把 ElfReader 删掉?
我觉得至少有下面几个原因:
所以从当前 Nook 的实现定位看,ElfReader 更像是一条兼容和兜底路径,而不是未来主要继续扩展复杂度的方向。
项目里已经有一个比较直接的例子:examples/native_hook/nook_native_strcmp_test/payload.cpp。
这份 payload 的逻辑不复杂,但很适合把前面的原理串起来:
核心代码大致是:
而真正的 Hook 函数只是:
表面上看,这只是一次普通的函数替换;但放回 Nook 内部实现链路中,它实际已经隐含触发了:
这也是为什么我觉得 PLT Hook 很适合作为 Native Hook 框架的第一步:对外接口很简洁,但内部已经把一条完整的 Hook 基础设施链路跑通了。
虽然当前 Nook 里的 PLT Hook 已经够用,但它也有非常明确的边界。
如果目标调用根本没有经过导入槽位,而是:
那么 PLT Hook 是无能为力的。因为它的切入点从来都不是“目标函数入口”,而是“导入链路上的重定位结果”。
当前 module_path_matches() 支持子串匹配,这使使用体验更宽容,但也意味着如果进程里存在名字很像的 so,理论上会有误命中风险。
ELFIO 路径读的是磁盘文件,patch 的是进程内存。如果 runtime bias 算错,最后 patch 的就不是目标槽位,而是一个错误地址。这也是整个实现里最不允许出错的换算步骤之一。
到这里,其实可以把 Nook 当前的 PLT Hook 核心思路压缩成一句话:
Nook 的 PLT Hook,本质上就是“先利用 ELF 元数据定位目标符号对应的重定位槽位,再把该槽位在运行时映像中的真实地址安全改写成 replacement,同时保留原始目标地址供后续继续调用”。
如果再拆细一点,这套实现可以被理解成四层:
我觉得 Nook 当前这部分实现最值得记录的,并不是“PLT Hook 这个概念本身有多新”,而是它把原本容易耦合在一起的几件事尽量拆开了:
这让整套 Native Hook 基础设施在演进时更容易控制风险,也更容易继续往 Inline Hook 那一侧扩展。
下一篇继续写 Native Hook,我会尝试顺着这个方向把 Inline Hook 接上:同样是 Hook native 函数,为什么到了 Inline Hook 这里,问题会从“找 relocation 和改槽位”变成“改机器码、搬运指令和构造 trampoline”。
strcmp(a, b);
malloc(16);
调用点
->
PLT stub
->
GOT 槽位
->
真实函数地址
slot_address = runtime_bias + relocation_offset
runtime_bias = runtime_module_base + p_offset - p_vaddr
libnative-lib.so
->
strcmp 对应的 GOT 槽位
->
libc.so:strcmp
libnative-lib.so
->
strcmp 对应的 GOT 槽位
->
hooked_strcmp
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
api.hook_symbol("libnative-lib.so",
"strcmp",
reinterpret_cast<void*>(hooked_strcmp),
&original);
NookPltHookSymbol
-> HookSymbolWithFallback
-> get_module_info
-> TryPltHookWithElfio
-> LoadFromFile
-> ComputeRuntimeBias
-> CollectRelocationsForSymbol
-> PatchPointerAtAddress
-> 失败时 TryPltHookWithElfReader
NookStatus NookPltHookInitialize(void);
NookStatus NookPltHookIsAvailable(int* available);
NookStatus NookPltHookSymbol(const char* module_name,
const char* symbol_name,
void* replacement,
void** original);
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
}
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::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;
}
}
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));
...
}
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;
}
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;
}
}
*original = *slot;
*slot = replacement;
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;
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;
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;
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;
}
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++) {
...
}
}
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);
}
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);
}
- PLT Hook 到底 Hook 的是什么?
- 一个导入函数在运行时是如何通过 GOT/PLT 被调用的
PLT Hook为什么本质上是“改重定位结果”- 为什么改一个槽位里的函数指针,就能劫持 native 调用?
- 一次
hook_symbol()调用在内部究竟经历了哪些步骤?
- 动态符号表
.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,Procedure Linkage Table,可以理解成导入函数调用的跳板GOT,Global Offset Table,可以理解成运行时保存目标地址的槽位表
- 符号索引,说明这条 relocation 对应哪个导入符号
- relocation type,说明这条 relocation 属于哪一类修正
- relocation offset,说明最终要修正的目标位置在哪里
- 先找到目标符号对应的 relocation
- 再拿到它的 offset
- 然后把这个 offset 换算成进程里的真实地址
- 最后在那个地址上把原函数地址替换掉
- 先查当前页权限
mprotect成可写- 写入 replacement
- 恢复原来的页权限
- 不直接改目标函数机器码
- 更依赖 ELF 和重定位信息
- 只能影响经过导入槽位发起的调用
- 直接劫持目标函数执行流
- 不依赖导入表
- 能覆盖的场景更广
- 但实现难度和风险也更高
include/nook/NookPltHook.h对外暴露 PLT Hook APIsrc/framework/NookPltHook.cpp负责参数校验、初始化、策略装配src/framework/NookNativeHook.cpp当前只是把 Native Hook 门面转到 Plt Hooksrc/native_hook/core放模块定位、路径匹配、通用调度、内存 patchsrc/native_hook/plt_hook放 ELF 元数据解析
- 用户调用
NookNativeHookHookSymbol() - 它直接转发到
NookPltHookSymbol() NookPltHookSymbol()组装依赖并进入统一调度器- 调度器通过
/proc/self/maps找到目标模块的运行时基址和磁盘路径 - 尝试
ELFIO解析主路径 - 最终定位到某个 relocation 对应的 slot 地址
- 通过统一的 runtime patch 逻辑改写该地址里的函数指针
- 同时把原始函数地址保存到
original
- 参数校验
- 懒初始化
- 组装 primary/fallback 依赖
- 打开
/proc/self/maps - 逐行读取映射记录
- 从每一行里解析出起始地址、权限、路径
- 用
module_path_matches()判断这行是不是目标模块 - 命中后返回
map_start作为运行时模块基址,同时把路径保存下来
- 运行时视角下的
module_base - 文件视角下的
module_path
- 从
.dynsym找到目标符号的动态符号索引 - 遍历所有
SHT_REL/SHT_RELAsection,找出引用该符号的 relocation - 从首个
PT_LOAD段计算 runtime bias
[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。