feature: App 逆向防护

This commit is contained in:
杭城小刘
2024-07-15 20:03:01 +08:00
parent 13f7457be9
commit 83fefff66b
109 changed files with 2549 additions and 672 deletions

View File

@@ -2,7 +2,7 @@
## 启动分类
- 冷启动Cold Launch点击 App 图标启动前,进程不在系统中。需要系统新创建一个进程并加载 Mach-O 文件dyld 从 Mach-O 头信息中读取依赖(undefined的动态库库),从动态库共享缓存中读取并链接,经历一次完整的启动过程。
- 冷启动Cold Launch点击 App 图标启动前,进程不在系统中。需要系统新创建一个进程并加载 Mach-O 文件dyld 从 Mach-O 头信息中读取依赖(Load Commands),从动态库共享缓存中读取并链接,经历一次完整的启动过程。dyld 加载 Mach-O 完整的流程可以查看我[另一篇文章](./1.91.md)
- 热启动Warm LaunchApp 在冷启动后,用户将 App 退后台。此阶段App 的进程还在系统中,用户重新启动进入 App 的过程,开发对该阶段能做的事情非常少。
所以我们聊启动时间优化,通常是指冷启动。此外启动慢,也就是看到主页面的过程很慢,都是发生在主线程上的。
@@ -13,6 +13,8 @@
- 如果需要更详细的信息,那就将 `DYLD_PRINT_STATISTICS_DETAILS` 设置为1
## 启动阶段划分
App 冷启动可以划分为3大阶段
@@ -37,9 +39,11 @@ main () {
}
```
## 第一阶段:进程创建到 main 函数执行dyld、runtime
![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/assets/AppLaunchingTime.png)
![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/AppLaunchingTime.png)
这一阶段主要包括 dyld 和 Runtime 、main 函数的工作。
@@ -187,7 +191,44 @@ void _objc_init(void){
}
```
方法注释说的很明白,被 dyld 所调用
方法注释说的很明白,被 dyld 所调用
dyld 加载解析 Mach-O根据 Mach-O 的 Load Commands 读取所需的动态库、静态库去加载。从 Mach-O 的 `LC_LOAD_DYLINKER` load command 中,根据 name 路径信息然后加载dyld 开始执行。它的主要任务是加载应用程序的主可执行文件以及其他依赖的动态库。
对于动态库,分为系统动态库和开发者写的动态库:
- 系统共享缓存系统动态库Apple 为了启动优化在 iOS 3.1及以后的版本中Apple 将系统库文件打包合并成一个大的缓存文件,存放在 `/System/Library/Caches/com.apple.dyld/ ` 目录下,以减少冗余并优化内存使用。
- 检查共享缓存当应用程序启动时dyld 首先检查共享缓存中是否已经包含了所需的系统库。共享缓存是一种优化机制,用于存储系统级别的动态库,以便多个应用程序可以重用这些库,减少内存占用并加快加载速度。
- 映射到地址空如果动态库在共享缓存中dyld 将直接从缓存中映射库到应用程序的地址空间,而不是从磁盘加载
- 验证和解密:对于加密的 Mach-O 文件例如应用商店发布的应用程序dyld 将解密并验证代码签名以确保安全性
- Rebase & Binding由于存在 ASLR 和系统共享缓存库的存在dyld 会进行 rebase 和 binding。解析符号真正的地址。具体可以看[FishHook 原理](./1.88.md) 和[DYLD 及 Mach-O ](./1.91.md) 文章
- 开发者编写的动态库:
- 解析依赖dyld 从应用程序的主可执行文件开始,解析出所有依赖的动态库,包括开发者添加的自定义动态库。
- 加载 Mach-O 文件对于每个依赖的动态库dyld 会找到对应的 Mach-O 文件,并进行加载。
- 读取和映射dyld 打开并读取 Mach-O 文件,然后使用 mmap 系统调用来将文件的内容映射到内存中。
- 依赖递归加载如果动态库本身还依赖其他库dyld 会递归地加载这些依赖库。
- 符号解析和绑定与系统库类似dyld 也会对自定义动态库进行 Rebase 和 Binding确保所有符号引用都是正确的。
- 初始化加载完成后dyld 会调用动态库中的初始化代码,例如 C++ 的静态构造函数和 Objective-C 的 +load 方法
到了 dyld3 之后,带来了**启动闭包**技术。
dyld 会首先创建启动闭包,闭包是一个缓存,用来提升启动速度的。既然是缓存,那么必然不是每次启动都创建的,只有在重启手机或者更新/下载 App 的第一次启动才会创建。闭包存储在沙盒的 tmp/com.apple.dyld 目录,清理缓存的时候切记不要清理这个目录
闭包是怎么提升启动速度的呢?我们先来看一下闭包里都有什么内容:
- dependends依赖动态库列表
- fixupbind & rebase 的地址
- initializer-order初始化调用顺序
- optimizeObjc: Objective C 的元数据
- 其他main entry, uuid…
为什么闭包能提高启动速度呢?
这些信息是每次启动都需要的,把信息存储到一个缓存文件就能避免每次都解析,尤其是 Objective C 的运行时数据Class/Method...)解析非常慢
### Runtime
@@ -203,6 +244,8 @@ void _objc_init(void){
到此为止,可执行文件和动态库中所有的符号(ClassProtocolSelectorIMP…)都已经按格式成功加载到内存中,被 Runtime 所管理
## 第二阶段main 函数到 didFinishLaunchingWithOptions
APP的启动由 dyld 主导将可执行文件加载到内存顺便加载所有依赖的动态库。并由Runtime 负责加载成 objc 定义的结构。所有初始化工作结束后dyld 就会调用 main 函数
@@ -211,6 +254,8 @@ APP的启动由 dyld 主导,将可执行文件加载到内存,顺便加载
AppDelegate 的`application:didFinishLaunchingWithOptions:` 方法。此阶段主要是各种 SDK 的注册、初始化、APM 监控系统的启动、热修复 SDK 的设置、App 初始化数据的加载、IO 等等。
## 第三阶段:`didFinishLaunchingWithOptions` 到业务首页加载完成
这部分主要是首页渲染相关逻辑,不同的场景,首页的定义可能不一样。比如未登录的时候首页就是登陆页、否则就是某个主页
@@ -219,13 +264,27 @@ AppDelegate 的`application:didFinishLaunchingWithOptions:` 方法。此阶段
- 渲染数据的计算
## 启动优化
### 第一阶段
#### dyld
- 减少动态库加载,合并一些动态库。定期清理不必要的动态库iOS 规定开发者写的动态库不能超过6个
- 减少动态库数量:过多的动态库会增加 dyld 的解析和加载时间。优化库的依赖关系,合并功能相似的库。定期清理不必要的动态库iOS 规定开发者写的动态库不能超过6个
- 使用静态库:考虑将动态库转换为静态库,以减少运行时的加载和链接时间。
- 懒加载:对于非启动必需的动态库,可以推迟到实际需要时再加载。
- 减少 Objective-C 元数据Objective-C 的类、分类和选择器数量会影响 dyld 的 Binding 时间。减少这些元数据的数量可以加快启动速度
- 优化 C++ 虚函数C++ 中的虚函数需要在运行时进行解析,这会增加 dyld 的工作量。尽量减少虚函数的使用或使用其他设计模式替代
- 利用 Swift 结构体Swift 的结构体是值类型,它们在编译时会进行优化,减少运行时的符号解析需求
- 优化 +load 方法Objective-C 中的 +load 方法会在类或分类加载时执行,这可能会影响启动速度。尽量避免在 +load 方法中执行耗时操作,或者使用 +initialize 方法替代,后者只有在类被实际使用时才会调用
- 二进制重排:接下去单独的篇章会讲。
- 利用 dyld 缓存iOS 13 引入的 dyld 3 可以生成“启动闭包”launch closure预先处理一些加载和链接工作加快启动速度。
- 使用 Xcode 的分析工具:利用 Xcode 的分析工具识别启动过程中的性能瓶颈。单点问题单点追踪分析。
- 关注 dyld 版本变更iOS 13 引入了 dyld 3它在性能上有所改进但也可能带来兼容性问题。了解 dyld 版本变更对 App 启动性能的影响,如果有需要,请根据需要进行适配。
#### Runtime
@@ -244,14 +303,24 @@ AppDelegate 的`application:didFinishLaunchingWithOptions:` 方法。此阶段
### 第二阶段
- 在不影响用户体验的前提下,尽可能将一些操作延迟,不要全部都放在 `application:didFinishLaunchingWithOptions` 方法中
- SDK 初始化遵循规范
- 任务启动器
- SDK 初始化遵循规范。什么时候注册、什么时候启动
- 任务启动器:其实就是按照一定的规则进行任务编排。将任务分类:
- 那些任务是需要在 App 启动完成前主线程同步执行的
- 那些任务是需要在 App 启动完成前主线程异步执行的
- 那些任务是需要在 App 启动完成后编排的
- 闲时主线程队列(监听 runloop 状态,`KCFRunLoopBeforeWaiting` 时执行,在 `KCFRunLoopAfterWaiting` 时停止)
- 异步串行队列
- 异步并行队列
- 闲时异步串行队列
-
- 二进制重排
- 方法耗时统计time profiler、os_signpost
AppDelegate 中 `application:didFinishLaunchingWithOptions` 函数阶段一般有很多业务代码介入,大多数启动时间问题都是在此阶段造成的。
- 比如二方、三方 SDK 的注册、初始化。这其中有些 SDK 比如 APM 是有必要放在很早的阶段。有些则不需要,比如日志 SDK 的初始化
- 梳理业务,非必要的延迟加载、启动
### 第三阶段
@@ -277,17 +346,39 @@ QA
Framework 只是打包方式,动态、静态库都可以支持。甚至可以存放资源文件。
## 二进制重排
### 虚拟内存、物理内存、内存分页
### 虚拟内存、物理内存、内存分页、ASLR
应用程序的可执行文件是存储在磁盘上的,启动应用,则需要将可执行文件加载进内存,早期计算机是没有虚拟内存的,一旦加载就会全部加载到内存中,并且进程都是按照顺序排列的,这样存在两个问题:
早期:应用程序的可执行文件是存储在磁盘上的,启动应用,则需要将可执行文件加载进内存,早期计算机是没有虚拟内存的,一旦加载就会全部加载到内存中,并且进程都是按照顺序排列的,这样存在两个问题:
- 上一个进程只需要一些地址就能访问到下一个进程,安全性很低
- 当前进程只需要在合适的地址基础上,加减一些地址就能访问到下一个进程所使用的内存,安全性很低
- 当前软件越来越大,开启多个软件时会直接全部加载到内存中,导致内存不够用。而且使用软件的时候大多数情况只使用部分功能。如果软件一打开就全部加载到内存中,会造成内存的严重浪费。
基于上述2个问题诞生了虚拟内存技术。
基于上述2个问题诞生了虚拟内存技术。App 进程通过内存管理单元Memory Management Unit, MMU来管理的。MMU 使用一张特殊的表来跟踪虚拟内存地址和物理内存地址之间的映射关系。这张表通常被称为页表Page Table
内存分页:
- 页表结构:页表包含了一系列的表项,每个表项对应虚拟内存中的一个页(通常是 4KB。每个表项包含了该虚拟页对应的物理页的信息包括物理页的起始地址和一些状态信息。
- 虚拟地址到物理地址的转换当程序访问一个虚拟地址时MMU 查看页表来找到对应的表项,然后根据表项中的信息将虚拟地址转换为物理地址。
- 页表缓存TLB - Translation Lookaside Buffer为了提高地址转换的速度MMU 通常会有一个 TLB它缓存了最近访问的页表项。这样对于频繁访问的地址转换过程可以更快地完成
- 页错误处理:如果一个虚拟地址在页表中没有对应的条目,就会发生页错误。操作系统的页错误处理程序会被调用,它负责加载缺失的页到物理内存中,并更新页表。
- 内存分配:当应用程序请求内存时,操作系统会分配虚拟地址空间,并在页表中创建相应的条目。如果需要,操作系统还会分配物理内存页,并将其与虚拟页关联起来。
当物理内存满的时候会发生覆盖。用户使用的活跃的数据覆盖内存中最不活跃的数据那一页。对应现实的表现就是iPhone 上永远可以较好的打开一个 App比如打开了 App1、App2、App3...,等打开使用了一阵子后,内存紧张,我们尝试切换打开最早的 App1会发现 App1 重新启动了,之前用的功能 A 的页面1已经不见了经历一个新的启动流程。
但虚拟内存方案带来一个问题。比如黑客不断探索发现某个重要的功能位于第3页是不是完全可以通过固定的地址去访问
因为早期物理内存方案下App 启动后位于什么地址是不确定的。有了虚拟内存后App 内符号的地址都是从0到4G都是相对地址。
为了解决该问题Apple 又推出了 ASLR 技术。核心就是为了解决虚拟内存地址固定不变的问题。就不能通过固定的地址访问内存或者数据了。
### 内存缺页异常
@@ -299,9 +390,11 @@ CPU 不直接和物理内存打交道,而是通过 MMUMemory Manage Unit
iOS 程序在进行加载时,会根据一 page 大小16kb 将程序分割为多页启动时部分的页加载进真实内存部分页还在磁盘中中间的调度记录在一张内存映射表Page Table这个表用来调度磁盘和内存两者之间的数据交换。
![](/Users/lbp/Desktop/GitHub/knowledge-kit/assets/iOSPageInPageOut.png)
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/iOSPageInPageOut.png" style="zoom:40%" />
如上图App 运行时执行某个任务时会先访问虚拟页表如果页表的标记为1则说明该页面数据已经存在于内存中可以直接访问。如果页表为0则说明数据未在物理内存中这时候系统会阻塞进程叫做**缺页中断page fault**,进程会从用户态切换到内核态,并将缺页中断交给内核的 `page Fault Handler` 处理。等将对应的 page 从磁盘加载到内存之后再进行访问,这个过程叫做 page in。1次缺页异常耗时较少用户感知不到但是在 App 启动阶段,容易发生缺页异常,如果发生几十次、几百次,这对于 App 启动时间来说,影响较大。
如上图App 运行时执行某个任务时会先访问虚拟页表如果页表的标记为1则说明该页面数据已经存在于内存中可以直接访问。如果页表为0则说明数据未在物理内存中这时候系统会阻塞进程叫做缺页中断page fault进程会从用户态切换到内核态并将缺页中断交给内核的 page Fault Handler 处理。等将对应的 page 从磁盘加载到内存之后再进行访问,这个过程叫做 page in。
因为磁盘访问速度较慢,所以 page in 比较耗时,而且 iOS 不仅仅是将数据加载到内存中,还要对这页做 Code Sign 签名认证,所以 iOS 耗时更长
@@ -309,7 +402,7 @@ TipsCode Sign 加密哈希并不少针对于整个文件,而是针对于
等到程序运行时用到了才去内存中寻找虚拟地址对应的页帧,找不到才进行分配,这就是内存的惰性(延时)分配机制。
![](/Users/lbp/Desktop/GitHub/knowledge-kit/assets/PageFault.png)
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/PageFault.png" style="zoom:50%" />
为了提高效率和方便管理对虚拟内存和物理内存进行分页Page管。进程在访问虚拟内存的一个 page 而对应的物理内存却不存在(没有被加载到物理内存中),则会触发一次缺页异常(缺页中断),然后分配物理内存,有需要的话会从磁盘 mmap 读入数据。
@@ -317,23 +410,50 @@ TipsCode Sign 加密哈希并不少针对于整个文件,而是针对于
二进制重排提升 App 启动速度是通过「解决内存缺页异常」(内存缺页会有几毫秒的耗时)来提速的。
一个 App 发生大量「内存缺页」的时机就是 App 刚启动的时候。所以优化手段就是「将影响 App 启动的方法集中处理,放到某一页或者某几页」(虚拟内存中的页)。
Xcode 工程允许开发者指定 「Order File」可以「按照文件中的方法顺序去加载」。
核心就是:二进制重排提升 App 启动速度是通过「解决内存缺页异常」(内存缺页会有几毫秒的耗时)来提速的。
一个 App 发生大量「内存缺页」的时机就是 App 刚启动的时候。所以优化手段就是「将影响 App 启动的方法集中处理放到某一页或者某几页」虚拟内存中的页。Xcode 工程允许开发者指定 「Order File」可以「按照文件中的方法顺序去加载」可以查看 linkMap 文件(需要在 Xcode 中的 「Buiild Settings」中设置 Order File、Write Link Map Files 参数)。
### 如何获取启动阶段的 Page Fault 次数
Instrucments 中的 System Trace 可以查看详细信息。
### 如何验证重排是否成功
查看 LinkMap。发现方法展示顺序是按照写代码的顺序展示的。
![image-20200814211215976](/Users/lbp/Library/Application Support/typora-user-images/image-20200814211215976.png)
### 获取符号顺序
可以查看 linkMap 文件(需要在 Xcode 中的 「Buiild Settings」中设置 `Order File``Write Link Map File` 参数)。
设置 Xcode: `Build Settings` -> `Write Link Map Files` 为 YES。
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/XcodeEnableLinkMap.png" style="zoom:30%" />
分析:
- 可以看到符号顺序是根据链接顺序来决定的
- 符号的链接顺序并非是 App 方法真正的执行顺序。
可以调整下 Person 类中2个方法的顺序也可以看到新生成的 linkMap 符号顺序变了。
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/LinkMapWithCompileOrder.png" style="zoom:45%" />
所以是有空间进行操作的,让符号链接顺序按照 App 启动阶段方法执行顺序来进行,这个抓手就是 `Order File`
### 有没有办法将 App 启动需要的方法集中收拢?
其实二进制重排 Apple 自己本身就在用,查看 `objc4` 源码的时候就发现了身影
![](/Users/lbp/Desktop/GitHub/knowledge-kit/assets/Objc-OrderFile.png)
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Objc-OrderFile.png" style="zoom:40%" >
Xcode 使用的链接器为 `ld`。 ld 有个参数 `-order_file` 。order_file 中的符号会按照顺序排列在对应 section 的开始。
@@ -343,47 +463,72 @@ Xcode 的 Build Setting GUI 面板也支持配置。
2. 如果你给 Xcode 工程根目录下指定一个 order 文件,比如 `refine.order`,则 Xcode 会按照指定的文件顺序进行二进制数据重排。分析 App 启动阶段,将优先需要加载的函数、方法,集中合并,利用 Order File减小缺页异常从而减小启动时间。
### 实践
第一步:编写 `.order` 文件。编写顺序是结合业务逻辑和代码顺序,然后再原始的 linkmap 文件中,将符号复制,写入到新创建的 `.order` 文件中。
第二步Build Settings -> Order File 中设置 `.order` 文件的位置。
第三步:编译,查看新的 linkmap 文件,验证符号编译顺序是否和 order file 一致。
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/LinkOrderWithOrderFile.png" style="zoom:30%" >
### 如何拿到启动时刻所调用的所有方法名称
clang 插桩,才可以 hook OC、C、block、Swift 全部。LLVM 官方文档
二进制的原理很简答。最核心的、最难的就是如何获取到 App 启动阶段的所有方法。可能有 OC、Swift、C/C++ 的方法
二进制重排提升 App 启动速度是通过「解决内存缺页异常」(内存缺页会有几毫秒的耗时)来提速的。
fishhook `objc_msgSend` 只可以拿到所有的 OC 符号。那 c/c++、Swift、block 怎么拿到?
一个 App 发生大量「内存缺页」的时机就是 App 刚启动的时候。所以优化手段就是「将影响 App 启动的方法集中处理放到某一页或者某几页」虚拟内存中的页。Xcode 工程允许开发者指定 「Order File」可以「按照文件中的方法顺序去加载」可以查看 linkMap 文件(需要在 Xcode 中的 「Buiild Settings」中设置 Order File、Write Link Map Files 参数)
Clang 插桩,才可以 hook OC、C/C++、block、Swift 全部的方法调用
其实难点是如何拿到启动时刻所调用的所用方法?代码可能是 Swift、block、c、OC所以hook 肯定不行、fishhook 也不行,用 clang 插桩可行
其实难点是如何拿到启动时刻所调用的所用方法?代码可能是 Swift、block、c、OC所以hook 肯定不行、fishhook 也不行,用 clang 插桩可行。
在 [Clang 10 documentation](https://clang.llvm.org/docs/SanitizerCoverage.html#tracing-pcs) 中可以看到 LLVM 官方对 SanitizerCoverage 的详细介绍,包含了示例代码。
简单来说 SanitizerCoverage 是 Clang 内置的一个代码覆盖工具。它把一系列以 `__sanitizer_cov_trace_pc_` 为前缀的函数调用插入到用户定义的函数里,借此实现了全局 AOP 的大杀器。其覆盖之广,包含 Swift/Objective-C/C/C++ 等语言Method/Function/Block 全支持。
也可以看[精准测试最佳实践](./1.108.md)这篇文章,查看详细插桩原理。
步骤:
- 在 Xcode Build Setting 下搜索 “Other c Flags”在后面添加 `-fsanitize-coverage=trace-pc-guard`
- 在 Xcode Build Setting 下搜索 “Other C Flags”在后面添加 `-fsanitize-coverage=trace-pc-guard`。如果观察包含 Swift 代码,还需要在 “Other Swift Flags” 中加入 `-sanitize-coverage=func``-sanitize=undefined`。所有链接到 App 中的二进制都需要开启 SanitizerCoverage这样才能完全覆盖到所有调用
如果是 Cocoapods 管理。可以脚本处理
```ruby
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['OTHER_CFLAGS'] = '-fsanitize-coverage=func,trace-pc-guard'
config.build_settings['OTHER_SWIFT_FLAGS'] = '-sanitize-coverage=func -sanitize=undefined'
end
end
end
```
- 在工程入口文件添加2个方法来解决编译报错问题 `__sanitizer_cov_trace_pc_guard_init`、`__sanitizer_cov_trace_pc_guard`
- clang 插桩原理就是给每个oc、c方法、block 等方法内部第一行添加 hook 代码,来实现 AOP 效果。所以在 `__sanitizer_cov_trace_pc_guard` 内部将函数的名称打印出来,最后可以统一写入 order 文件
```objectivec
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
uint32_t *stop) {
static uint64_t N; // Counter for the guards.
if (start == stop || *start) return; // Initialize only once.
printf("INIT: %p %p\n", start, stop);
for (uint32_t *x = start; x < stop; x++)
*x = ++N; // Guards should start from 1.
}
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/ClangCodeCoverageGenerateOrderFile.png" style="zoom:30%" />
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
void *PC = __builtin_return_address(0);
Dl_info info;
dladdr(PC, &info);
printf("name: %s\n", info.dli_sname);
}
```
- 收集 App 启动过程中的函数调用,生成 `.order` 文件
- 可能会存在 while、for 循环 case所以为了避免死循环需修改 "Other c Flags" 配置为 `-fsanitize-coverage=func,trace-pc-guard`。func 表示仅 hook 函数时调用
做了个封装,假设我们 App 启动完成的重点是 AppDelegate 的 `didFinishLaunchingWithOptions` 方法。在这里一行调用,便可获得 App 启动阶段的 `.order` 文件。
- 最后修改 Build Setting 中的 "Order File" 配置项
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CollectAppLaunchMethod.png" style="zoom:30%" />
- 最后修改 Build Setting 中的 "Order File" 配置项,值为 `.order` 文件的路径信息。
完整 Demo 可以查看 [BlogDemos:BinarayOrderExplore](https://github.com/FantasticLBP/BlogDemos/tree/master/BinarayOrderExplore)