之前在实现 组件化分发生命周期 - AOP 方案 过程中用到了 Aspects 这个库,所以当时又仔细看了看源码,借鉴了些消息分发的写法。后来又为了性能考虑用 libffi 这个库替换了 Aspects 的实现 ,参考了 Stinger 这个库的实现。本质上都是在做 Hook 操作,加上最近看了些 fishhook 的原理,涉及了很多 Mach-O 等底层原理,所以打算把 iOS 里的一些 Hook 方案列一下,做个笔记总结,就是这篇文章的初衷了。
看了一段时间的底层原理就有些倦怠了,所以计划了一场组内分享驱动着看完了 fishhook 相关的知识。Demo 和 keynote 放在了 GitHub上,地址在这:https://github.com/gonghonglou/HookDemo
Preview
1、Method Swizzling
2、Message Forwarding
3、libffi
4、fishhook
5、静态库插桩6、基于桥的全量方法 Hook 方案 TrampoLineHook
7、Dobby / Frida
小试牛刀:Method Swizzling
这是 iOS 里最基础最原生的 Hook 方法了,当然也是性能最好的选择。本质上就是交换两个方法的 IMP(函数指针),即:
常见的写法也很简单:
1 | @implementation HookDemoObj (HK) |
需要注意的也就是以下几点:
1、为什么 load 时机 可以成功执行?
1.1、启动 dyld(the dynamic link editor)将应用程序加载到二进制中
1.2、Runtime 向 dyld 中注册回调函数
1.3、通过 ImageLoader 将所有的 image 加载到内存中
1.4、dyld 在 image 发生改变时,主动调用回调函数
1.5、Runtime 接收到 dyld 的函数回调,开始 map_images、load_images等操作,并回调 +load 方法
1.6、调用 mian 函数
其中 3、4、5会执行多次,在 ImageLoader 加载新的 image 进内存后就会执行一次
ImageLoader 是 image 的加载器,image 可以理解为编译后的二进制
2、dispatch_once 保证,避免方法被多次交换。
3、调用原方法,因为方法已经被交换过了,所以这里调用 [self hook_loopLogWithCount:count];
即执行的是原函数(originalIMP)。
穿针引线:Message Forwarding
主要是 Aspects 的原理,让目标方法在被执行时直接进入快速消息转发流程,在最后的 forwardInvocation:
方法里拿到 NSInvocation
对象,即包含了被调用方法的所有信息,主要是参数个数,参数值。然后在调用原方法之前或之后调用传入的 Block。
看一下 Aspects 的使用:
1 | HookDemoObj *forwardingObj = [HookDemoObj new]; |
需要注意的有以下几点:
1、isa-swizzling 的应用
2、消息转发流程。如何不重写 “methodSignatureForSelector:” 方法也能进入 “forwardInvocation:” 阶段?
3、如何获取 block NSMethodSignature?并且在不定参数情况下执行 block?
关于isa-swizzling 的应用:isa-swizzling 的原理如下图。即 KVO 的基本原理:原来的对象 isa 指向一个 class,通过 runtime 动态创建一个 class 的子类,并将原来对象的 isa 指向这个新建的子类,重写这个子类 getter、setter 即可。
Aspects 的应用:
1 | // Default case. Create dynamic subclass. |
关于消息转发流程:正常的消息转发流程如下:
1 | + (BOOL)resolveInstanceMethod:(SEL)sel { |
注意,必须实现 methodSignatureForSelector:
方法返回 NSMethodSignature
才会进入 forwardInvocation:
方法。但 Aspects 并没有实现 methodSignatureForSelector:
方法。Aspects 的做法是:把要 hook 的方法通过 class_replaceMethod()
接口指向 _objc_msgForward
,这是一个全局 IMP,OC 调用方法不存在时都会转发到这个 IMP 上,直接把方法替换成这个 IMP,这样调用 hook 方法时就会走到 forwardInvocation:
:
1 | Method targetMethod = class_getInstanceMethod(klass, selector); |
关于 Block NSMethodSignature 和调用:
这里 Block_layout 是 Block 的源码,Block 的结构体内容是:
1 |
|
isa 指向 Block 对象的类;
flags 决定 Block 包含的 Block_descriptor_1、Block_descriptor_2、Block_descriptor_3 信息;
reserved 预留字段
Aspects 获取 Block signature 的方式:
1 | // 这里自己定义了和系统 Block_layout 布局一致的 block 结构体 |
将这些信息保存下来,在方法进入 forwardInvocation:
阶段,拿到方法的参数个数和入参值等信息分发到传入的 Block 上:
1 | - (BOOL)invokeWithInfo:(id<AspectInfo>)info { |
移花接木:libffi
libffi 相当于 C 语言上的 runtime,拥有动态调用 C 方法及 OC 方法的能力。简单介绍下用法,例如,调用 C 函数:
1 | int c_func(int a , int b) { |
调用 OC 函数:
1 | - (int)oc_func:(int)a b:(int)b { |
函数调用 ffi_call
方法需要传入:1、函数模版(ffi_cif
),2、函数指针,3、返回值,4、参数数组 这四个参数字段。
重点在于 函数模版(ffi_cif
)。用 ffi_cif
生成一套函数模版,这个模版定义了调用一个函数时,这个函数的:1、参数个数(int
),2、返回值类型(ffi_type
),3、包含各个入参类型(ffi_type
)的数组
这样,libffi
的 ffi_call
方法就可以根据这个传入的 函数模版(ffi_cif
)去调用函数了。
ffi_type
类型对应着系统的 Type Encodings 类型
除了动态调用 C & OC 方法外,libffi
提供的另外一个关键的能力是:
通过 ffi_closure_alloc
方法创建一个关联着函数指针(IMP
)的闭包(closure
)
通过 ffi_prep_closure_loc
函数给 cif
关联上这个闭包,并传入一个函数实体(fun
)和刚才闭包里的 IMP
这样,当 IMP
被执行的时候,就会执行函数实体(fun
),并且能在这个函数里拿到:1、cif
,2、返回值,3、参数地址,4、自定义的关联数据(userdata
),例如:
1 | void *newIMP = NULL; |
有了这样的能力,我们就可以将上述的 newIMP
与我们要 hook 的方法 IMP
交换,这样调用 hook 方法的时候,就会执行到 holo_lifecycle_ffi_closure_func
方法,在这个方法里可以拿到原方法的参数个数、参数类型能信息,我们只要在这个方法里再调用一下原方法的 IMP
,并在之前和之后分别调用要转发的目标对象的同名方法即可。
实现了一份简单版的基于 libffi 的 hook 方法代码:
1 | @implementation NSObject (LibffiHook) |
libffi_hook_ffi_type
方法直接拷贝自:Stinger 里的 ffi_type *_st_ffiTypeWithType(const char *c) 方法
偷梁换柱:fishhook
以 hook NSLog 为例,使用方式如下:
1 | - (void)fishhook_nslog { |
fishhook 是 FaceBook 出品的能够 hook 动态库方法的框架(注意:仅能 hook 系统动态库方法),其源代码仅仅不足两百行,但是要搞明白这两百行代码的工作原理所需要的基础知识却是非常多的。
以下是 App 启动的大致过程,大致分为:开辟进程、加载可执行文件、加载 Dyld、Dyld 加载各个动态库、Rebase(因为 ALSR 技术进行基址重定位)、Bind(动态库的符号绑定)、加载 OC 类,分类、init 方法、调用 Main 函数、调用 UIApplicationMain 函数、起一个主线程的 runloop。
fishhook
的工作原理就是 Bind 阶段:符号重绑定。
普及一些涉及的基础概念:
为什么要动态链接?
远古时代,所有源代码都在一个文件上(想象下开发一个App,所有源代码都在main.m上,这个 main.m 有几百万行代码。多人协同开发、如何维护、复用、每次编译几分钟….)。
为了解决上面问题,于是有了静态链接。像我们平时开发,每个人开发自己的模块功能,最后编译链接在一起。解决了协同开发、可维护、可复用、编译速度也很快了(未改动的模块用编译好的缓存)。
静态链接好像已经很完美了。我们平时开发 App,都会用到 UIKit、Foundation 等许多系统库。假如都是通过静态链接的,手机里每个App都包含了一份这些系统库,每个App包体积变大了,占用磁盘空间;每个 App 运行时都要在内存里分别加载这些库,占用内存。
假设 UIKit 里某个函数有bug,需要更新,所有 App 都要重新静态链接最新的 UIKit 库,然后发版。
为了这些问题,于是产生了动态链接。
position-independent code (PIC 地址无关代码)
产生地址无关代码原因:dylib 在编译时候,是不知道自己在进程中的虚拟内存地址的。因为 dylib 可以被多个进程共享,比如进程 1 可以在空闲地址 0x1000-0x2000 放共享对象 a,但是进程 2 的 0x1000-0x2000 已经被主模块占用了,只有空闲地址 0x3000-0x4000 可以放这个共享对象 a。
所以共享对象 a 里面有一个函数,在进程 1 中的虚拟内存地址是 0x10f4,在进程 2 中的虚拟内存地址就成了 0x30f4。那机器指令就不能包含绝地地址了(动态库代码段所有进程共享;可修改的数据段,每个进程有一个副本,私有的)。
PIC原理:为了解决 dylib 的代码段能被共享,PIC(地址无关代码)技术就产生了。PIC 原理就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分是每个进程都有一个副本。
ALSR:
在计算机科学中,地址空间配置随机加载(英语:Address space layout randomization,缩写ASLR,又称地址空间配置随机化、地址空间布局随机化)是一种防范内存损坏漏洞被利用的计算机安全技术。ASLR通过随机放置进程关键数据区域的地址空间来防止攻击者能可靠地跳转到内存的特定位置来利用函数。现代操作系统一般都加设这一机制,以防范恶意程序对已知地址进行Return-to-libc攻击。
Mach-O 文件
在了解 fishhook 的具体原理之前还要熟悉下 Mach-O 文件:MacOS 上的可执行文件格式,类似于 Linux 和大部分 UNIX 的原生格式 ELF(Extensible Firmware Interface)。
1、Header:magic 魔数,内核识别 MachO
2、Load Commands:存储 Mach-O 的布局信息
3、Data:包含实际的代码和数据,Data 被分割为多个 Segment。每个 Segment 被分割为多个 Section,分别存放不同的数据
标准的三个 Segment:TEXT、DATA、LINKEDIT
3.1、TEXT:代码段,只读可执行,存储函数的二进制代码(__text),常量字符串(__cstring),Objective C 的类/方法名等信息
3.2、DATA:数据段,读写,存储 Objective C 的字符串(__cfstring),以及运行时的元数据:class/protocol/method…
3.3、LINKEDIT:启动 App 需要的信息,如 bind & rebase 的地址,代码签名,符号表…
Mach-O 中 __DATA 段有两个 Section 与动态符号绑定有关系
__nl_symbol_ptr :存储了 non-lazily 绑定的符号,这些符号在 Mach-O 加载的时候绑定完成
__la_symbol_ptr :存储了 lazy 绑定的方法,这些方法在第一次调用时,由 dyld_stub_binder 进行绑定
为了实现系统动态库的共用,有了上文提到的动态链接。PIC 原理里提到了把那些需要共用的符号放在了 DATA 段,DATA 段的权限是可读写的,fishhook 就是在运行期修改 DATA 段里的数据,把系统符号绑定的地址重新绑定位我们自己定义的 hook 函数地址。
比如 NSLog 就是懒加载的,在第一次访问 NSLog 符号的时候先去 stub,stub 告诉从 __la_symbol_ptr 查找,__la_symbol_ptr 表示还没有 NSLog 符号真实函数地址,需要动态绑定,于是去 __nl_symbol_ptr 查找 dyld_stub_binder 函数的地址,进行查找真实的 NSLog 地址。
找到后调用 NSLog 函数,并把这个地址保存进 __la_symbol_ptr。
下次调用 NSLog 函数的时候在 __la_symbol_ptr 就能得到真实地址进行跳转。
fashhook 工作流程:
下图是 fashhook 在 GitHub 上 README.md 里的图,非常清晰的介绍了 fashhook 的工作流程:
以 hook NSLog 方法为例:
1、在 Lazy Symbol Pointer Table 找到 NSLog 顺序
2、按上面的顺序在 Indirect Symbol Table 找到 NSLog
3、把 Indirect Symbol Table 中 NSLog 的 data 值转为 10 进制,作为角标在 Symbols Table -> Symbols 中查找
4、把 Symbols 表中 NSLog 的 data 值加上 String Table 中的第一条数据(base value)的值,确认找到了目标符号
把 Lazy Symbol Pointer Table 里的角标位置上的值修改为我们自己函数的地址,即完成了符号重绑定过程。
核心代码如下:
1 | // dyld 在 image 发生改变时,主动调用回调函数 |
李代桃僵:静态库插桩
主要是 静态插桩的方式来实现Hook Method 这个文化里提到的技术,
文章里主要是操作的 _objc_msgSend
方法。基本原理大概是:
把自己的组件打成静态库,编译阶段因为不知道引用的外部符号的具体地址,只在符号表里做了标记,需要在链接阶段再查找外部符号的引用进行绑定。
通过脚本手动替换掉 .a 文件里的符号(_objc_msgSend
)为我们自定义的符号(_hook_msgSend
),注意两个符号必须等长。
再自己在 text 段定义一个 _hook_msgSend
函数,这样,链接阶段查找外部符号就绑定成了自己定义的函数。
脚本代码如下,需要对 Mach-O 格式非常熟悉:
1 | # -*- coding: utf-8 -* |
在 text 段定义一个 _hook_msgSend
函数:
1 |
|
简单解释下以上 _hook_msgSend
的汇编代码:
ARM64 有 31 个通用寄存器,每个寄存器可以存取一个 64 位的数据。我们可以通过 X0 - X30
来对这些寄存器进行寻址。对应 X0 - X30
,W0 - W30
对应的就是相同单元数的低 32
位。W0 - W30
当进行写入操作时,会将高 32
位清零。
每一个寄存器具体的作用:
X0 - X7
:这 8 个寄存器主要 用来存储传递参数 。如果参数超过 8 个,则会通过栈来传递 ;X0
也用来存放上文方法的返回值
X29
:即我们通常所说的帧指针 FP
(Frame Pointer),指向当前方法栈的底部
X30
:即链接寄存器 LR
(Link Register)。为什么叫做链接,是因为这个寄存器会记录着当前方法的调用方地址 ,即当前方法调用完成时应该返回的位置。例如我们遇到 Crash 要获取方法堆栈,其本质就是不断的向上递归每一个 X30
寄存器的记录状态(也就是栈上 X30
寄存器的内容)来找到上层调用方。
除了这些通用寄存器,还有一个最重要的 SP
寄存器:
SP
寄存器:即我们通常说的栈帧 SP
(Stack Pointer)。指向当前方法栈的顶部。
这里 _hook_msgSend
方法里因为要用到 X0 - X7
等参数寄存器,所以每次保存下这些寄存器,调用原 _objc_msgSend
方法前再回复这些寄存器的内容,以保证上下文环境不被污染。
镜花水月:基于桥的全量方法 Hook 方案 TrampolineHook
这是 五子棋 开源的中心重定向框架:TrampolineHook,用法示例:
1 | void myInterceptor() { |
因为对 TrampolineHook 里的汇编代码还没完全看懂,之前 靛青 曾经写过一篇:TrampolineHook 学习笔记 这里就不错过多原理解析了。
除了以上提到的 Hook 方案,剩下还有 Dobby / Frida 逆向领域的 Hook 手段等,因为才疏学浅就不再继续解析了。