从野指针探测到对iOS 15 bind 的探索

·  阅读 1977

从野指针探测说起

前段时间58旗下本地版APP上出现了较多的野指针崩溃,崩溃堆栈没有太多有效信息,只是告诉崩溃发生在自动释放池释放对象的时候。

堆栈

相关问题极难复现,在开发阶段我们先后开启了zombiescribbleAddressSanitizer检测,但是实际经过大量的测试并没有复现相关崩溃,问题比较难定位。为了定位相关问题,我们部署了线上野指针探测工具,通过相关工具可以捕捉到崩溃发生时的堆栈以及野指针对象的类型,如果有必要,还可以捕捉对象释放时的轻量堆栈。

轻量堆栈:为了节省内存,我们只保存了APP的调用轨迹的堆栈,并且通过偏移地址运算将地址从8字节优化为4字节,尽量节省空间。

线上野指针比较难的一点就在于如何确定到底是哪些类出现了问题,只有明确了发生的类型才能建立有效的监控。如果不能明确过度释放的类名,那么只能开启全量监控捕捉类名,然后再进一步根据类名开启对应的堆栈监控。但是全量监控会因为监控范围过大影响APP性能和监控的有效性。为了先确定野指针发生的类型,我们牺牲了一部分内存(30MB)作为缓存,广撒网式地全方位轻量探测,期望在明确类型以后再开启抓取释放时的堆栈信息以及野指针发生时的堆栈信息。因此我们将APP内我们自定义的类以及我们用到的系统类都被纳入到监控范围。

监控范围

符号的类型

那如何确定到底我们在项目中用到了哪些系统类呢?

获取当前APP用到的系统类有多种方式,例如可以通过nm 等命令获取到符号,并根据符号类型来判断内外符号。

nm 输出示例

细心的同学会发现nm输出的符号前会有U S D等字母修饰,其实这些字母就代表了符号的类型。

关于符号类型可以参考下符号的类型说明:

符号类型说明
A该符号的值是绝对的,在以后的链接过程中,不允许进行改变。这样的符号值,常常出现在中断向量表中,例如用符号来表示各个中断向量函数在中断向量表中的位置。
B该符号的值出现在非初始化数据段(bss)中。例如,在一个文件中定义全局static int test。则该符号test的类型为b,位于bss section中。其值表示该符号在bss段中的偏移。一般而言,bss段分配于RAM中
C该符号为common。common symbol是未初始话数据段。该符号没有包含于一个普通section中。只有在链接过程中才进行分配。符号的值表示该符号需要的字节数。例如在一个c文件中,定义int test,并且该符号在别的地方会被引用,则该符号类型即为C。否则其类型为B。
D该符号位于初始话数据段中。一般来说,分配到data section中。例如定义全局int baud_table[5] = {9600, 19200, 38400, 57600, 115200},则会分配于初始化数据段中。
G该符号也位于初始化数据段中。主要用于small object提高访问small data object的一种方式。
I该符号是对另一个符号的间接引用。
N该符号是一个debugging符号。
R该符号位于只读数据区。例如定义全局const int test[] = {123, 123};则test就是一个只读数据区的符号。注意在cygwin下如果使用gcc直接编译成MZ格式时,源文件中的test对应_test,并且其符号类型为D,即初始化数据段中。但是如果使用m6812-elf-gcc这样的交叉编译工具,源文件中的test对应目标文件的test,即没有添加下划线,并且其符号类型为R。一般而言,位于rodata section。值得注意的是,如果在一个函数中定义const char *test = “abc”, const char test_int = 3。使用nm都不会得到符号信息,但是字符串“abc”分配于只读存储器中,test在rodata section中,大小为4。
S符号位于非初始化数据区,用于small object。
T该符号位于代码区text section。
U该符号在当前文件中是未定义的,即该符号的定义在别的文件中。例如,当前文件调用另一个文件中定义的函数,在这个被调用的函数在当前就是未定义的;但是在定义它的文件中类型是T。但是对于全局变量来说,在定义它的文件中,其符号类型为C,在使用它的文件中,其类型为U。
V该符号是一个weak object。
W该符号是弱符号,尚未专门标记为弱对象符号。
-该符号是a.out格式文件中的stabs symbol。
?该符号类型没有定义

符号类型说明摘选自:提米果的博客

我们可以看到如果符号类型为U,则说明该符号为外部符号。比如UIViewControllerUIViewController 在我们的二进制文件中没有对应的实现和定义,它是在UIKit中实现和定义的,那UIViewController对我们来说就是外部符号,因此会用U来修饰。因此通过nm命令结合符号类型说明就可以拿到当前项目中用到的所有系统类。当然这种获取不包括动态调用方式。

思考:大家可以思考下linkmap文件是否也可以获取到APP中用到的类?如何实现呢?

提示:可以借助linkmap中的文件索引来判断符号的来源。如果文件索引对应的是系统库,那么就是外部符号,也就是我们用到的系统类等。

如果用命令结合文本分析的方式来获取系统类,会带来额外的版本流程。可能在每次打包时我们都要先分析导出一份系统类列表内置到APP中,这显然令目前本已复杂的打包流程更是雪上加霜。因此思考如何在APP内部运行期拿到所有用到的系统类。

联想到了bind

接着上面的例子,既然UIViewController不是在我们的APP中定义的,那么它的地址我们在APP运行前肯定不知道。只有在bind之后我们才能拿到对应的地址。在二进制文件中,如果我们用到了UIViewController,实际上在二进制文件中都是用0x0000000000000000来进行"占位"。在经过bind后,对应的地址才会被替换为UIViewController的真实地址。那系统是如何实现bind的呢?在实际开发中,我们很多地方都用到了UIViewController(例如MyViewController继承自UIViewController),系统在bind的时候一定会逐一对地址进行修正,这就意味着我们的二进制文件中一定会有信息记录到底哪些地方用到了哪些类,否则系统无法进行修正。

晦涩的 LC_DYLD_INFO_ONLY

压缩字节流

bind信息存储在LC_DYLD_INFO_ONLY中,通过MachOView我们可以直观的看到相关的数据。

LC_DYLD_INFO_ONLY 的结构

LC_DYLD_INFO_ONLY(详见下面的代码注释)记录的是压缩字节流的偏移和长度,这些压缩字节流是在dyld加载镜像时所需的数据。

/*
 * The dyld_info_command contains the file offsets and sizes of 
 * the new compressed form of the information dyld needs to 
 * load the image.  This information is used by dyld on Mac OS X
 * 10.6 and later.  All information pointed to by this command
 * is encoded using byte streams, so no endian swapping is needed
 * to interpret it. 
 */
public struct dyld_info_command 
复制代码

压缩字节流是LC_DYLD_INFO_ONLY数据压缩存储的一种格式,按照特定的解法解析可以获取到相应的数据。苹果在这里用压缩字节流存储rebase & bind 信息是在保证解析效率的前提下尽量节省存储空间

在iOS 15以前,iOS的bind可以分为3种类型,分别是bindlazy_bindweak_bind,iOS 15之后去除了lazy_bind

这里的iOS 15 指的是 iOS Deployment Target 设置为iOS 15 ,而不是指在iOS 15系统运行。因为 iOS Deployment Target < 15 打出来的包需要在iOS 14等低系统上运行,因此打包出来的二进制不是真的具有iOS 15的新特性。iOS Deployment Target 设置为iOS 15 后,低端系统不再支持,所以iOS 的二进制文件才会有所变化。

iOS 对 bind的压缩字节流介绍如下:

 /*
     * Dyld binds an image during the loading process, if the image
     * requires any pointers to be initialized to symbols in other images.  
     * The bind information is a stream of byte sized 
     * opcodes whose symbolic names start with BIND_OPCODE_.
     * Conceptually the bind information is a table of tuples:
     *    <seg-index, seg-offset, type, symbol-library-ordinal, symbol-name, addend>
     * The opcodes are a compressed way to encode the table by only
     * encoding when a column changes.  In addition simple patterns
     * like for runs of pointers initialzed to the same value can be 
     * encoded in a few bytes.
     */
    public var bind_off: UInt32 /* file offset to binding info   */
    
复制代码

简单来说就是从bind 的压缩字节流中,我们可以提取出来一个元组列表。其含义如下:

    <seg-index, seg-offset, type, symbol-library-ordinal, symbol-name, addend>
  	👇
    <bind需要修改的地址在哪个段, 这个地址距离段的偏移, 类型(如Pointer), 这个地址所在的lib库, 符号名, addend>
复制代码

我们通过MachOView查看下bind信息,经过MachOView解析好后的数据,我们可以轻易读懂相应内容。

解析好的bind信息展示

图中表示的意思就是VM Address 0x100015D50 ~ 0x100015D58 这连续8字节的内容代表的是NSObject。因此在bind 时,系统可以清楚地知道0x100015D50 ~ 0x100015D58需要修改为哪个类的地址。

lazy_bindbind的数据格式一致,提取解析后也是元组列表。存储在lazy_bind的符号并不是启动立即绑定,而是在相关符号首次使用的时候的触发绑定。一般来说NSLog函数是lazy_bind,那么在APP首次使用NSLog才会触发相应的绑定。

那究竟如何将压缩字节流解析为元组列表呢?

压缩字节流的解析

bind 信息的读取有一套特定的"语法"。在这套“语法”内,一个长度为8 bit的字节被拆分为2部分,高4位代表指令,低4位代表数据。

#define BIND_OPCODE_MASK					0xF0
#define BIND_IMMEDIATE_MASK					0x0F
复制代码

因此一个字节可以标识16种指令和16以内的数据。我们以图中数据流中第一个字节为例,offset = 0x181A0处的数据为0x12

解析示例1

let opcode = 0x12 & BIND_OPCODE_MASK  
let immediate = 0x12 & BIND_IMMEDIATE_MASK
复制代码

通过运算后就可以发现 指令为 opcode = BIND_OPCODE_SET_DYLIB_ORDINAL_IMM == 0x10 数据为 immediate == 0x02BIND_OPCODE_SET_DYLIB_ORDINAL_IMM 的意思为后面的数据代表库的索引值,即库的索引值为0x02。通过这一个字节,我们能获取到元组中的一个数据。

解析示例2

与此类似,我们将紧接着解析下个字节0x400x40的指令为BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM,这条指令告诉我们后面的数据为一个symbol字符串。因此可元组中的字符串也能获取到。依次类推,遍历完成整个字节流后即可完整得到元组列表,相关代码大家可以参考dyld || MachOView || WBBlades 。这里需要提一下,如果数据长度超过4 bit,压缩字节流会通过特殊的指令告诉我们从下个字节开始是什么数据,包括数据的类型及编码格式。例如BIND_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB 这条指令就会告诉我们当前这个字节的低4 bit代表段的索引,并且还告诉我们从这个字节以后的连续N字节代表指针距离段的偏移,并且还告诉我们编码格式是ULEB格式。

ULEB128:Unsigned Little Endian Base 128

SLEB128:Signed Little Endian Base 128

ULEB128和SLEB128是为了解决存储数据浪费而提出的变长编码格式,适合存储一些数据通常较小但是偶尔较大的数。在iOS中,常见的数据类型都是定长的,例如UInt8就是1个字节,能表示的数据范围为0 ~ 63。UInt16就是2个字节能表示的数据范围为0 ~ 65535。那假设有这样的数组[ 1 , 2 , 3 , 4 , 160 , 0 , 1 , 2 , 3 , 4 , 5 , 7],那数组该定义成什么类型呢?显然UInt8(1Byte)不够用,UInt16(2Byte)又有些浪费。因此如果数据能弹性伸缩就能解决存储空间浪费的问题,ULEB128和SLEB128能解决此类问题。以ULEB128为例,ULEB128长度最少可以为1字节,通常少于5字节。每个字节拆分为2部分,最高位为标记位,用于表示是否有后续字节,如果最高位为1则说明后续有字节跟随,为0则说明当前字节是数据的最后一个字节。每个字节的低7 bit用于表示数据。

举个例子:

0xA001,占据2个字节,其二进制表示为:'10100000 00000001'。

Step1: 去除标记位后 -> '0100000 0000001'

Step2: 小端调整 -> '0000001 0100000'

因此结果为 '10100000' ,即十进制的160

再看fishhook

简单认识fishhook

通过解析bindlazy_bindweak_bind可以获取到元组列表,每个元组会告诉我们符号和指针信息,指针信息包括指针位于哪个段以及在段的偏移。等等,元组列表是不是类似符号表?那是不是可以元组列表做点什么事情呢?看到这里可能有同学会跟我一样想到了fishhook。Fishhook 是Facebook 开源的一个C函数hook 框架。它能够在运行期间hook 一些外部C函数(在APP内我们自己写的函数hook 不到,因为不涉及bind)。

介绍fishhook原理的文章非常多,因此有关fishhook的具体实现细节在这里不做讨论,感兴趣的同学可以搜索相关文章了解下原理。想直接并且简单地了解fishhook大体思路的同学可以阅读下面的总结。

在iOS中,我们使用用变量或类似NSLog()等外部函数并不是直接调用地址,而是在经过bind 或 lazy_bind后才能得到真正的地址。bind或lazy_bind后真正的函数地址记录在 nl_symbol_ptrla_symbol_ptr中,通过符号表可以找到每个函数对应在nl_symbol_ptrla_symbol_ptr中的地址。fishhook 就是通过查找符号表,找到记录函数指针的地址修改函数指针从而实现C函数的hook。

bind是在加载镜像的时候就就已经绑定,而lazy_bind是在首次使用时才触发绑定。

思考:lazy_bind是如何实现在首次调用函数时进行bind的呢?

打个比方来解释:假设张三和李四是同学,老师手里有个名单,这个名单上记录着要参加值日的同学。本来今天应该是李四值日,但是由于打印名单时教务处老师不知道李四的名字,因此打印了班长张三的名字。老师只认名单,因此老师找来张三打扫卫生。但是张三只做了一件事情,就是把名单上的名字改成了李四,并且叫李四来打扫卫生。这样老师以后如果再吩咐打扫卫生的事情时就直接找到了李四。这就是lazy_bind。故事中老师就是我们写的代码,代码只认地址。名单就是la_symbol_ptr,上面记录了值日同学名。张三就是stub机制,它只是起到了辅助作用。而李四则是真正的外部函数,需要真正执行的函数。

有关fishhook 近期的改动是比较有意思的,前端时间经常通过大家反馈fishhook 在iOS 14.5 上的崩溃,以及在iOS 15上的崩溃。主要问题在于fishhook 在写入指针的时候写入保护引起的。fishhook #87 Pr 做了解释和修改。

C 函数的hook 可不止fishhook一种方案

除了fishhook外,笔者也有一种C函数的静态hook方式,相比于fishhook,此方案不存在耗时的查找比对操作。下面我将介绍这种比较特殊的方案:基于动态库的C函数hook 。

动态库hook 流程图

方案没有任何代码,但是比较难理解。

Step1: 首先在主工程中定义一个同名同参同返回的函数,这样在ld64链接时会认为func1func2 中用到的NSLog是我们自定义的函数,这样就不会跟系统库的函数进行匹配,NSLog也就不会被标记为需要bind的函数。

Step2: 在我们自定义的NSLog内部,我们调用自定义动态库的中间函数MyNSLog,这一步是为了能够调用到真正的NSLog

Step3: 由于动态库中我们没有自定义NSLog去“欺骗”ld64,因此动态中的NSLog会去调用真正的系统函数。

到这里可能有同学会问,“难道动态库的NSLog不存在重新调用到主程序的NSLog函数的风险吗?那样岂不是会死循环?”

不会的。因为动态库是具备编译和链接过程的产物。经过链接时,在二进制文件中就已经写定了NSLog bind到系统库中的NSLog了,因此在启动阶段dyld不会“违抗”二进制的命令执行到主程序的NSLog

这套方案在同城APP上有所应用,已经在线上运行将近1年。但是由于侵入性较强,仅对部分需要同步启动用到的函数使用。fishhook 还是项目中最主要的使用方式。

第三种C函数hook 方案

上文中我们提到了压缩字节流中提取的元组列表包含了地址和符号的映射。那么是不是可以借助元组列表实现C函数的hook呢?经过demo实践发现是可行的。代码已经上传到WBBlades->ChubbyCat,GitHub 搜索WBBlades,demo在ChubbyCat目录下。

typealias MYNSLogType = @convention(thin) (_ format: String, _ args: CVarArg...)  -> Void
func MYNSLog(_ format: String, _ args: CVarArg...){
    print("test success")
}

class Test: NSObject {
    func test() {
        let replacement = unsafeBitCast(MYNSLog as MYNSLogType, to: UInt64.self)
        let ret = ChubbyCatHook.replaceC(name: "_NSLog", replacement:replacement)
        print("hook result = \(ret)")
    }
}
复制代码

但是这个方案在iOS Deployment Target == 15上会失效,如果想进一步解决问题需要在线上从沙盒下获取磁盘文件进行辅助解析,得不偿失,因此仅做学习和交流使用。

风云再起,iOS 15 的LC_DYLD_CHAINED_FIXUPS

在iOS15 上,APP的rebase & bind 的方式发生了变化。

Deployment Target设置

如果我们将iOS Deployment Target设置为15的话,通过MachOView查看打包后的Mach-O文件会发现新的二进制上出现了不支持的LC。

新的LoadCommand

这是由于LC_DYLD_INFO_ONLY被替换成了新增的LC_DYLD_EXPORTS_TRIELC_DYLD_CHAINED_FIXUPS

图中另外一个不支持的LC为 LC_BUILD_VERSION: /* build for platform min OS version */

文件的变化意味着iOS 15的rebasebind机制发生了变化。回顾iOS 14及以前,dyld是通过解析压缩字节流实现了rebasebind。解析压缩字节会告诉dyld 整个二进制文件中有哪些地址需要修正,以及在bind时每个地址是为哪个外部符号预留。那iOS 15 dyld是如何进行过修正的呢?接下来我们探索下dyld

dyld3 ? dyld4?

前段时间听到有同学讨论iOS 15 dyld3 更新为dyld4了。笔者无法确定苹果是否偷偷地升级了dyld,但是从蛛丝马迹中可以看出来dyld 确实是有变化,例如在instrument 中我们可以看到部分函数的命名空间变成了dyld4。还有就是一些API的调用上发生了一些变化,例如:

let header:UnsafePointer<mach_header> = _dyld_get_image_header(0)
复制代码

在iOS 15系统之前通过索引获取header时,如果index == 0,返回的是可执行程序的header。但是在iOS 15中,index == 0获取到的却是系统库。当然这些变化对我们的业务代码可能还不足以产生影响,但是可以说明dyld 确定是有改动。那LC_DYLD_CHAINED_FIXUPSdyld的新特性吗?我的答案是否定的。因为从dyld3dyld-852.2版本中可以看到LC_DYLD_CHAINED_FIXUPS早就预埋在dyld中了,只不过在iOS Deployment Target == 15时引起Mach-O文件变化后,才能进入相应的代码分支。

回归对bind的探索

iOS 15上rebase & bind发生的变化,iOS 15 如何让你的应用启动更快 (附英文原文) 一文做了介绍。感兴趣的同学可以细读此文,想直接看结论的同学可以看下面的文字,我对文章的内容做概括和总结。

在iOS 15中,原本用于rebase & bind 的压缩字节流被替换,取而代之的是fixup-chains(链表结构)。在iOS 启动时,dyld 先判断是否存在fixup-chains,如果存在fixup-chains 则按照fixup-chains的方式进行解析,否则还是按照压缩字节流的方式解析。解析的目的是为了将应用程序的地址进行修正。fixup-chains 机制是由三层结构进行存储,分别是segment(段)-> pages(页) -> fixup-chains(指针链表) 组成。LC_DYLD_CHAINED_FIXUPS所指向的数据会告诉我们有多少segments,每个segment的信息又会告诉我们这个segment有多少pages,以及每个page 的fixup-chains在哪里。 而 fixup-chains中的指针指向了当前page中每一个需要rebase 或者 bind的地址,这些地址中存储的数据并非像iOS 15之前那样都是0x00,而是有一定格式的具有一定意义的8字节数据。而这短短的8字节数据被按照不同的结构体拆分成多个bit,每个或连续几个bit都具有其特殊的含义用于推断rebase 或 bind 所需要的一切信息。iOS 15废除了lazy_bind(weak_bind仍然保留),由于rebase和bind 被整合为一个链表,因此遍历一次链表即可完成一个page所需的rebase和bind。

那fixup-chains为什么能加快启动呢?

因为在iOS 15以前,rebase和bind的信息在压缩字节流中是分别存储的。这就意味着,在启动时dyld在做rebase时会先遍历一遍rebase压缩字节流所记录的地址进行地址修改,假设为N次page fault,由于经过rebase 的page 是被写入数据的dirty page,因此不会被释放,iOS 会通过压缩的方式优化最近没有使用到的dirty page。然后在进行bind时,又遍历bind压缩字节流所记录的那些地址进行修改,假设需要bind M个page。那么在N和M这两个Pages集合中可能存在很多重叠,这就造成了二次遍历,并且iOS可能对其中某些dirty page做了压缩优化。在这种情况下,bind时就需要对这些重叠的pages做解压操作。而fixup-chains很巧妙地解决了这个问题,因为同一个page的rebase和bind整合成一个链表,同时进行这两种操作,这样就不会存在重复遍历相同的page,也不会存在解压的问题。

疑问

fixup-chains 会减少page falut次数吗?:不会,依旧是M N

有人问这个算不算iOS 帮我们做了二进制重排?:完全是两回事。虽然都提到了page fault,但是阶段是不同的。

重回野指针探测

重新回到文章的开头,在做野指针探测的时候我们通过解析压缩字节流获取到了项目中所有的被使用到的系统类。那在iOS 15没有压缩字节流的情况下,我们如何利用fixup-chains获取到项目中的类呢?

查看头文件可以发现,我们通过LC_DYLD_CHAINED_FIXUPS所指向的结构体linkedit_data_command,可以找到当前文件的fixups_header

从LC_DYLD_CHAINED_FIXUPS 找到 fixups_header

fixups_header是整个fixups信息的入口,具体信息如下:

// header of the LC_DYLD_CHAINED_FIXUPS payload
struct dyld_chained_fixups_header
{
    uint32_t    fixups_version;    // 0
    uint32_t    starts_offset;     // offset of dyld_chained_starts_in_image in chain_data
    uint32_t    imports_offset;    // offset of imports table in chain_data
    uint32_t    symbols_offset;    // offset of symbol strings in chain_data
    uint32_t    imports_count;     // number of imported symbol names
    uint32_t    imports_format;    // DYLD_CHAINED_IMPORT*
    uint32_t    symbols_format;    // 0 => uncompressed, 1 => zlib compressed
};
复制代码

结构体中其他信息我们暂不做介绍,在这里我们只关注symbols_offsetsymbols_offset记录的是符号链表的偏移。symbols_offset 并不是我们常说的符号表,这里存储的是bind所需的字符串,这里所记录的符号并没有复用在strtab中的数据,推测可能是为了用空间换取时间,相对于bind启动的消耗,这点存储空间应该不算什么。symbols_offset的大概位置见下图👇🏻:

symbols_offset的位置

symbols_offset位于原先存储压缩字节流的位置,由于MachOView并不支持fixups的展示,因此在图中看不到相应的数据。

回到symbols_offset,通过遍历字符串表我们就很容易拿到所有的外部符号。

//symbols_format: 0 => uncompressed
if fixup.pointee.symbols_format == 0 {
    print("import count = \(fixup.pointee.imports_count)")
    let symbolStarts = UInt(fixup.pointee.symbols_offset) + ptr
    var length : UInt = 0
    for _ in 0 ..< fixup.pointee.imports_count {
        let location = symbolStarts + length
        let symbol = UnsafeMutablePointer<CChar>(bitPattern: UInt(location))
        if let name = symbol{
            length += UInt(strlen(name)) + 1
            print(String.init(cString: name))
        }
    }
}else if fixup.pointee.symbols_format == 1{
    //TODO: zlib compressed
}
复制代码

在上面的demo中,打印片段如下:

...
...
_OBJC_CLASS_$_UISceneConfiguration
_OBJC_CLASS_$_NSException
_OBJC_CLASS_$_UIViewController
_OBJC_METACLASS_$_UIViewController
_OBJC_METACLASS_$_NSObject
_OBJC_METACLASS_$_UIResponder
_OBJC_CLASS_$_UIResponder
复制代码

请记住这里打印的信息,我们暂且称之为 imports symbols,后续的内容会用上它

到这一步,我们的野指针探测所需的数据即使在最低版本iOS 15编译的包上也可以应用。但是,细心的同学肯定会有疑问,iOS 的bind是需要知道在哪个地址需要绑定哪个库的哪个符号,我们这里只是打印了所有的符号,那fixup-chains到底是如何准确知道符号和地址的关系从而实现bind的呢?比如我们要调用NSLog()函数,在iOS 15之前,压缩字节流会告诉我们哪个地址对应的8字节是NSLog()函数的指针。并且在bind之前,使用8字节的0x00来填入文件的某个位置,假设这个位置是0x140000字节处,那么在0x14000之后的8个字节都是0x00。在iOS 15时,0x140000字节处存储的就不再是0x00了。而是类似下图中的特定的8字节数据。那0x801000000000000A到底代表的是哪个函数呢?

需要bind的位置是特殊含义的数字

fixup-chains如何实现bind

我们先回顾下上文中提到的结构体dyld_chained_fixups_headerdyld_chained_fixups_header结构体中的starts_offset指向了当前镜像内的需要做rebase& bind段信息的数据,也就是dyld_chained_starts_in_image结构体。dyld_chained_starts_in_image结构体中,前4字节代表当前镜像需要做rebase& bind的段的个数,随后是具体每个段的偏移。

dyld_chained_starts_in_image 如何找到每个段信息

在获取到每个段的偏移信息后,我们就可以找到每个段的fixups信息结构体dyld_chained_starts_in_segment:

struct dyld_chained_starts_in_segment
{
    uint32_t    size;               // size of this (amount kernel needs to copy)
    uint16_t    page_size;          // 0x1000 or 0x4000
    uint16_t    pointer_format;     // DYLD_CHAINED_PTR_*
    uint64_t    segment_offset;     // offset in memory to start of segment
    uint32_t    max_valid_pointer;  // for 32-bit OS, any value beyond this is not a pointer
    uint16_t    page_count;         // how many pages are in array
    uint16_t    page_start[1];      // each entry is offset in each page of first element in chain
};
复制代码

查询dyld_chained_starts_in_segment信息我们会发现,我们能够拿到当前段内的所有pages信息。其中page_start会告诉我们每个pagefixup-chains在这个page的偏移。

段、页、链表 之间的位置关系

由于段是由一系列连续的pages组成,因此只要知道page的固定大小以及page的个数,那么就能遍历到整个段的所有fixup-chains的起始地址。

for pageIndex in 0 ..< segment.pointee.page_count {
    let offsetInPage : UInt16 = segment.pointee.page_start + 16 * pageIndex;
    if offsetInPage == DYLD_CHAINED_PTR_START_NONE {
        continue
    }
    //32-bit chains which may need multiple starts per page
    if (offsetInPage & UInt16( DYLD_CHAINED_PTR_START_MULTI)) != 0 {
        print("multiple starts per page")
    }else{
        // one chain start per page
        let chainStart = UInt64(headPtr) + segment.pointee.segment_offset + UInt64(offsetInPage) + UInt64(pageIndex * segment.pointee.page_size)
        let chainContentPtr = UnsafePointer<UInt>(bitPattern: UInt(chainStart))
        print("page:\(pageIndex) first pointer = \(String(format: "0x%llx", chainStart)) chainContent = \(String(format: "0x%llx", (chainContentPtr?.pointee ?? 0)))")
    }
}
复制代码

上面代码是从 dyld-852.2 中抽象提取的。 通过上面的代码,我们能获取到每个段的每个页的fixup-chains链表。

实际上在这一步,我们获取到的是链表的表头,也就是第一个元素。那如何获取到链表中的下一个元素呢?我们可以简单理解为链表中每个元素都有几个bit 来标识下个指针距离此处的偏移。那到底是几个bit呢?在arm64中,如果stride = 4,则需要12 bit。如果stide = 8,则需要11 bit。

举例说明:如果stride = 4 && next = 1,则说明下个指针距离此处为stride * next = 4个字节。如果stride = 8 && next = 10,则说明下个指针距离此处为stride * next = 80个字节。

总之,无论是next 用11 bit表示还是12 bit表示,总能覆盖一个page 16KB的范围。

在获取到链表中的数据后,实际上我们知道了地址和数据,但是对应的符号还是未知的。

地址数据未知symbol
0x10453c0000x801000000000000A"_NSLog"
0x10453c0080x801000000000000B"_printf"
............

列表中数据为了方便大家理解写的是bind之前的数据。例如0x10453c000这个指针的指向的内容在运行期间不可能获取到0x801000000000000A,而是类似0x185fd0150这样的函数地址。这个很好理解,我们的代码在bind之后运行,除非我们去获取磁盘中的Mach-O文件,否则不可能读取到0x801000000000000A这样的原始值,除非从磁盘中的文件中获取。

因此我们想知道如何通过0x801000000000000A获取到函数名_NSLog

在上文的dyld_chained_starts_in_segment结构体中,存在一个成员pointer_formatpointer_format是描述当前这个段的数据解析格式的。一共有12种解析类型。

// values for dyld_chained_starts_in_segment.pointer_format
enum {
    DYLD_CHAINED_PTR_ARM64E                 =  1,    // stride 8, unauth target is vmaddr
    DYLD_CHAINED_PTR_64                     =  2,    // target is vmaddr
    DYLD_CHAINED_PTR_32                     =  3,
    DYLD_CHAINED_PTR_32_CACHE               =  4,
    DYLD_CHAINED_PTR_32_FIRMWARE            =  5,
    DYLD_CHAINED_PTR_64_OFFSET              =  6,    // target is vm offset
    DYLD_CHAINED_PTR_ARM64E_OFFSET          =  7,    // old name
    DYLD_CHAINED_PTR_ARM64E_KERNEL          =  7,    // stride 4, unauth target is vm offset
    DYLD_CHAINED_PTR_64_KERNEL_CACHE        =  8,
    DYLD_CHAINED_PTR_ARM64E_USERLAND        =  9,    // stride 8, unauth target is vm offset
    DYLD_CHAINED_PTR_ARM64E_FIRMWARE        = 10,    // stride 4, unauth target is vmaddr
    DYLD_CHAINED_PTR_X86_64_KERNEL_CACHE    = 11,    // stride 1, x86_64 kernel caches
    DYLD_CHAINED_PTR_ARM64E_USERLAND24      = 12,    // stride 8, unauth target is vm offset, 24-bit bind
};
复制代码

为了搞清楚bind查找symbol的机制,我们选择其中的一种类型DYLD_CHAINED_PTR_64来说明。查询fixup-chains.h我们可以找到DYLD_CHAINED_PTR_64对应的8字节结构如下:

// DYLD_CHAINED_PTR_64
struct dyld_chained_ptr_64_bind
{
    uint64_t    ordinal   : 24,
                addend    :  8,   // 0 thru 255
                reserved  : 19,   // all zeros
                next      : 12,   // 4-byte stride
                bind      :  1;   // == 1
};
复制代码

dyld中,如果类型为DYLD_CHAINED_PTR_64,那么dyld会按照下面的方式进行获取bind要绑定的值。

if ( fixupLoc->generic64.bind.bind ) {
    newValue = (void*)((long)bindTargets[fixupLoc->generic64.bind.ordinal] + fixupLoc->generic64.signExtendedAddend());
}
复制代码

在这里我们不需要对这段代码有什么了解,我们只需要知道两件事情:

  • bind bindTargets数组有关,这也就意味着symbol与bindTargets有关。
  • bind 与 上面结构体dyld_chained_ptr_64_bind 中的ordinal有关。这也就意味着symbol与ordinal有关。

那接下来我们需要看下bindTargets是如何生成的,它的顺序又是什么。在dyld3中有如下代码:

for (uint32_t i=0; i < header->imports_count && !stop; ++i) {
    const char* symbolName = &symbolsPool[imports[i].name_offset];
   	...
   	...
    //一直回调,在回调中不断向 bindTargets 存入数据。
    callback(libOrdinal, symbolName, 0, imports[i].weak_import, stop);
}
复制代码

在上面的代码中,我们知道callback不断向 bindTargets 存入数据。回想下我们在上文中提到了imports信息并且获取到了imports 所对应的所有symbols(也就是上文中让大家记住的 imports symbols)。

这就意味着ordinal == 0则意味着bindTargets中的第一个元素的符号为imports symbols的第一个字符串。

因此dyld获取到二进制文件中0x801000000000000A后,通过dyld_chained_ptr_64_bind解析就知道其ordinal0x0A = 10。其对应的符号为imports symbols0x0A个符号,在我的测试demo中为"_NSLog"。

总结

至此,我们从野指针探测引起的探索都已经介绍完成,在这里做个简单的总结:

  • 野指针探测我们期望能拿到所有的使用到的 系统类,如UIView等,未使用到的系统类不想拿到。
  • 介绍了符号的类型的概念,以及如何通过命令+文本的方式达成上述目的。
  • 介绍了压缩字节流的概念和解析方法。
  • 简单介绍了fishhook的工作流程以及关键改动,并提出了通过压缩字节流的符号和地址映射关系(即上文中的元组),不使用符号表实现C函数的替换。
  • 基于iOS 15 打包的文件不再具有压缩字节流,而是采用fixup-chains来实现高效地rebase & bind
  • 通过解析imports我们能获取到所有的用到的系统符号,这已经能满足我们野指针探测的需要。
  • 探索了bind的符号和地址是如何映射的,最终得到了bind中的ordinalimports symbols数组的索引下标的结论。

作者自述

大家好,我叫邓竹立,目前就职于 58同城-用户与价值增长中心-平台技术部。非常感谢大家能耐着性子看完这万字长文。写这篇文章的原因是最近做了很多的技术调研,但是没有将这些内容整理下来,因此想着通过一篇文章将自己探索和发现记录下来,或许有人会用到。

由于个人能力和时间的限制,相关结论和观点难免会有纰漏,如果您在阅读过程中有任何问题都可以留言或者加我微信进行探讨。

我是一个乐于分享也喜欢总结的人,因为分享和总结会促使我进行更深入的思考,也会反思自己的策略和方案是否能经得起推敲。有关动态库懒加载技术日志符号化APP卡死野指针探测Mach-O探索等话题可以留言交流下~

参考:

1 、fishhook #87 : 关于fishhook 崩溃及修复方案的讨论。

2、fishhook的实现原理浅析 : 有关fishhook原理解释的很好的文章。

3、为什么 iOS 14.5 下 fishhook 会 crash : 关于fishhook崩溃的解释。

5、给实习生讲明白 Lazy/Non-lazy Binding : 对lazy_bind解释的很清晰的文章。

6、iOS 15 如何让你的应用启动更快 : 有关iOS 15 fixup 少有的文章。

7、dyld :dyld源码下载地址。

8、radare : 关于fixup-chains解析的三方代码。

9、提米果的博客 : 符号类型介绍参考文章。

10、How Apple has supercharged app launching in iOS 15 and macOS Monterey : 有关iOS 15 fixups的另一篇文章,但是基本上在介绍Noah Martin 的文章内容。

分类:
iOS
标签:
收藏成功!
已添加到「」, 点击更改