iOS tweak 开发必修:__got 与 __la_symbol_ptr 函数劫持原理详解

在 iOS 越狱圈待得够久的人,几乎都见证过 tweak 注入器的一轮又一轮更替——从最早的 Cydia Substrate,到后来的 Substitute、libhooker,再到如今的 ElleKit。与此同时,还有 Dobby、FishHook(由 Facebook 开发)等一大批工具,它们的共同目标都是拦截、重定向甚至修补 Mach-O 二进制里的函数。而在这些工具的背后,有一个绕不开的核心:__la_symbol_ptr 段,或者说 iOS 上的 __DATA.__la_symbol_ptr。本文就来把这一机制的来龙去脉讲清楚。

GOT 与 PLT:从 ELF 到 Mach-O

如果你接触过 Android 或 Linux 的开发,可能对 GOT(全局偏移表)和 PLT(过程链接表)并不陌生。iOS 底层的机制与之相似,但具体实现有所不同。在 Linux 或 Android 的 ELF 格式里,有一个明确命名为 PLT 的段,GOT 就位于其中;而在 iOS 上,并没有一个叫 PLT 的独立段。iOS 的 GOT 表位于 __DATA_CONST.__got,而对应 ELF 里 PLT 角色的,是 __TEXT.__stubs。这里 TEXT 是代码段,被标记为 W^X(写与执行互斥),一旦尝试向其中写入,就会触发 CS_ENFORCEMENT 保护。

那么 Linux 或 Android 里的 .got.plt 在 iOS 上对应什么呢?答案就是 __DATA.__la_symbol_ptr。搞清楚这层映射关系之后,还需要弄明白 GOT 到底在做什么。假设你写了一个 C 程序,调用了 libc 里的 printf()。你当然可以把 printf 的整个实现都编译进自己的程序里,但几乎没有人会这么做。通常的做法是包含 stdio.h 头文件,然后在需要的地方直接调用 printf()。问题随之而来:你的程序怎么知道 printf() 的实现在哪里?头文件只提供了函数原型,真正的实现却在你的二进制之外。

__got 与 __la_symbol_ptr 分别是什么

这正是 GOT 出场的地方。对于那些惰性链接(lazy linking)的外部函数,系统会为每个函数准备一个「蹦床」(trampoline),并把解析工作交给绑定器——在 iOS 上是 dyld_stub_binder,在 Linux 上则是 _dl_runtime_resolve。当程序第一次调用 printf() 时,绑定器会去 libsystem_c.dylib(苹果把它藏在 libSystem.B.dylib 这个大伞之下)里查找 printf() 的真实地址,找到之后把这个地址写回到二进制内部的 __la_symbol_ptr 表中。这个写入每个函数只会发生一次。之后程序再调用 printf(),虽然仍会先命中那个 stub,但因为绑定器已经完成映射,它可以直接从 __la_symbol_ptr 里读到地址并跳转过去。

这里有一个关键区分:__DATA_CONST.__got 专门用于非惰性绑定的引用,这些引用在程序执行之初、甚至在 main() 函数运行之前就由绑定器解析完成。你会在这里看到 C++ 的虚表指针、___stack_chk_guard 栈保护变量、Objective-C 的类引用等。这些东西必须在任何代码运行之前就有效,否则程序会直接崩溃。而 __DATA.__la_symbol_ptr 则用于惰性链接的引用,像 printf() 这类来自标准 C 库的函数,会在运行时第一次被需要时填入。

为什么劫持的是 __la_symbol_ptr 而不是 __got

之所以要区分「改 GOT」还是「改 la_symbol_ptr」,是因为绑定器会根据补丁发生的时机,选择使用其中一张表。最核心的差别在于:GOT 位于 __DATA_CONST 里,dyld 处理完之后会把它变成完全只读;而 __la_symbol_ptr 位于 __DATA 里,它保持可写,因为绑定器要按需往里写地址。

由此带来的实际影响是:如果你在应用初始化之后,试图通过一个侧载进来的 DYLIB 去写 GOT 表,应用会直接崩溃;而写 __la_symbol_ptr 则大多能瞒过内存保护,因为它本来就是允许写入的。这正是 tweak 开发者偏爱 __la_symbol_ptr 的根本原因——它的可写属性让函数劫持变得相对「安全」,至少在内存权限层面不会被立刻抓住。

tweak 注入到底是怎么工作的

一个 iOS tweak,本质上通常就是一个 DYLIB(动态链接库)。它既可以通过越狱环境下的 DYLD_INSERT_LIBRARIES 被 tweak 注入器加载,也可以通过 LC_LOAD_DYLIB 借助 Sideloadly 之类的侧载工具加载,最终被映射进目标进程的内存。tweak 加载之后,它的构造函数会在 main 函数之前执行,从而有机会去修补任何它想修补的东西。

问题在于,对于 GOT 里的内容,这时候已经太晚了——当 tweak 的 dylib 运行时,dyld 早已把内容映射进 GOT 并切成了只读。因此像 FishHook 这样的 hook 库,会在内部调用 mprotect,把相关内存页临时切回 PROT_READ | PROT_WRITE,写入补丁之后再恢复:

mprotect(pageAlignedAddr, pageSize, PROT_READ | PROT_WRITE);
*(void**)gotEntry = hook;
mprotect(pageAlignedAddr, pageSize, PROT_READ);

可以看到,钩子放置完成后,hook 库会再次调用 mprotect 恢复这些内存页的正常权限。不过这套方法通常只在越狱设备上奏效,因为越狱已经削弱了不少 iOS 的安全特性。而在侧载的 DYLIB 里,对 __DATA_CONST 的 mprotect 调用很可能会直接杀死应用,所以 __DATA.__la_symbol_ptr 钩子就成了更受偏爱的选择。

mprotect 与函数指针的替换细节

借助 otool 查看一个真实 Mach-O 二进制(比如 Roblox)的 stub 段,会发现成百上千个引用,但它们都遵循同一套汇编模式。每一个被导入的函数都对应这样三条指令:

adrp x16, 397              ; 把 __la_symbol_ptr 的页地址载入 x16
ldr  x16, [x16, #offset]   ; 从该页读出真正的函数指针
br   x16                   ; 跳转过去

这三条指令每一条占 8 字节,偏移量按 8 字节递增(#0x0、#0x8、#0x10……),本质上就是把 __la_symbol_ptr 当作一个数组来索引。所以如果你想 hook 某个 stub 里的函数,根本不用去碰 __TEXT(否则会因内存权限违规而崩溃),而是直接到 __DATA 段里对应的地址,把那个指针覆盖掉。stub 本身从不改变,它只是转而使用你提供的地址而已。这就是 tweak 函数劫持最底层的画面。

A12+ 设备上的 PAC 指针认证

在 A12 及之后的 arm64e 设备上(如 iPhone XS 及更新机型),苹果引入了 PAC(Pointer Authentication Codes,指针认证码),这让越狱开发者头疼不已。存储在 __got 和 __la_symbol_ptr 里的地址现在都用一个嵌入在硬件里的密钥进行了签名。当 CPU 加载一个被签名的地址并跳转过去时,会校验签名,所以简单地把一个未签名的地址塞进 GOT 表项,会导致 CPU 出错、应用崩溃。

为了绕过这一点,较新的 hook 库会在写入表项之前,先对函数指针进行签名。签名必须使用正确的密钥,并且以表项自身的地址作为上下文(苹果称之为地址多样性),否则 CPU 依然会拒绝。这正是 Substrate 这类老工具在 A12+ 上失效、越狱圈不得不转向 libhooker 和后来的 ElleKit 的原因——后两者支持 PAC 重新签名。在 arm64e 设备上,你不能再直接写 *(void**)slotEntry = theHook;,而是需要先剥离旧签名,再用正确的密钥和地址多样性重新签名后再写入。这层保护让函数劫持从「改一个指针」变成了「正确处理签名」的精细活儿,也是现代 iOS tweak 开发必须跨过的一道坎。

结语:理解底层才能写出稳定的 tweak

从 ELF 的 GOT/PLT,到 iOS 的 __DATA_CONST.__got 与 __DATA.__la_symbol_ptr,再到 mprotect 与 PAC,这一整套机制构成了越狱 tweak 函数劫持的技术底座。iOS 与 ELF 之间的相似与差异,恰恰是理解这件事最有意思的地方。对于有志于 tweak 开发的读者来说,弄懂 __la_symbol_ptr 为什么可写、GOT 为什么只读、以及 A12+ 上 PAC 为何让老工具失效,远比背几个 API 更重要——因为只有理解了底层,才能写出在真实设备上稳定运行的 tweak。